222 lines
7.9 KiB
Go
222 lines
7.9 KiB
Go
package kitutil
|
|
|
|
import (
|
|
"maps"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// maskURLPattern stops at characters a URL cannot contain unescaped, such
|
|
// as double quotes and angle or curly brackets, so a URL quoted in an error
|
|
// keeps its delimiters. Apostrophes and parentheses are valid inside a URL;
|
|
// the caller trims them, like other punctuation, only when they trail it.
|
|
maskURLPattern = regexp.MustCompile(`(?:https?|wss?)://[^\s"<>{}]+`)
|
|
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
|
// maskBracketedIPv6Pattern matches the bracketed form Go network errors use
|
|
// for IPv6 addresses, as in "dial tcp [2001:db8::1]:443", with an optional
|
|
// zone; the caller confirms the content parses as an IP.
|
|
maskBracketedIPv6Pattern = regexp.MustCompile(`\[([0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*)(%[^\]\s]*)?\]`)
|
|
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
|
|
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
|
)
|
|
|
|
// maskHostTail returns the tail parts of a domain/host that should be preserved.
|
|
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
|
|
func maskHostTail(parts []string) []string {
|
|
if len(parts) < 2 {
|
|
return parts
|
|
}
|
|
lastPart := parts[len(parts)-1]
|
|
secondLastPart := parts[len(parts)-2]
|
|
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
|
|
// Likely country code TLD like co.uk, com.cn
|
|
return []string{secondLastPart, lastPart}
|
|
}
|
|
return []string{lastPart}
|
|
}
|
|
|
|
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
|
|
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
|
|
func maskHostForURL(host string) string {
|
|
parts := strings.Split(host, ".")
|
|
if len(parts) < 2 {
|
|
return "***"
|
|
}
|
|
tail := maskHostTail(parts)
|
|
return "***." + strings.Join(tail, ".")
|
|
}
|
|
|
|
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
|
|
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
|
|
func maskHostForPlainDomain(domain string) string {
|
|
parts := strings.Split(domain, ".")
|
|
if len(parts) < 2 {
|
|
return domain
|
|
}
|
|
tail := maskHostTail(parts)
|
|
numStars := max(len(parts)-len(tail), 1)
|
|
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
|
|
return stars + "." + strings.Join(tail, ".")
|
|
}
|
|
|
|
// maskURLQuery keeps the parameter names of a raw query and masks the values.
|
|
// Example: key=secret&alt=sse -> ?alt=***&key=***
|
|
func maskURLQuery(rawQuery string) string {
|
|
if rawQuery == "" {
|
|
return ""
|
|
}
|
|
values, err := url.ParseQuery(rawQuery)
|
|
if err != nil {
|
|
// If can't parse query, just mask the whole query string
|
|
return "?***"
|
|
}
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
maskedParams := make([]string, 0, len(values))
|
|
for _, key := range slices.Sorted(maps.Keys(values)) {
|
|
maskedParams = append(maskedParams, key+"=***")
|
|
}
|
|
return "?" + strings.Join(maskedParams, "&")
|
|
}
|
|
|
|
// MaskSensitiveInfo masks the upstream addresses in str and leaves every other
|
|
// URL and domain readable. upstreamURLs are the channel base URL and the URL
|
|
// the gateway actually requested, which differs for routes such as absolute
|
|
// Advanced Custom paths; empty entries are skipped. Credentials and IPv4
|
|
// addresses are masked regardless of the upstream: API key values, the query
|
|
// values and userinfo of every URL, which carry secrets such as client_secret
|
|
// or key on provider endpoints, and IPs, which in network errors include the
|
|
// gateway's own address.
|
|
// Example with upstream https://api.upstream.example.com:
|
|
// https://api.upstream.example.com/v1/chat?key=secret -> https://***.com/***/***?key=***
|
|
// lookup api.upstream.example.com -> lookup ***.***.***.com
|
|
// dial tcp 192.168.1.1:443 -> dial tcp ***.***.***.***:443
|
|
// https://docs.example.org/errors -> unchanged
|
|
// https://auth.example.org/token?client_secret=s -> https://auth.example.org/token?client_secret=***
|
|
func MaskSensitiveInfo(str string, upstreamURLs ...string) string {
|
|
// Mask API keys (e.g., "api_key:test-api-key-for-redaction" -> "api_key:***")
|
|
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
|
|
|
|
upstreamHosts := make([]string, 0, len(upstreamURLs))
|
|
for _, upstreamURL := range upstreamURLs {
|
|
upstreamURL = strings.TrimSpace(upstreamURL)
|
|
if upstreamURL == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(upstreamURL, "://") {
|
|
// A base URL saved without a scheme would otherwise parse as a path.
|
|
upstreamURL = "//" + upstreamURL
|
|
}
|
|
parsed, err := url.Parse(upstreamURL)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// A fully qualified host keeps its trailing root dot; errors may omit it.
|
|
if host := strings.TrimSuffix(parsed.Hostname(), "."); host != "" {
|
|
upstreamHosts = append(upstreamHosts, host)
|
|
}
|
|
}
|
|
// Mask URLs on an upstream host, and the query values of every other URL
|
|
str = maskURLPattern.ReplaceAllStringFunc(str, func(match string) string {
|
|
urlStr := match
|
|
for urlStr != "" {
|
|
last := urlStr[len(urlStr)-1]
|
|
if strings.IndexByte(".,;:!?'", last) < 0 && (last != ')' || strings.Count(urlStr, "(") >= strings.Count(urlStr, ")")) {
|
|
break
|
|
}
|
|
urlStr = urlStr[:len(urlStr)-1]
|
|
}
|
|
suffix := match[len(urlStr):]
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return match
|
|
}
|
|
host := strings.TrimSuffix(u.Hostname(), ".")
|
|
if !slices.ContainsFunc(upstreamHosts, func(upstreamHost string) bool { return strings.EqualFold(upstreamHost, host) }) {
|
|
if u.RawQuery == "" && u.User == nil {
|
|
return match
|
|
}
|
|
// Userinfo is a credential wherever the URL points.
|
|
if u.User != nil {
|
|
scheme, afterScheme, _ := strings.Cut(urlStr, "://")
|
|
authorityEnd := strings.IndexAny(afterScheme, "/?#")
|
|
if authorityEnd < 0 {
|
|
authorityEnd = len(afterScheme)
|
|
}
|
|
urlStr = scheme + "://***@" + afterScheme[strings.LastIndex(afterScheme[:authorityEnd], "@")+1:]
|
|
}
|
|
if u.RawQuery == "" {
|
|
return urlStr + suffix
|
|
}
|
|
beforeQuery, _, _ := strings.Cut(urlStr, "?")
|
|
fragment := ""
|
|
if u.Fragment != "" {
|
|
fragment = "#" + u.EscapedFragment()
|
|
}
|
|
return beforeQuery + maskURLQuery(u.RawQuery) + fragment + suffix
|
|
}
|
|
|
|
maskedHost := maskHostForURL(host)
|
|
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
|
maskedHost = "***.***.***.***"
|
|
}
|
|
if port := u.Port(); port != "" {
|
|
maskedHost += ":" + port
|
|
}
|
|
|
|
result := u.Scheme + "://" + maskedHost
|
|
|
|
// Mask path
|
|
if u.Path != "" && u.Path != "/" {
|
|
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
maskedPathParts := make([]string, len(pathParts))
|
|
for i := range pathParts {
|
|
if pathParts[i] != "" {
|
|
maskedPathParts[i] = "***"
|
|
}
|
|
}
|
|
if len(maskedPathParts) > 0 {
|
|
result += "/" + strings.Join(maskedPathParts, "/")
|
|
}
|
|
} else if u.Path == "/" {
|
|
result += "/"
|
|
}
|
|
|
|
return result + maskURLQuery(u.RawQuery) + suffix
|
|
})
|
|
|
|
// Mask IP addresses: in network errors they are the resolved upstream
|
|
// address or the gateway's own, so they are masked even without an
|
|
// upstream address, and an IP upstream host is covered too. IPv6 goes
|
|
// first so an embedded IPv4 such as [::ffff:10.0.0.1] still parses.
|
|
str = maskBracketedIPv6Pattern.ReplaceAllStringFunc(str, func(match string) string {
|
|
if net.ParseIP(maskBracketedIPv6Pattern.FindStringSubmatch(match)[1]) == nil {
|
|
return match
|
|
}
|
|
return "[***]"
|
|
})
|
|
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
|
|
if len(upstreamHosts) == 0 {
|
|
return str
|
|
}
|
|
|
|
// Mask an upstream host where it appears without a scheme, as in
|
|
// "dial tcp: lookup api.example.com: no such host". Longer hosts go first
|
|
// so a subdomain upstream is masked whole before its parent base host.
|
|
slices.SortFunc(upstreamHosts, func(a, b string) int { return len(b) - len(a) })
|
|
for _, host := range upstreamHosts {
|
|
if net.ParseIP(host) != nil {
|
|
continue
|
|
}
|
|
hostPattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(host) + `\b`)
|
|
str = hostPattern.ReplaceAllStringFunc(str, maskHostForPlainDomain)
|
|
}
|
|
|
|
return str
|
|
}
|