diff --git a/README.zh-CN.md b/README.zh-CN.md index 6b65cda..439e0ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -182,11 +182,11 @@ 从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。 -| 平台 | 下载链接 | -| ------- | ------------------------------------------------------------------------------ | +| 平台 | 下载链接 | +| ------------------- | ------------------------------------------------------------------------------ | | Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) | -| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) | -| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) | +| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) | +| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) | ### 从源码构建 diff --git a/src/main.ts b/src/main.ts index 79d54d1..2c368b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,25 +28,10 @@ import { rpcHandler } from './ipc/handler'; import { ConfigManager } from './ipc/config/manager'; import { AppConfig } from './types/config'; import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart'; +import { safeStringifyPacket } from './utils/sensitiveDataMasking'; const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log'); -/** - * Safely stringify an object, handling circular references - */ -function safeStringifyPacket(obj: unknown): string { - const seen = new WeakSet(); - return JSON.stringify(obj, (key, value) => { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) { - return '[Circular]'; - } - seen.add(value); - } - return value; - }); -} - function logPacket(data: any) { try { fs.appendFileSync( diff --git a/src/tests/unit/logger.test.ts b/src/tests/unit/logger.test.ts index acac1e5..4982e21 100644 --- a/src/tests/unit/logger.test.ts +++ b/src/tests/unit/logger.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import { logger } from '../../utils/logger'; import fs from 'fs'; import path from 'path'; @@ -42,8 +42,38 @@ describe('Logger Utilities', () => { }; beforeEach(() => { - if (!fs.existsSync(testLogDir)) { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + if (fs.existsSync(testLogDir)) { + fs.rmSync(testLogDir, { recursive: true, force: true }); + } fs.mkdirSync(testLogDir, { recursive: true }); + } catch (err) { + console.error('beforeEach: setup testLogDir failed', err); + throw err; + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { + if (fs.existsSync(testLogDir)) { + fs.rmSync(testLogDir, { recursive: true, force: true }); + } + } catch (err) { + console.error('afterEach: cleanup testLogDir failed', err); + throw err; + } + }); + + afterAll(() => { + try { + if (fs.existsSync(testLogDir)) { + fs.rmSync(testLogDir, { recursive: true, force: true }); + } + } catch (err) { + console.error('afterAll: cleanup testLogDir failed', err); } }); diff --git a/src/tests/unit/sensitive-data-masking.test.ts b/src/tests/unit/sensitive-data-masking.test.ts new file mode 100644 index 0000000..41d5fc3 --- /dev/null +++ b/src/tests/unit/sensitive-data-masking.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeObject, safeStringifyPacket } from '@/utils/sensitiveDataMasking'; + +describe('sensitive data masking', () => { + describe('sanitizeObject', () => { + it('masks password', () => { + expect(sanitizeObject({ password: 'secret123' })).toEqual({ password: '[REDACTED]' }); + }); + + it('masks token and variants (case-insensitive)', () => { + expect(sanitizeObject({ token: 'abc' })).toEqual({ token: '[REDACTED]' }); + expect(sanitizeObject({ Authorization: 'Bearer xyz' })).toEqual({ + Authorization: '[REDACTED]', + }); + expect(sanitizeObject({ api_key: 'key123' })).toEqual({ api_key: '[REDACTED]' }); + expect(sanitizeObject({ refresh_token: 'rt' })).toEqual({ refresh_token: '[REDACTED]' }); + }); + + it('masks nested keys', () => { + expect( + sanitizeObject({ + user: { name: 'Alice', password: 'pwd', nested: { token: 't' } }, + }), + ).toEqual({ + user: { name: 'Alice', password: '[REDACTED]', nested: { token: '[REDACTED]' } }, + }); + }); + + it('does not mask non-sensitive keys', () => { + expect(sanitizeObject({ name: 'Alice', id: 1 })).toEqual({ name: 'Alice', id: 1 }); + }); + + it('handles null and undefined', () => { + expect(sanitizeObject(null)).toBe(null); + expect(sanitizeObject(undefined)).toBe(undefined); + }); + + it('handles arrays recursively', () => { + expect(sanitizeObject([{ password: 'x' }, { name: 'y' }])).toEqual([ + { password: '[REDACTED]' }, + { name: 'y' }, + ]); + }); + + it('handles JSON string with sensitive fields', () => { + const json = JSON.stringify({ password: 'hidden' }); + expect(sanitizeObject(json)).toBe(JSON.stringify({ password: '[REDACTED]' })); + }); + + it('leaves non-JSON string unchanged', () => { + expect(sanitizeObject('plain text')).toBe('plain text'); + }); + + it('leaves malformed JSON string unchanged (no throw)', () => { + expect(sanitizeObject('{"broken":')).toBe('{"broken":'); + expect(sanitizeObject('not valid json ]')).toBe('not valid json ]'); + }); + + it('handles circular references without infinite loops', () => { + const circular: Record = { name: 'a', password: 'secret' }; + circular.self = circular; + expect(sanitizeObject(circular)).toEqual({ + name: 'a', + password: '[REDACTED]', + self: '[Circular]', + }); + }); + + it('masks session_id, cookie, client_secret, otp, pin', () => { + expect( + sanitizeObject({ + session_id: 'sess', + cookie: 'c', + client_secret: 'cs', + otp: '1234', + pin: '0000', + }), + ).toEqual({ + session_id: '[REDACTED]', + cookie: '[REDACTED]', + client_secret: '[REDACTED]', + otp: '[REDACTED]', + pin: '[REDACTED]', + }); + }); + }); + + describe('safeStringifyPacket', () => { + it('stringifies with sensitive fields masked', () => { + const out = safeStringifyPacket({ user: 'a', password: 'p' }); + expect(out).toContain('"user":"a"'); + expect(out).toContain('"password":"[REDACTED]"'); + expect(() => JSON.parse(out)).not.toThrow(); + }); + }); +}); diff --git a/src/utils/sensitiveDataMasking.ts b/src/utils/sensitiveDataMasking.ts new file mode 100644 index 0000000..5bc11df --- /dev/null +++ b/src/utils/sensitiveDataMasking.ts @@ -0,0 +1,105 @@ +/** + * Keys to mask in logs to avoid exposing sensitive data. + */ +const SENSITIVE_KEYS = [ + 'password', + 'token', + 'apikey', + 'api_key', + 'secret', + 'authorization', + 'credentials', + 'accesstoken', + 'access_token', + 'refreshtoken', + 'refresh_token', + 'bearertoken', + 'bearer_token', + 'sessionid', + 'session_id', + 'cookie', + 'private_key', + 'privatekey', + 'client_secret', + 'clientsecret', + 'auth', + 'authcode', + 'auth_code', + 'code', + 'otp', + 'pin', + 'verificationcode', + 'verification_code', +]; + +const CIRCULAR_PLACEHOLDER = '[Circular]'; + +function sanitizeWithSeen(obj: unknown, seen: WeakSet): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'string') { + try { + const parsed = JSON.parse(obj); + if (typeof parsed === 'object' && parsed !== null) { + return JSON.stringify(sanitizeWithSeen(parsed, seen)); + } + } catch { + // Not JSON, return unchanged + } + return obj; + } + + if (Array.isArray(obj)) { + if (seen.has(obj)) { + return CIRCULAR_PLACEHOLDER; + } + seen.add(obj); + return obj.map((item) => sanitizeWithSeen(item, seen)); + } + + if (typeof obj === 'object') { + if (seen.has(obj)) { + return CIRCULAR_PLACEHOLDER; + } + seen.add(obj); + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + if (SENSITIVE_KEYS.includes(lowerKey)) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = sanitizeWithSeen(value, seen); + } + } + return sanitized; + } + + return obj; +} + +/** + * Recursively sanitizes an object by masking sensitive field values. + * Handles circular references by replacing them with '[Circular]'. + */ +export function sanitizeObject(obj: unknown): unknown { + return sanitizeWithSeen(obj, new WeakSet()); +} + +/** + * Safely stringifies an object, handling circular references and masking sensitive data. + */ +export function safeStringifyPacket(obj: unknown): string { + const sanitized = sanitizeObject(obj); + const seen = new WeakSet(); + return JSON.stringify(sanitized, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }); +}