| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import type { UiCatalog, UiLocale } from './ui-locale.js'; |
| |
| const SENSITIVE_KEY_SUFFIXES = new Set([ |
| 'auth', |
| 'authorization', |
| 'credential', |
| 'credentials', |
| 'passwd', |
| 'password', |
| 'secret', |
| 'token', |
| ]); |
| const SENSITIVE_KEY_QUALIFIERS = new Set(['api', 'private', 'secret', 'ssh']); |
| |
| const POSIX_LINE_CONTINUATION_SOURCE = String.raw`\\\r?\n`; |
| const OPTIONAL_POSIX_LINE_CONTINUATION_SOURCE = `(?:${POSIX_LINE_CONTINUATION_SOURCE})*`; |
| const SHELL_SEPARATOR_SOURCE = `(?:[ \\t]|${POSIX_LINE_CONTINUATION_SOURCE})+`; |
| const OPTIONAL_SHELL_SEPARATOR_SOURCE = `(?:[ \\t]|${POSIX_LINE_CONTINUATION_SOURCE})*`; |
| const SHELL_SECRET_TOKEN_SOURCE = `("(?:\\\\[\\s\\S]|[^"\\\\])*"|'(?:\\\\[\\s\\S]|[^'\\\\])*'|(?:${POSIX_LINE_CONTINUATION_SOURCE}|[^\\s;&|()<>])+)`; |
| |
| const AWS_CONFIG_SECRET_KEY_SOURCE = posixContinuedTokenSource('aws_secret_access_key'); |
| const AWS_SECRET_ACCESS_KEY_FLAG_SOURCE = posixContinuedTokenSource('--secret-access-key'); |
| const AWS_SECRET_ACCESS_KEY_ENV_SOURCE = posixContinuedTokenSource('AWS_SECRET_ACCESS_KEY'); |
| |
| const QUOTED_SECRET_KEY_VALUE_PATTERN = /((?:"([^"\\]+)"\s*:\s*"))(?:\\.|[^"\\])*/g; |
| const ASSIGNED_SECRET_KEY_VALUE_PATTERN = |
| /\b(([A-Za-z][A-Za-z0-9_-]*)(?:[ \t]|\\\r?\n)*[:=](?:[ \t]|\\\r?\n)*['"]?)(?:\\\r?\n|[^\s"'&<>])+/g; |
| const AUTHORIZATION_HEADER_PATTERN = |
| /(^|[^A-Za-z0-9_])(['"]?(?:proxy[-_]?authorization|authorization)['"]?\s*:\s*['"]?(?:bearer|basic|token)\s+)[^\s"'<>]+/gim; |
| const AWS_CLI_SPACE_SECRET_PATTERN = new RegExp( |
| `(^|[\\s;&|()])((?:aws${SHELL_SEPARATOR_SOURCE}configure${SHELL_SEPARATOR_SOURCE}set${SHELL_SEPARATOR_SOURCE}${AWS_CONFIG_SECRET_KEY_SOURCE}|${AWS_SECRET_ACCESS_KEY_FLAG_SOURCE})${SHELL_SEPARATOR_SOURCE})${SHELL_SECRET_TOKEN_SOURCE}`, |
| 'gm', |
| ); |
| const AWS_SECRET_ASSIGNMENT_PATTERN = new RegExp( |
| `\\b(${AWS_SECRET_ACCESS_KEY_ENV_SOURCE}${OPTIONAL_SHELL_SEPARATOR_SOURCE}[:=]${OPTIONAL_SHELL_SEPARATOR_SOURCE}['"]?)(?:${POSIX_LINE_CONTINUATION_SOURCE}|[^\\s"'&<>])+`, |
| 'gi', |
| ); |
| |
| const SECRET_PATTERNS: RegExp[] = [ |
| /\b(sk-(?:ant-)?[a-z0-9_-]{8,})\b/gi, |
| /\b(AIza[0-9A-Za-z_-]{20,})\b/g, |
| /\b(gh[pousr]_[0-9A-Za-z_]{20,})\b/g, |
| /\b(xox[abprs]-[0-9A-Za-z-]{10,})\b/g, |
| /\b([a-f0-9]{40,})\b/gi, |
| ]; |
| |
| export function redactSecrets(value: string): string { |
| const json = redactSerializedJsonSecrets(value); |
| return json ?? redactTextSecrets(value); |
| } |
| |
| function redactTextSecrets(value: string): string { |
| let next = value; |
| next = redactUrlUserinfoSecrets(next); |
| next = redactUrlQuerySecrets(next); |
| next = next.replace(QUOTED_SECRET_KEY_VALUE_PATTERN, (match, prefix: string, key: string) => |
| isSensitiveKey(key) ? `${prefix}[redacted]` : match, |
| ); |
| next = next.replace( |
| AUTHORIZATION_HEADER_PATTERN, |
| (_match, boundary: string, prefix: string) => `${boundary}${prefix}[redacted]`, |
| ); |
| next = next.replace( |
| AWS_CLI_SPACE_SECRET_PATTERN, |
| (_match, boundary: string, prefix: string, token: string) => |
| `${boundary}${prefix}${redactShellToken(token)}`, |
| ); |
| next = next.replace( |
| AWS_SECRET_ASSIGNMENT_PATTERN, |
| (_match, prefix: string) => `${prefix}[redacted]`, |
| ); |
| next = next.replace(ASSIGNED_SECRET_KEY_VALUE_PATTERN, (match, prefix: string, key: string) => |
| isAssignmentSensitiveKey(key) ? `${prefix}[redacted]` : match, |
| ); |
| for (const pattern of SECRET_PATTERNS) { |
| // Each pattern's single capture group matches only the secret token, so the |
| // replacement is always the full redaction marker. Never echo any part of |
| // the match back — a future pattern whose group could hold a separator |
| // would otherwise leak the secret it was meant to hide. |
| next = next.replace(pattern, () => '[redacted]'); |
| } |
| return next; |
| } |
| |
| function posixContinuedTokenSource(token: string): string { |
| return [...token] |
| .map((character) => escapeRegExpLiteral(character)) |
| .join(OPTIONAL_POSIX_LINE_CONTINUATION_SOURCE); |
| } |
| |
| function escapeRegExpLiteral(value: string): string { |
| return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); |
| } |
| |
| function redactShellToken(token: string): string { |
| const quote = token.at(0); |
| return (quote === '"' || quote === "'") && token.at(-1) === quote |
| ? `${quote}[redacted]${quote}` |
| : '[redacted]'; |
| } |
| |
| function redactSerializedJsonSecrets(value: string): string | undefined { |
| try { |
| const parsed: unknown = JSON.parse(value); |
| if (typeof parsed !== 'string' && (parsed === null || typeof parsed !== 'object')) |
| return undefined; |
| const redacted = redactJsonValue(parsed); |
| return redacted.changed ? JSON.stringify(redacted.value) : value; |
| } catch { |
| return undefined; |
| } |
| } |
| |
| function redactJsonValue(value: unknown): { value: unknown; changed: boolean } { |
| if (Array.isArray(value)) { |
| let changed = false; |
| const next = value.map((item) => { |
| const redacted = redactJsonValue(item); |
| changed = changed || redacted.changed; |
| return redacted.value; |
| }); |
| return { value: next, changed }; |
| } |
| if (typeof value === 'string') { |
| const next = redactTextSecrets(value); |
| return { value: next, changed: next !== value }; |
| } |
| if (!value || typeof value !== 'object') return { value, changed: false }; |
| |
| let changed = false; |
| const next: Record<string, unknown> = {}; |
| for (const [key, raw] of Object.entries(value)) { |
| if (isSensitiveKey(key)) { |
| Object.defineProperty(next, key, { |
| value: '[redacted]', |
| enumerable: true, |
| configurable: true, |
| writable: true, |
| }); |
| changed = true; |
| continue; |
| } |
| const redacted = redactJsonValue(raw); |
| Object.defineProperty(next, key, { |
| value: redacted.value, |
| enumerable: true, |
| configurable: true, |
| writable: true, |
| }); |
| changed = changed || redacted.changed; |
| } |
| return { value: next, changed }; |
| } |
| |
| function redactUrlUserinfoSecrets(value: string): string { |
| // Authority runs through the first `/`, `?`, `#`, whitespace, quote, or |
| // angle bracket. If it contains `@`, everything from the host-start through |
| // the last `@` is userinfo. The class matches display-redaction's |
| // streamingTerminator so a bare `https://host` followed later by an |
| // email/`@package` (including across JSON quotes) cannot swallow the gap. |
| // Known boundary: punctuation like commas can still join a bare URL to a |
| // later `@` into one fake credentialed match; a proper fix would restrict |
| // userinfo to the RFC 3986 set instead of exclusion. http(s) only for now. |
| return value.replace(/(https?:\/\/)[^\s"'<>/?#]*@/gi, '$1[redacted]@'); |
| } |
| |
| function redactUrlQuerySecrets(value: string): string { |
| return value.replace(/([?&])([^=\s&?#]+)=([^&\s#]*)/g, (match, sep: string, key: string) => { |
| if (!isSensitiveKey(key)) return match; |
| return `${sep}${key}=[redacted]`; |
| }); |
| } |
| |
| /** Whether a key NAME marks its value as credential material (TOKEN, |
| * API_KEY, clientSecret, …). Exported for callers that must decide whether |
| * a keyed value is a secret — e.g. which MCP stdio env values are masked at |
| * the IPC boundary — so the heuristic cannot drift from the one redaction |
| * itself applies. */ |
| export function isSensitiveKey(key: string): boolean { |
| const segments = sensitiveKeySegments(key); |
| const suffix = segments.at(-1); |
| if (!suffix) return false; |
| if (suffix !== 'key') return SENSITIVE_KEY_SUFFIXES.has(suffix); |
| if (segments.length === 1) return true; |
| if (SENSITIVE_KEY_QUALIFIERS.has(segments.at(-2) ?? '')) return true; |
| const qualifiedKey = segments.slice(-3).join('_'); |
| return qualifiedKey === 'service_account_key' || qualifiedKey === 'secret_access_key'; |
| } |
| |
| function sensitiveKeySegments(key: string): string[] { |
| return key |
| .replace(/([a-z0-9])([A-Z])/g, '$1 $2') |
| .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') |
| .toLowerCase() |
| .split(/[^a-z0-9]+/) |
| .filter(Boolean); |
| } |
| |
| function isAssignmentSensitiveKey(key: string): boolean { |
| if (!isSensitiveKey(key)) return false; |
| const suffix = sensitiveKeySegments(key).at(-1); |
| return suffix !== 'auth' && suffix !== 'authorization'; |
| } |
| |
| export type GeneralizedErrorClass = |
| | 'timeout' |
| | 'rate_limited' |
| | 'auth_failed' |
| | 'provider_error' |
| | 'network_error'; |
| |
| /** |
| * Keyword classification shared by the localized message helpers and by |
| * producers that emit a stable machine code instead of prose. |
| */ |
| export function classifyGeneralizedError(error: unknown): GeneralizedErrorClass | undefined { |
| const message = error instanceof Error ? error.message : String(error); |
| const lower = redactSecrets(message).toLowerCase(); |
| if (lower.includes('timeout')) return 'timeout'; |
| if (lower.includes('429') || lower.includes('rate')) return 'rate_limited'; |
| // builder-util-runtime appends generic authentication-token advice to HTTP |
| // 404 errors. electron-updater has already classified this particular case |
| // as a missing channel artifact, so it is not evidence of bad credentials. |
| if ( |
| typeof error === 'object' && |
| error !== null && |
| 'code' in error && |
| error.code === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' |
| ) |
| return undefined; |
| if (lower.includes('401') || lower.includes('403') || isAuthenticationErrorText(lower)) |
| return 'auth_failed'; |
| if (/\b5\d\d\b/.test(lower)) return 'provider_error'; |
| if ( |
| lower.includes('network') || |
| lower.includes('fetch') || |
| // Chromium network stack error codes (`net::ERR_CONNECTION_RESET`, |
| // `net::ERR_NAME_NOT_RESOLVED`, ...) never match the Node errno |
| // spellings below. |
| lower.includes('net::err') || |
| lower.includes('econn') || |
| lower.includes('enotfound') |
| ) |
| return 'network_error'; |
| return undefined; |
| } |
| |
| /** Locale copy for each {@link GeneralizedErrorClass}; catalog authors spread |
| * this per-locale block instead of restating the sentences. */ |
| export const GENERALIZED_ERROR_COPY = { |
| 'zh-CN': { |
| timeout: '请求超时', |
| rate_limited: '触发模型速率限制', |
| auth_failed: '鉴权失败', |
| provider_error: '模型服务返回错误', |
| network_error: '网络错误', |
| }, |
| 'zh-TW': { |
| timeout: '請求逾時', |
| rate_limited: '已達模型速率限制', |
| auth_failed: '驗證失敗', |
| provider_error: '模型服務傳回錯誤', |
| network_error: '網路錯誤', |
| }, |
| en: { |
| timeout: 'Request timed out', |
| rate_limited: 'Rate limit exceeded', |
| auth_failed: 'Authentication failed', |
| provider_error: 'Provider returned an error', |
| network_error: 'Network error', |
| }, |
| } satisfies UiCatalog<Record<GeneralizedErrorClass, string>>; |
| |
| export function generalizedErrorMessageForLocale( |
| error: unknown, |
| fallback: string, |
| locale: UiLocale, |
| ): string { |
| const classified = classifyGeneralizedError(error); |
| return classified ? GENERALIZED_ERROR_COPY[locale][classified] : fallback; |
| } |
| |
| export function generalizedErrorMessage(error: unknown, fallback = 'Operation failed'): string { |
| return generalizedErrorMessageForLocale(error, fallback, 'en'); |
| } |
| |
| export function isAuthenticationErrorText(message: string): boolean { |
| return message.replace(/\bauthorit\w*/g, '').includes('auth'); |
| } |
| |
| const reportedFailures = new WeakSet<object>(); |
| |
| /** Redacted diagnostics channel for unexpected operation failures. Copy |
| * catalogs live here (bare-importable) because a depended-on copy catalog may |
| * only hold bare package runtime imports. */ |
| export function reportUnexpectedOperation(scope: string, error: unknown): void { |
| // One failure, one diagnostic: a rejection formatted again by an outer layer |
| // is the same defect, not a second one. |
| if (typeof error === 'object' && error !== null) { |
| if (reportedFailures.has(error)) return; |
| reportedFailures.add(error); |
| } |
| const detail = |
| error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); |
| console.error(`[${scope}] operation failed:`, redactSecrets(detail)); |
| } |
| |
| export function unexpectedOperationFallback( |
| error: unknown, |
| fallback: string, |
| scope: string, |
| ): string { |
| reportUnexpectedOperation(scope, error); |
| return fallback; |
| } |