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:
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build build-backend build-frontend test test-backend test-frontend
|
.PHONY: build build-backend build-frontend test test-backend test-frontend secret-scan
|
||||||
|
|
||||||
# 一键编译前后端
|
# 一键编译前后端
|
||||||
build: build-backend build-frontend
|
build: build-backend build-frontend
|
||||||
@@ -20,3 +20,6 @@ test-backend:
|
|||||||
test-frontend:
|
test-frontend:
|
||||||
@pnpm --dir frontend run lint:check
|
@pnpm --dir frontend run lint:check
|
||||||
@pnpm --dir frontend run typecheck
|
@pnpm --dir frontend run typecheck
|
||||||
|
|
||||||
|
secret-scan:
|
||||||
|
@python3 tools/secret_scan.py
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ type SecurityConfig struct {
|
|||||||
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
|
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
|
||||||
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
|
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
|
||||||
CSP CSPConfig `mapstructure:"csp"`
|
CSP CSPConfig `mapstructure:"csp"`
|
||||||
|
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
|
||||||
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
|
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +201,12 @@ type CSPConfig struct {
|
|||||||
Policy string `mapstructure:"policy"`
|
Policy string `mapstructure:"policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProxyFallbackConfig struct {
|
||||||
|
// AllowDirectOnError 当代理初始化失败时是否允许回退直连。
|
||||||
|
// 默认 false:避免因代理配置错误导致 IP 泄露/关联。
|
||||||
|
AllowDirectOnError bool `mapstructure:"allow_direct_on_error"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyProbeConfig struct {
|
type ProxyProbeConfig struct {
|
||||||
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
|
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
|
||||||
}
|
}
|
||||||
@@ -1047,9 +1054,20 @@ func setDefaults() {
|
|||||||
viper.SetDefault("gemini.oauth.scopes", "")
|
viper.SetDefault("gemini.oauth.scopes", "")
|
||||||
viper.SetDefault("gemini.quota.policy", "")
|
viper.SetDefault("gemini.quota.policy", "")
|
||||||
|
|
||||||
|
// Security - proxy fallback
|
||||||
|
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
|
// Gemini OAuth 配置校验:client_id 与 client_secret 必须同时设置或同时留空。
|
||||||
|
// 留空时表示使用内置的 Gemini CLI OAuth 客户端(其 client_secret 通过环境变量注入)。
|
||||||
|
geminiClientID := strings.TrimSpace(c.Gemini.OAuth.ClientID)
|
||||||
|
geminiClientSecret := strings.TrimSpace(c.Gemini.OAuth.ClientSecret)
|
||||||
|
if (geminiClientID == "") != (geminiClientSecret == "") {
|
||||||
|
return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty")
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(c.Server.FrontendURL) != "" {
|
if strings.TrimSpace(c.Server.FrontendURL) != "" {
|
||||||
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
|
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
|
||||||
return fmt.Errorf("server.frontend_url invalid: %w", err)
|
return fmt.Errorf("server.frontend_url invalid: %w", err)
|
||||||
|
|||||||
@@ -187,9 +187,14 @@ func shouldFallbackToNextURL(err error, statusCode int) bool {
|
|||||||
|
|
||||||
// ExchangeCode 用 authorization code 交换 token
|
// ExchangeCode 用 authorization code 交换 token
|
||||||
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) {
|
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) {
|
||||||
|
clientSecret, err := getClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("client_id", ClientID)
|
params.Set("client_id", ClientID)
|
||||||
params.Set("client_secret", ClientSecret)
|
params.Set("client_secret", clientSecret)
|
||||||
params.Set("code", code)
|
params.Set("code", code)
|
||||||
params.Set("redirect_uri", RedirectURI)
|
params.Set("redirect_uri", RedirectURI)
|
||||||
params.Set("grant_type", "authorization_code")
|
params.Set("grant_type", "authorization_code")
|
||||||
@@ -226,9 +231,14 @@ func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*
|
|||||||
|
|
||||||
// RefreshToken 刷新 access_token
|
// RefreshToken 刷新 access_token
|
||||||
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
||||||
|
clientSecret, err := getClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("client_id", ClientID)
|
params.Set("client_id", ClientID)
|
||||||
params.Set("client_secret", ClientSecret)
|
params.Set("client_secret", clientSecret)
|
||||||
params.Set("refresh_token", refreshToken)
|
params.Set("refresh_token", refreshToken)
|
||||||
params.Set("grant_type", "refresh_token")
|
params.Set("grant_type", "refresh_token")
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +24,11 @@ const (
|
|||||||
|
|
||||||
// Antigravity OAuth 客户端凭证
|
// Antigravity OAuth 客户端凭证
|
||||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||||
ClientSecret = "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"
|
ClientSecret = ""
|
||||||
|
|
||||||
|
// AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。
|
||||||
|
// 出于安全原因,该值不得硬编码入库。
|
||||||
|
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||||
|
|
||||||
// 固定的 redirect_uri(用户需手动复制 code)
|
// 固定的 redirect_uri(用户需手动复制 code)
|
||||||
RedirectURI = "http://localhost:8085/callback"
|
RedirectURI = "http://localhost:8085/callback"
|
||||||
@@ -46,6 +54,18 @@ const (
|
|||||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getClientSecret() (string, error) {
|
||||||
|
if v := strings.TrimSpace(ClientSecret); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv(AntigravityOAuthClientSecretEnv); ok {
|
||||||
|
if vv := strings.TrimSpace(v); vv != "" {
|
||||||
|
return vv, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", infraerrors.Newf(http.StatusBadRequest, "ANTIGRAVITY_OAUTH_CLIENT_SECRET_MISSING", "missing antigravity oauth client_secret; set %s", AntigravityOAuthClientSecretEnv)
|
||||||
|
}
|
||||||
|
|
||||||
// BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致)
|
// BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致)
|
||||||
var BaseURLs = []string{
|
var BaseURLs = []string{
|
||||||
antigravityProdBaseURL, // prod (优先)
|
antigravityProdBaseURL, // prod (优先)
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ const (
|
|||||||
// GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
|
// GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
|
||||||
// They enable the "login without creating your own OAuth client" experience, but Google may
|
// They enable the "login without creating your own OAuth client" experience, but Google may
|
||||||
// restrict which scopes are allowed for this client.
|
// restrict which scopes are allowed for this client.
|
||||||
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||||
GeminiCLIOAuthClientSecret = "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"
|
// GeminiCLIOAuthClientSecret is intentionally not embedded in this repository.
|
||||||
|
// If you rely on the built-in Gemini CLI OAuth client, you MUST provide its client_secret via config/env.
|
||||||
|
GeminiCLIOAuthClientSecret = ""
|
||||||
|
|
||||||
|
// GeminiCLIOAuthClientSecretEnv is the environment variable name for the built-in client secret.
|
||||||
|
GeminiCLIOAuthClientSecretEnv = "GEMINI_CLI_OAUTH_CLIENT_SECRET"
|
||||||
|
|
||||||
SessionTTL = 30 * time.Minute
|
SessionTTL = 30 * time.Minute
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuthConfig struct {
|
type OAuthConfig struct {
|
||||||
@@ -164,15 +168,24 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to built-in Gemini CLI OAuth client when not configured.
|
// Fall back to built-in Gemini CLI OAuth client when not configured.
|
||||||
|
// SECURITY: This repo does not embed the built-in client secret; it must be provided via env.
|
||||||
if effective.ClientID == "" && effective.ClientSecret == "" {
|
if effective.ClientID == "" && effective.ClientSecret == "" {
|
||||||
|
secret := strings.TrimSpace(GeminiCLIOAuthClientSecret)
|
||||||
|
if secret == "" {
|
||||||
|
if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok {
|
||||||
|
secret = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return OAuthConfig{}, infraerrors.Newf(http.StatusBadRequest, "GEMINI_CLI_OAUTH_CLIENT_SECRET_MISSING", "built-in Gemini CLI OAuth client_secret is not configured; set %s or provide a custom OAuth client", GeminiCLIOAuthClientSecretEnv)
|
||||||
|
}
|
||||||
effective.ClientID = GeminiCLIOAuthClientID
|
effective.ClientID = GeminiCLIOAuthClientID
|
||||||
effective.ClientSecret = GeminiCLIOAuthClientSecret
|
effective.ClientSecret = secret
|
||||||
} else if effective.ClientID == "" || effective.ClientSecret == "" {
|
} else if effective.ClientID == "" || effective.ClientSecret == "" {
|
||||||
return OAuthConfig{}, fmt.Errorf("OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)")
|
return OAuthConfig{}, infraerrors.New(http.StatusBadRequest, "GEMINI_OAUTH_CLIENT_NOT_CONFIGURED", "OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)")
|
||||||
}
|
}
|
||||||
|
|
||||||
isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID &&
|
isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID
|
||||||
effective.ClientSecret == GeminiCLIOAuthClientSecret
|
|
||||||
|
|
||||||
if effective.Scopes == "" {
|
if effective.Scopes == "" {
|
||||||
// Use different default scopes based on OAuth type
|
// Use different default scopes based on OAuth type
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ func ErrorFrom(c *gin.Context, err error) bool {
|
|||||||
|
|
||||||
// Log internal errors with full details for debugging
|
// Log internal errors with full details for debugging
|
||||||
if statusCode >= 500 && c.Request != nil {
|
if statusCode >= 500 && c.Request != nil {
|
||||||
log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, err.Error())
|
log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, logredact.RedactText(err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata)
|
ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata)
|
||||||
|
|||||||
@@ -18,14 +18,21 @@ type githubReleaseClient struct {
|
|||||||
downloadHTTPClient *http.Client
|
downloadHTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type githubReleaseClientError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// NewGitHubReleaseClient 创建 GitHub Release 客户端
|
// NewGitHubReleaseClient 创建 GitHub Release 客户端
|
||||||
// proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议
|
// proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议
|
||||||
func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
|
func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) service.GitHubReleaseClient {
|
||||||
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
ProxyURL: proxyURL,
|
ProxyURL: proxyURL,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if proxyURL != "" && !allowDirectOnProxyError {
|
||||||
|
return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)}
|
||||||
|
}
|
||||||
sharedClient = &http.Client{Timeout: 30 * time.Second}
|
sharedClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +42,9 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
|
|||||||
ProxyURL: proxyURL,
|
ProxyURL: proxyURL,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if proxyURL != "" && !allowDirectOnProxyError {
|
||||||
|
return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)}
|
||||||
|
}
|
||||||
downloadClient = &http.Client{Timeout: 10 * time.Minute}
|
downloadClient = &http.Client{Timeout: 10 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +54,18 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
|
func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
|
||||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc
|
|||||||
// ProvideGitHubReleaseClient 创建 GitHub Release 客户端
|
// ProvideGitHubReleaseClient 创建 GitHub Release 客户端
|
||||||
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub
|
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub
|
||||||
func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient {
|
func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient {
|
||||||
return NewGitHubReleaseClient(cfg.Update.ProxyURL)
|
return NewGitHubReleaseClient(cfg.Update.ProxyURL, cfg.Security.ProxyFallback.AllowDirectOnError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvidePricingRemoteClient 创建定价数据远程客户端
|
// ProvidePricingRemoteClient 创建定价数据远程客户端
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities {
|
|||||||
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
|
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
|
||||||
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
|
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
|
||||||
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
|
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
|
||||||
enabled := clientID != "" && clientSecret != "" &&
|
enabled := clientID != "" && clientSecret != "" && clientID != geminicli.GeminiCLIOAuthClientID
|
||||||
(clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret)
|
|
||||||
|
|
||||||
return &GeminiOAuthCapabilities{
|
return &GeminiOAuthCapabilities{
|
||||||
AIStudioOAuthEnabled: enabled,
|
AIStudioOAuthEnabled: enabled,
|
||||||
@@ -151,8 +150,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
|
||||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
|
||||||
|
|
||||||
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
|
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
|
||||||
if oauthType == "ai_studio" && isBuiltinClient {
|
if oauthType == "ai_studio" && isBuiltinClient {
|
||||||
@@ -485,15 +483,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
|
||||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
|
||||||
if isBuiltinClient {
|
if isBuiltinClient {
|
||||||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize")
|
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// code_assist always uses the built-in client and its fixed redirect URI.
|
// code_assist/google_one always uses the built-in client and its fixed redirect URI.
|
||||||
if oauthType == "code_assist" {
|
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||||
redirectURI = geminicli.GeminiCLIRedirectURI
|
redirectURI = geminicli.GeminiCLIRedirectURI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
|||||||
// Ensure org_uuid is set (from step 1 if not from token response)
|
// Ensure org_uuid is set (from step 1 if not from token response)
|
||||||
if tokenInfo.OrgUUID == "" && orgUUID != "" {
|
if tokenInfo.OrgUUID == "" && orgUUID != "" {
|
||||||
tokenInfo.OrgUUID = orgUUID
|
tokenInfo.OrgUUID = orgUUID
|
||||||
log.Printf("[OAuth] Set org_uuid from cookie auth: %s", orgUUID)
|
log.Printf("[OAuth] Set org_uuid from cookie auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
@@ -251,16 +251,16 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
|
|||||||
|
|
||||||
if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" {
|
if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" {
|
||||||
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
tokenInfo.OrgUUID = tokenResp.Organization.UUID
|
||||||
log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID)
|
log.Printf("[OAuth] Got org_uuid")
|
||||||
}
|
}
|
||||||
if tokenResp.Account != nil {
|
if tokenResp.Account != nil {
|
||||||
if tokenResp.Account.UUID != "" {
|
if tokenResp.Account.UUID != "" {
|
||||||
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
tokenInfo.AccountUUID = tokenResp.Account.UUID
|
||||||
log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID)
|
log.Printf("[OAuth] Got account_uuid")
|
||||||
}
|
}
|
||||||
if tokenResp.Account.EmailAddress != "" {
|
if tokenResp.Account.EmailAddress != "" {
|
||||||
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
|
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
|
||||||
log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress)
|
log.Printf("[OAuth] Got email_address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package logredact
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,22 @@ var defaultSensitiveKeys = map[string]struct{}{
|
|||||||
"password": {},
|
"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 {
|
func RedactMap(input map[string]any, extraKeys ...string) map[string]any {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
@@ -48,6 +65,62 @@ func RedactJSON(raw []byte, extraKeys ...string) string {
|
|||||||
return string(encoded)
|
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{} {
|
func buildKeySet(extraKeys []string) map[string]struct{} {
|
||||||
keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys))
|
keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys))
|
||||||
for k := range defaultSensitiveKeys {
|
for k := range defaultSensitiveKeys {
|
||||||
|
|||||||
@@ -17,6 +17,58 @@ type ValidationOptions struct {
|
|||||||
AllowPrivate bool
|
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) {
|
func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
|
||||||
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
|
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
@@ -50,38 +102,7 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
|
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
|
||||||
trimmed := strings.TrimSpace(raw)
|
return ValidateHTTPURL(raw, false, opts)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全
|
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ TOTP_ENCRYPTION_KEY=
|
|||||||
# Leave unset to use default ./config.yaml
|
# Leave unset to use default ./config.yaml
|
||||||
#CONFIG_FILE=./config.yaml
|
#CONFIG_FILE=./config.yaml
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Built-in OAuth Client Secrets (Optional)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SECURITY NOTE:
|
||||||
|
# - 本项目不会在代码仓库中内置第三方 OAuth client_secret。
|
||||||
|
# - 如需使用“内置客户端”(而不是自建 OAuth Client),请在运行环境通过 env 注入。
|
||||||
|
#
|
||||||
|
# Gemini CLI built-in OAuth client_secret(用于 Gemini code_assist/google_one 内置登录流)
|
||||||
|
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||||
|
#
|
||||||
|
# Antigravity OAuth client_secret(用于 Antigravity OAuth 登录流)
|
||||||
|
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Rate Limiting (Optional)
|
# Rate Limiting (Optional)
|
||||||
# 速率限制(可选)
|
# 速率限制(可选)
|
||||||
|
|||||||
@@ -303,6 +303,10 @@ Requires your own OAuth client credentials.
|
|||||||
```bash
|
```bash
|
||||||
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||||
|
|
||||||
|
# 可选:如需使用 Gemini CLI 内置 OAuth Client(Code Assist / Google One)
|
||||||
|
# 安全说明:本仓库不会内置该 client_secret,请在运行环境通过环境变量注入。
|
||||||
|
# GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-built-in-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3: Create Account in Admin UI**
|
**Step 3: Create Account in Admin UI**
|
||||||
@@ -430,6 +434,11 @@ If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client cre
|
|||||||
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如需使用“内置 Gemini CLI OAuth Client”(Code Assist / Google One),还需要注入:
|
||||||
|
```ini
|
||||||
|
Environment=GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-built-in-secret
|
||||||
|
```
|
||||||
|
|
||||||
3. Reload and restart:
|
3. Reload and restart:
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|||||||
@@ -707,10 +707,14 @@ turnstile:
|
|||||||
# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同)
|
# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同)
|
||||||
gemini:
|
gemini:
|
||||||
oauth:
|
oauth:
|
||||||
# Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio)
|
# OAuth 客户端配置说明:
|
||||||
# Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio)
|
# 1) 留空 client_id/client_secret:使用 Gemini CLI 内置 OAuth Client(其 client_secret 需通过环境变量注入)
|
||||||
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
# - GEMINI_CLI_OAUTH_CLIENT_SECRET
|
||||||
client_secret: "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"
|
# 2) 同时设置 client_id/client_secret:使用你自建的 OAuth Client(推荐,权限更完整)
|
||||||
|
#
|
||||||
|
# 注意:client_id 与 client_secret 必须同时为空或同时非空。
|
||||||
|
client_id: ""
|
||||||
|
client_secret: ""
|
||||||
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
|
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
|
||||||
# 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。
|
# 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。
|
||||||
scopes: ""
|
scopes: ""
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ services:
|
|||||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||||
|
|
||||||
|
# Built-in OAuth client secrets (optional)
|
||||||
|
# SECURITY: This repo does not embed third-party client_secret.
|
||||||
|
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Security Configuration (URL Allowlist)
|
# Security Configuration (URL Allowlist)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ services:
|
|||||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||||
|
|
||||||
|
# Built-in OAuth client secrets (optional)
|
||||||
|
# SECURITY: This repo does not embed third-party client_secret.
|
||||||
|
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Security Configuration (URL Allowlist)
|
# Security Configuration (URL Allowlist)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ services:
|
|||||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||||
|
|
||||||
|
# Built-in OAuth client secrets (optional)
|
||||||
|
# SECURITY: This repo does not embed third-party client_secret.
|
||||||
|
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Security Configuration (URL Allowlist)
|
# Security Configuration (URL Allowlist)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ services:
|
|||||||
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
|
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
|
||||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||||
|
|
||||||
|
# Built-in OAuth client secrets (optional)
|
||||||
|
# SECURITY: This repo does not embed third-party client_secret.
|
||||||
|
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ services:
|
|||||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||||
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
|
||||||
|
|
||||||
|
# Built-in OAuth client secrets (optional)
|
||||||
|
# SECURITY: This repo does not embed third-party client_secret.
|
||||||
|
- GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Security Configuration (URL Allowlist)
|
# Security Configuration (URL Allowlist)
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
149
tools/secret_scan.py
Executable file
149
tools/secret_scan.py
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""轻量 secret scanning(CI 门禁 + 本地自检)。
|
||||||
|
|
||||||
|
目标:在不引入额外依赖的情况下,阻止常见敏感凭据误提交。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 该脚本只扫描 git tracked files(优先)以避免误扫本地 .env。
|
||||||
|
- 输出仅包含 file:line 与命中类型,不回显完整命中内容(避免二次泄露)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Rule:
|
||||||
|
name: str
|
||||||
|
pattern: re.Pattern[str]
|
||||||
|
# allowlist 仅用于减少示例文档/占位符带来的误报
|
||||||
|
allowlist: Sequence[re.Pattern[str]]
|
||||||
|
|
||||||
|
|
||||||
|
RULES: list[Rule] = [
|
||||||
|
Rule(
|
||||||
|
name="google_oauth_client_secret",
|
||||||
|
# Google OAuth client_secret 常见前缀
|
||||||
|
# 真实值通常较长;提高最小长度以避免命中文档里的占位符(例如 GOCSPX-your-client-secret)。
|
||||||
|
pattern=re.compile(r"GOCSPX-[0-9A-Za-z_-]{24,}"),
|
||||||
|
allowlist=(
|
||||||
|
re.compile(r"GOCSPX-your-"),
|
||||||
|
re.compile(r"GOCSPX-REDACTED"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
name="google_api_key",
|
||||||
|
# Gemini / Google API Key
|
||||||
|
# 典型格式:AIza + 35 位字符。占位符如 'AIza...' 不会匹配。
|
||||||
|
pattern=re.compile(r"AIza[0-9A-Za-z_-]{35}"),
|
||||||
|
allowlist=(
|
||||||
|
re.compile(r"AIza\.{3}"),
|
||||||
|
re.compile(r"AIza-your-"),
|
||||||
|
re.compile(r"AIza-REDACTED"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_git_files(repo_root: Path) -> list[Path]:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["git", "ls-files"], cwd=repo_root, stderr=subprocess.DEVNULL, text=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
p = (repo_root / line).resolve()
|
||||||
|
if p.is_file():
|
||||||
|
files.append(p)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def iter_walk_files(repo_root: Path) -> Iterable[Path]:
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(repo_root):
|
||||||
|
if "/.git/" in dirpath.replace("\\", "/"):
|
||||||
|
continue
|
||||||
|
for name in filenames:
|
||||||
|
yield Path(dirpath) / name
|
||||||
|
|
||||||
|
|
||||||
|
def should_skip(path: Path, repo_root: Path) -> bool:
|
||||||
|
rel = path.relative_to(repo_root).as_posix()
|
||||||
|
# 本地环境文件一般不应入库;若误入库也会被 git ls-files 扫出来。
|
||||||
|
# 这里仍跳过一些明显不该扫描的二进制。
|
||||||
|
if any(rel.endswith(s) for s in (".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip")):
|
||||||
|
return True
|
||||||
|
if rel.startswith("backend/bin/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def scan_file(path: Path, repo_root: Path) -> list[tuple[str, int]]:
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 尝试按 utf-8 解码,失败则当二进制跳过
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
findings: list[tuple[str, int]] = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
for idx, line in enumerate(lines, start=1):
|
||||||
|
for rule in RULES:
|
||||||
|
if not rule.pattern.search(line):
|
||||||
|
continue
|
||||||
|
if any(allow.search(line) for allow in rule.allowlist):
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(repo_root).as_posix()
|
||||||
|
findings.append((f"{rel}:{idx} ({rule.name})", idx))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
default=str(Path(__file__).resolve().parents[1]),
|
||||||
|
help="仓库根目录(默认:脚本上两级目录)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
repo_root = Path(args.repo_root).resolve()
|
||||||
|
files = iter_git_files(repo_root)
|
||||||
|
if not files:
|
||||||
|
files = list(iter_walk_files(repo_root))
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
if should_skip(f, repo_root):
|
||||||
|
continue
|
||||||
|
for msg, _line in scan_file(f, repo_root):
|
||||||
|
problems.append(msg)
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
sys.stderr.write("Secret scan FAILED. Potential secrets detected:\n")
|
||||||
|
for p in problems:
|
||||||
|
sys.stderr.write(f"- {p}\n")
|
||||||
|
sys.stderr.write("\n请移除/改为环境变量注入,或使用明确的占位符(例如 GOCSPX-your-client-secret)。\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("Secret scan OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
|
||||||
Reference in New Issue
Block a user