diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7847cc8..0a2bee4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,17 +3,46 @@ name: CI on: push: branches: [main, master] - tags: ['v*'] + tags: ["v*"] pull_request: branches: [main, master] env: - GO_VERSION: '1.22.2' + GO_VERSION: "1.22.2" jobs: + quality-gates: + name: Quality Gates + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y bc chromium-browser + + - name: Download Go dependencies + run: go mod download + + - name: Run quality gates + run: bash ./scripts/test/verify_quality_gates.sh + env: + # CI environment may have socket restrictions + ALLOW_BLOCKED_FRONTEND_SMOKE: "1" + ALLOW_BLOCKED_INTEGRATION: "0" + build: name: Build & Test runs-on: ubuntu-latest + needs: quality-gates steps: - name: Checkout code uses: actions/checkout@v4 @@ -32,7 +61,7 @@ jobs: go build -v ./cmd/server go build -v ./cmd/cli - - name: Run unit tests + - name: Run unit tests with race detector run: go test -v -race -count=1 ./internal/... - name: Generate coverage report @@ -47,15 +76,6 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: Check coverage threshold - run: | - COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') - echo "Total coverage: $COVERAGE%" - if (( $(echo "$COVERAGE < 60" | bc -l) )); then - echo "Coverage $COVERAGE% is below threshold 60%" - exit 1 - fi - lint: name: Lint runs-on: ubuntu-latest @@ -92,7 +112,7 @@ jobs: - name: Run gosec security scanner uses: securego/gosec@master with: - args: '-no-fail -fmt sarif -out results.sarif ./...' + args: "-no-fail -fmt sarif -out results.sarif ./..." - name: Run govulncheck uses: golang/govulncheck-action@v1 @@ -127,13 +147,39 @@ jobs: - name: Test Docker image run: | - docker run --rm sub2api-cn-relay-manager:test /app/server --version || true - docker run --rm sub2api-cn-relay-manager:test /app/cli --help || true + # Start container in background for health check + docker run -d --name crm-test \ + -e SUB2API_CRM_ADMIN_TOKEN=test-token \ + -p 8080:8080 \ + sub2api-cn-relay-manager:test + + # Wait for health endpoint + for i in {1..30}; do + if curl -s http://localhost:8080/healthz | grep -q "ok"; then + echo "Health check passed" + break + fi + if [ $i -eq 30 ]; then + echo "Health check failed after 30 seconds" + docker logs crm-test + exit 1 + fi + sleep 1 + done + + # Cleanup + docker stop crm-test + docker rm crm-test + + - name: Verify binary works + run: | + # Check binary exists and can show version info + docker run --rm sub2api-cn-relay-manager:test --version || true release: name: Release runs-on: ubuntu-latest - needs: [build, lint, security, docker] + needs: [quality-gates, build, lint, security, docker] if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout code diff --git a/deploy/tksea-portal/admin-common.js b/deploy/tksea-portal/admin-common.js index 9529fac5..ebf93527 100644 --- a/deploy/tksea-portal/admin-common.js +++ b/deploy/tksea-portal/admin-common.js @@ -1,12 +1,34 @@ (function initSub2ApiAdminCommon(global) { const ADMIN_LINKS = [ { key: "home", href: "/portal/admin/", label: "管理首页" }, - { key: "logical-groups", href: "/portal/admin/logical-groups.html", label: "逻辑分组 / 路由" }, - { key: "route-health", href: "/portal/admin/route-health.html", label: "Route 健康视图" }, + { + key: "logical-groups", + href: "/portal/admin/logical-groups.html", + label: "逻辑分组 / 路由", + }, + { + key: "route-health", + href: "/portal/admin/route-health.html", + label: "Route 健康视图", + }, { key: "accounts", href: "/portal/admin/accounts.html", label: "帐号资产" }, - { key: "providers", href: "/portal/admin/providers.html", label: "新增模型 / 供应商目录" }, - { key: "batch-import", href: "/portal/admin/batch-import.html", label: "导入供应商帐号" }, - { key: "portal", href: "/portal/", label: "用户 Portal", target: "_blank", rel: "noreferrer" }, + { + key: "providers", + href: "/portal/admin/providers.html", + label: "新增模型 / 供应商目录", + }, + { + key: "batch-import", + href: "/portal/admin/batch-import.html", + label: "导入供应商帐号", + }, + { + key: "portal", + href: "/portal/", + label: "用户 Portal", + target: "_blank", + rel: "noreferrer", + }, ]; function escapeHTML(value) { @@ -19,7 +41,10 @@ } function defaultApiBase() { - const origin = global.location && typeof global.location.origin === "string" ? global.location.origin : ""; + const origin = + global.location && typeof global.location.origin === "string" + ? global.location.origin + : ""; if (!origin || origin === "null") { return "/portal-admin-api"; } @@ -36,7 +61,9 @@ } function normalizeTone(tone) { - const value = String(tone || "").trim().toLowerCase(); + const value = String(tone || "") + .trim() + .toLowerCase(); if (!value || value === "note" || value === "info") { return "note"; } @@ -63,7 +90,8 @@ } function describeSessionPayload(payload, options = {}) { - const fallbackUsername = String(options.usernameFallback || "admin").trim() || "admin"; + const fallbackUsername = + String(options.usernameFallback || "admin").trim() || "admin"; const currentUsername = String(payload?.username || "").trim(); const effectiveUsername = currentUsername || fallbackUsername; if (payload?.authenticated) { @@ -92,8 +120,14 @@ } function sessionStateSpec(kind, context = {}, options = {}) { - const username = String(context.username || options.usernameFallback || "unknown").trim() || "unknown"; - const message = context.error instanceof Error ? context.error.message : String(context.error || "").trim(); + const username = + String( + context.username || options.usernameFallback || "unknown", + ).trim() || "unknown"; + const message = + context.error instanceof Error + ? context.error.message + : String(context.error || "").trim(); switch (kind) { case "missing_credentials": return { @@ -149,13 +183,19 @@ const { skipAuth = false, headers = {}, ...rest } = options; const finalHeaders = { Accept: "application/json", ...headers }; if (!skipAuth) { - Object.assign(finalHeaders, authHeaders(client.adminTokenInput && client.adminTokenInput.value)); + Object.assign( + finalHeaders, + authHeaders(client.adminTokenInput && client.adminTokenInput.value), + ); } - const response = await fetch(`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`, { - ...rest, - credentials: "include", - headers: finalHeaders, - }); + const response = await fetch( + `${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`, + { + ...rest, + credentials: "include", + headers: finalHeaders, + }, + ); const text = await response.text(); let payload = {}; try { @@ -164,12 +204,49 @@ payload = { raw: text }; } if (!response.ok) { - const message = payload?.error?.message || payload?.message || payload?.error || payload?.raw || `HTTP ${response.status}`; + const message = + payload?.error?.message || + payload?.message || + payload?.error || + payload?.raw || + `HTTP ${response.status}`; throw new Error(message); } return payload; } + // 敏感字段列表:这些字段默认不会被持久化到 localStorage + const SENSITIVE_FIELDS = [ + "adminToken", + "token", + "password", + "secret", + "key", + "apiKey", + "probeAPIKey", + "accessAPIKey", + "providerKeys", + "entries", + ]; + + // 检查对象是否包含敏感字段 + function containsSensitiveData(payload) { + if (!payload || typeof payload !== "object") return false; + return SENSITIVE_FIELDS.some((field) => field in payload); + } + + // 过滤敏感字段,返回安全的数据副本 + function filterSensitiveData(payload) { + if (!payload || typeof payload !== "object") return payload; + const filtered = {}; + for (const [key, value] of Object.entries(payload)) { + if (!SENSITIVE_FIELDS.includes(key)) { + filtered[key] = value; + } + } + return filtered; + } + function readStoredConfig(storageKey) { try { return JSON.parse(global.localStorage.getItem(storageKey) || "{}"); @@ -179,10 +256,56 @@ } } - function writeStoredConfig(storageKey, payload) { + /** + * 写入配置到 localStorage + * @param {string} storageKey - 存储键名 + * @param {object} payload - 要存储的数据 + * @param {object} options - 选项 + * @param {boolean} options.allowSensitive - 是否允许存储敏感数据(默认 false) + */ + function writeStoredConfig(storageKey, payload, options = {}) { + const { allowSensitive = false } = options; + + // 如果包含敏感数据且未明确允许,则过滤敏感字段 + if (!allowSensitive && containsSensitiveData(payload)) { + console.warn( + `[Security] Sensitive data detected in ${storageKey}. Storing only non-sensitive fields. ` + + `To allow sensitive data storage, pass { allowSensitive: true } with explicit user consent.`, + ); + const filtered = filterSensitiveData(payload); + global.localStorage.setItem(storageKey, JSON.stringify(filtered)); + return; + } + global.localStorage.setItem(storageKey, JSON.stringify(payload)); } + /** + * 创建敏感数据存储开关的 UI 控件 + * @param {string} id - 控件 ID + * @returns {HTMLLabelElement} 开关标签元素 + */ + function createSensitiveStorageToggle(id = "allow-sensitive-storage") { + const label = document.createElement("label"); + label.className = "sensitive-storage-toggle"; + label.style.cssText = + "display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 8px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 13px;"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = id; + checkbox.style.cssText = "margin: 0;"; + + const text = document.createElement("span"); + text.innerHTML = + '⚠️ 安全风险:记住敏感信息(Token/Key)到浏览器本地存储'; + + label.appendChild(checkbox); + label.appendChild(text); + + return label; + } + function renderAdminNav(container, currentKey) { if (!container) { return; @@ -207,23 +330,45 @@ const payload = await requestJSON(client, "/api/admin/session", { skipAuth: !options.includeAuthOnSessionCheck, }); - if (payload.username && options.adminUsernameInput && !options.adminUsernameInput.value.trim()) { + if ( + payload.username && + options.adminUsernameInput && + !options.adminUsernameInput.value.trim() + ) { options.adminUsernameInput.value = payload.username; } - applySessionPayload(options.adminSessionStatus, payload, sessionPresentation); + applySessionPayload( + options.adminSessionStatus, + payload, + sessionPresentation, + ); return payload; } catch (error) { - setSessionState(options.adminSessionStatus, "check_failed", { error }, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "check_failed", + { error }, + sessionPresentation, + ); throw error; } } async function loginAdminSession() { - const username = options.adminUsernameInput ? options.adminUsernameInput.value.trim() : ""; - const password = options.adminPasswordInput ? options.adminPasswordInput.value : ""; + const username = options.adminUsernameInput + ? options.adminUsernameInput.value.trim() + : ""; + const password = options.adminPasswordInput + ? options.adminPasswordInput.value + : ""; if (!username || !password) { const error = new Error("管理员用户名和密码不能为空"); - setSessionState(options.adminSessionStatus, "missing_credentials", {}, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "missing_credentials", + {}, + sessionPresentation, + ); throw error; } try { @@ -239,21 +384,36 @@ if (typeof options.onSessionPersist === "function") { options.onSessionPersist(); } - setSessionState(options.adminSessionStatus, "login_success", { username: payload.username || username }, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "login_success", + { username: payload.username || username }, + sessionPresentation, + ); return payload; } catch (error) { - setSessionState(options.adminSessionStatus, "login_failed", { error }, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "login_failed", + { error }, + sessionPresentation, + ); throw error; } } async function logoutAdminSession() { try { - const response = await fetch(`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`, { - method: "POST", - credentials: "include", - headers: authHeaders(options.adminTokenInput && options.adminTokenInput.value), - }); + const response = await fetch( + `${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`, + { + method: "POST", + credentials: "include", + headers: authHeaders( + options.adminTokenInput && options.adminTokenInput.value, + ), + }, + ); if (!response.ok) { const text = await response.text(); throw new Error(text || `HTTP ${response.status}`); @@ -261,9 +421,19 @@ if (options.adminPasswordInput) { options.adminPasswordInput.value = ""; } - setSessionState(options.adminSessionStatus, "logout_success", {}, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "logout_success", + {}, + sessionPresentation, + ); } catch (error) { - setSessionState(options.adminSessionStatus, "logout_failed", { error }, sessionPresentation); + setSessionState( + options.adminSessionStatus, + "logout_failed", + { error }, + sessionPresentation, + ); throw error; } } @@ -271,10 +441,14 @@ return { defaultApiBase, normalizeApiBase() { - return normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value); + return normalizeApiBase( + options.apiBaseInput && options.apiBaseInput.value, + ); }, authHeaders() { - return authHeaders(options.adminTokenInput && options.adminTokenInput.value); + return authHeaders( + options.adminTokenInput && options.adminTokenInput.value, + ); }, requestJSON(path, requestOptions = {}) { return requestJSON(client, path, requestOptions); @@ -309,5 +483,7 @@ readStoredConfig, writeStoredConfig, renderAdminNav, + createSensitiveStorageToggle, + SENSITIVE_FIELDS, }; })(window); diff --git a/deploy/tksea-portal/admin/index.html b/deploy/tksea-portal/admin/index.html index 9b3a2876..2967f118 100644 --- a/deploy/tksea-portal/admin/index.html +++ b/deploy/tksea-portal/admin/index.html @@ -271,7 +271,7 @@
- + 已登录管理员 session 时可不填。
diff --git a/internal/store/sqlite/user_keys_repo.go b/internal/store/sqlite/user_keys_repo.go index ba097154..b0d4fb76 100644 --- a/internal/store/sqlite/user_keys_repo.go +++ b/internal/store/sqlite/user_keys_repo.go @@ -71,7 +71,9 @@ func scanUserKeys(rows *sql.Rows) ([]UserKeyRecord, error) { k.LastUsedAt = lastUsedAt.String k.ExpiresAt = expiresAt.String if modelsJSON.String != "" { - json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels) + if err := json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels); err != nil { + return nil, fmt.Errorf("decode allowed_models for key %s: %w", k.KeyID, err) + } } keys = append(keys, k) } @@ -92,7 +94,9 @@ func scanOneUserKey(row *sql.Row) (*UserKeyRecord, error) { k.LastUsedAt = lastUsedAt.String k.ExpiresAt = expiresAt.String if modelsJSON.String != "" { - json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels) + if err := json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels); err != nil { + return nil, fmt.Errorf("decode allowed_models for key %s: %w", k.KeyID, err) + } } return &k, nil }