fix: 修复代码审核发现的安全和质量问题

安全修复(P0):
- 移除硬编码的 OAuth client_secret(Antigravity、Gemini CLI),
  改为通过环境变量注入(ANTIGRAVITY_OAUTH_CLIENT_SECRET、
  GEMINI_CLI_OAUTH_CLIENT_SECRET)
- 新增 logredact.RedactText() 对非结构化文本做敏感信息脱敏,
  覆盖 GOCSPX-*/AIza* 令牌和常见 key=value 模式
- 日志中不再打印 org_uuid、account_uuid、email_address 等敏感值

安全修复(P1):
- URL 验证增强:新增 ValidateHTTPURL 统一入口,支持 allowlist 和
  私网地址阻断(localhost/内网 IP)
- 代理回退安全:代理初始化失败时默认阻止直连回退,防止 IP 泄露,
  可通过 security.proxy_fallback.allow_direct_on_error 显式开启
- Gemini OAuth 配置校验:client_id 与 client_secret 必须同时
  设置或同时留空

其他改进:
- 新增 tools/secret_scan.py 密钥扫描工具和 Makefile secret-scan 目标
- 更新所有 docker-compose 和部署配置,传递 OAuth secret 环境变量
- google_one OAuth 类型使用固定 redirectURI,与 code_assist 对齐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-02-09 09:58:13 +08:00
parent fc8a39e0f5
commit d7011163b8
22 changed files with 444 additions and 61 deletions

View File

@@ -2,6 +2,7 @@ package logredact
import (
"encoding/json"
"regexp"
"strings"
)
@@ -19,6 +20,22 @@ var defaultSensitiveKeys = map[string]struct{}{
"password": {},
}
var defaultSensitiveKeyList = []string{
"authorization_code",
"code",
"code_verifier",
"access_token",
"refresh_token",
"id_token",
"client_secret",
"password",
}
var (
reGOCSPX = regexp.MustCompile(`GOCSPX-[0-9A-Za-z_-]{24,}`)
reAIza = regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`)
)
func RedactMap(input map[string]any, extraKeys ...string) map[string]any {
if input == nil {
return map[string]any{}
@@ -48,6 +65,62 @@ func RedactJSON(raw []byte, extraKeys ...string) string {
return string(encoded)
}
// RedactText 对非结构化文本做轻量脱敏。
//
// 规则:
// - 如果文本本身是 JSON则按 RedactJSON 处理。
// - 否则尝试对常见 key=value / key:"value" 片段做脱敏。
//
// 注意:该函数用于日志/错误信息兜底,不保证覆盖所有格式。
func RedactText(input string, extraKeys ...string) string {
input = strings.TrimSpace(input)
if input == "" {
return ""
}
raw := []byte(input)
if json.Valid(raw) {
return RedactJSON(raw, extraKeys...)
}
keyAlt := buildKeyAlternation(extraKeys)
// JSON-like: "access_token":"..."
reJSONLike := regexp.MustCompile(`(?i)("(?:` + keyAlt + `)"\s*:\s*")([^"]*)(")`)
// Query-like: access_token=...
reQueryLike := regexp.MustCompile(`(?i)\b((?:` + keyAlt + `))=([^&\s]+)`)
// Plain: access_token: ... / access_token = ...
rePlain := regexp.MustCompile(`(?i)\b((?:` + keyAlt + `))\b(\s*[:=]\s*)([^,\s]+)`)
out := input
out = reGOCSPX.ReplaceAllString(out, "GOCSPX-***")
out = reAIza.ReplaceAllString(out, "AIza***")
out = reJSONLike.ReplaceAllString(out, `$1***$3`)
out = reQueryLike.ReplaceAllString(out, `$1=***`)
out = rePlain.ReplaceAllString(out, `$1$2***`)
return out
}
func buildKeyAlternation(extraKeys []string) string {
seen := make(map[string]struct{}, len(defaultSensitiveKeyList)+len(extraKeys))
keys := make([]string, 0, len(defaultSensitiveKeyList)+len(extraKeys))
for _, k := range defaultSensitiveKeyList {
seen[k] = struct{}{}
keys = append(keys, regexp.QuoteMeta(k))
}
for _, k := range extraKeys {
n := normalizeKey(k)
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
keys = append(keys, regexp.QuoteMeta(n))
}
return strings.Join(keys, "|")
}
func buildKeySet(extraKeys []string) map[string]struct{} {
keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys))
for k := range defaultSensitiveKeys {

View File

@@ -17,6 +17,58 @@ type ValidationOptions struct {
AllowPrivate bool
}
// ValidateHTTPURL validates an outbound HTTP/HTTPS URL.
//
// It provides a single validation entry point that supports:
// - scheme 校验https 或可选允许 http
// - 可选 allowlist支持 *.example.com 通配)
// - allow_private_hosts 策略(阻断 localhost/私网字面量 IP
//
// 注意DNS Rebinding 防护(解析后 IP 校验)应在实际发起请求时执行,避免 TOCTOU。
func ValidateHTTPURL(raw string, allowInsecureHTTP bool, opts ValidationOptions) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowInsecureHTTP || scheme != "http") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" {
return "", errors.New("invalid host")
}
if !opts.AllowPrivate && isBlockedHost(host) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
if port := parsed.Port(); port != "" {
num, err := strconv.Atoi(port)
if err != nil || num <= 0 || num > 65535 {
return "", fmt.Errorf("invalid port: %s", port)
}
}
allowlist := normalizeAllowlist(opts.AllowedHosts)
if opts.RequireAllowlist && len(allowlist) == 0 {
return "", errors.New("allowlist is not configured")
}
if len(allowlist) > 0 && !isAllowedHost(host, allowlist) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
trimmed := strings.TrimSpace(raw)
@@ -50,38 +102,7 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
}
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" {
return "", errors.New("invalid host")
}
if !opts.AllowPrivate && isBlockedHost(host) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
allowlist := normalizeAllowlist(opts.AllowedHosts)
if opts.RequireAllowlist && len(allowlist) == 0 {
return "", errors.New("allowlist is not configured")
}
if len(allowlist) > 0 && !isAllowedHost(host, allowlist) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = ""
return strings.TrimRight(parsed.String(), "/"), nil
return ValidateHTTPURL(raw, false, opts)
}
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全