140 lines
4.5 KiB
Go
140 lines
4.5 KiB
Go
package harness
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
)
|
|
|
|
// redactionMarker replaces each credential redact removes.
|
|
const redactionMarker = "[REDACTED]"
|
|
|
|
var (
|
|
// chatWebhookURL matches a Google Chat webhook URL, with or without
|
|
// its scheme.
|
|
chatWebhookURL = regexp.MustCompile(`(?i)(?:https?://)?chat\.googleapis\.com/[^\s"'<>]*`)
|
|
// schemeURL matches a URL with a scheme, up to whitespace, a quote or
|
|
// an angle bracket.
|
|
schemeURL = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^\s"'<>]+`)
|
|
// credentialHeader matches an Authorization or API-key header, or a
|
|
// field so named, and its value up to the end of the line or a quote.
|
|
// No word boundary is required before the name, so escaped text such
|
|
// as a JSON "\nAuthorization: …" matches too.
|
|
credentialHeader = regexp.MustCompile(`(?i)(` + credentialHeaderNames + `)(["']?[ \t]*[:=][ \t]*["']?)[^"'\r\n]*`)
|
|
)
|
|
|
|
// credentialHeaderNames are the header names whose values are credentials.
|
|
const credentialHeaderNames = `proxy-authorization|authorization|x-goog-api-key|x-api-key|api-key`
|
|
|
|
// credentialField matches a JSON object key naming a credential header.
|
|
var credentialField = regexp.MustCompile(`(?i)^(?:` + credentialHeaderNames + `)$`)
|
|
|
|
// credentialParams are the substrings that make a URL query or fragment
|
|
// parameter name key-like, compared in lower case.
|
|
var credentialParams = []string{"key", "token", "secret", "sig", "pass", "auth", "credential"}
|
|
|
|
// redact returns s with credentials removed: every occurrence of apiKey
|
|
// (the decrypted API key, or "" when there is none), Google Chat webhook
|
|
// URLs, URLs with a key-like query or fragment parameter or with user
|
|
// info, and the values of Authorization and API-key headers. Each becomes
|
|
// "[REDACTED]".
|
|
//
|
|
// When s is a JSON document its strings are redacted after decoding, so
|
|
// JSON escapes cannot hide a credential from the patterns, and it is
|
|
// encoded again. Any other s is redacted as text.
|
|
func redact(s, apiKey string) string {
|
|
var doc any
|
|
dec := json.NewDecoder(strings.NewReader(s))
|
|
dec.UseNumber()
|
|
if strings.TrimSpace(s) != "" && json.Valid([]byte(s)) && dec.Decode(&doc) == nil {
|
|
var b bytes.Buffer
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(redactJSON(doc, apiKey)); err != nil {
|
|
// A decoded document always encodes; were it not to, it is
|
|
// still redacted, as text.
|
|
return redactText(s, apiKey)
|
|
}
|
|
// The encoding can join text into the key; Redact deletes it.
|
|
return llm.Redact(strings.TrimSuffix(b.String(), "\n"), apiKey)
|
|
}
|
|
return redactText(s, apiKey)
|
|
}
|
|
|
|
// redactJSON returns the decoded JSON value v with every string and object
|
|
// key redacted, and the values of credential header fields replaced.
|
|
func redactJSON(v any, apiKey string) any {
|
|
switch v := v.(type) {
|
|
case string:
|
|
return redact(v, apiKey)
|
|
case []any:
|
|
for i, e := range v {
|
|
v[i] = redactJSON(e, apiKey)
|
|
}
|
|
return v
|
|
case map[string]any:
|
|
out := make(map[string]any, len(v))
|
|
for k, e := range v {
|
|
if credentialField.MatchString(k) {
|
|
e = redactionMarker
|
|
}
|
|
out[redact(k, apiKey)] = redactJSON(e, apiKey)
|
|
}
|
|
return out
|
|
}
|
|
return v
|
|
}
|
|
|
|
// redactText redacts s as plain text.
|
|
func redactText(s, apiKey string) string {
|
|
s = llm.Redact(s, apiKey)
|
|
s = chatWebhookURL.ReplaceAllLiteralString(s, redactionMarker)
|
|
s = schemeURL.ReplaceAllStringFunc(s, func(u string) string {
|
|
if hasCredential(u) {
|
|
return redactionMarker
|
|
}
|
|
return u
|
|
})
|
|
s = credentialHeader.ReplaceAllString(s, "${1}${2}"+redactionMarker)
|
|
// A removal can join text either side into the key; Redact deletes
|
|
// it until none remains.
|
|
return llm.Redact(s, apiKey)
|
|
}
|
|
|
|
// hasCredential reports whether URL u carries user info or a key-like
|
|
// query or fragment parameter. A parameter name that does not unescape
|
|
// counts as key-like, so it is never kept unexamined.
|
|
func hasCredential(u string) bool {
|
|
_, rest, _ := strings.Cut(u, "://")
|
|
authority := rest
|
|
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
|
authority = rest[:i]
|
|
}
|
|
if strings.Contains(authority, "@") {
|
|
return true
|
|
}
|
|
i := strings.IndexAny(rest, "?#")
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
params := strings.FieldsFunc(rest[i+1:], func(r rune) bool { return strings.ContainsRune("&;?#", r) })
|
|
for _, p := range params {
|
|
name, _, _ := strings.Cut(p, "=")
|
|
name, err := url.QueryUnescape(name)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
name = strings.ToLower(name)
|
|
for _, c := range credentialParams {
|
|
if strings.Contains(name, c) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|