diff --git a/main.js b/main.js index f032794..63fb249 100644 --- a/main.js +++ b/main.js @@ -3,7 +3,7 @@ const path = require('path'); const { createRegistry } = require('./src/providers/registry'); const { UsageStore } = require('./src/main/usage-store'); const { CollectorScheduler } = require('./src/main/scheduler'); -const { getHistory, settings } = require('./src/main/store'); +const { getHistory, isSecretSettingsKey, redactSettingsForRenderer, settings } = require('./src/main/store'); const { providerLogoUrl } = require('./src/main/assets'); const { createTray } = require('./src/main/tray'); const { AlertManager } = require('./src/main/alerts'); @@ -48,6 +48,7 @@ let lastWidgetProviderCount = 1; function mergeSettingsPatch(patch = {}) { for (const [key, value] of Object.entries(patch)) { + if (isSecretSettingsKey(key)) continue; if (key === 'floatingWidget' && value && typeof value === 'object') { const prev = settings.get('floatingWidget') || {}; const merged = { @@ -144,7 +145,7 @@ function applySettingsPatch(patch) { } function broadcastSettings() { - const payload = settings.store; + const payload = redactSettingsForRenderer(settings.store); for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.webContents.send('settings:updated', payload); @@ -242,10 +243,10 @@ function registerIpc() { throw err; } }); - ipcMain.handle('settings:get', () => settings.store); + ipcMain.handle('settings:get', () => redactSettingsForRenderer(settings.store)); ipcMain.handle('settings:update', (_e, patch) => { applySettingsPatch(patch || {}); - return settings.store; + return redactSettingsForRenderer(settings.store); }); ipcMain.on('window:minimize', (e) => { diff --git a/package-lock.json b/package-lock.json index dd8130e..af862a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^28.3.3", + "electron": "^34.0.0", "electron-builder": "^24.9.1", "playwright": "^1.60.0" }, @@ -998,28 +998,29 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "dev": true + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" }, "node_modules/@types/ms": { - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, - "node_modules/@types/node/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -4064,15 +4065,15 @@ } }, "node_modules/electron": { - "version": "28.3.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", - "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "version": "34.5.8", + "resolved": "https://registry.npmjs.org/electron/-/electron-34.5.8.tgz", + "integrity": "sha512-vxLD65mabTzYmEVa9KceMHM0+zO+vqgrhcyNVlmTd0IGV5J7XZ8v/qElm0o4YQ4wPeq7olZkUjZkBQQEdr23/g==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -6057,6 +6058,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -7393,6 +7404,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 46b453d..f07fdf3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "start": "electron .", "postinstall": "electron-builder install-app-deps", - "test": "node --test tests/**/*.test.js", + "test": "node scripts/run-tests.mjs", "icons:generate": "node scripts/generate-icons.js", "screenshots": "node scripts/capture-readme-screenshots.mjs", "build": "electron-builder", @@ -48,7 +48,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^28.3.3", + "electron": "^34.0.0", "electron-builder": "^24.9.1", "playwright": "^1.60.0" }, diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..6cfd3f9 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,27 @@ +import { readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function collectTests(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectTests(full)); + continue; + } + if (entry.name.endsWith('.test.js')) files.push(full); + } + return files; +} + +const tests = collectTests(join(root, 'tests')); +const result = spawnSync(process.execPath, ['--test', ...tests], { + cwd: root, + stdio: 'inherit', +}); + +process.exit(result.status ?? 1); \ No newline at end of file diff --git a/src/main/store.js b/src/main/store.js index 4308d0c..610ba14 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -33,6 +33,22 @@ const settings = new Store({ }, }); +const SECRET_KEY_PATTERN = /^secret_/; +const SECRET_META_KEY_PATTERN = /^secret_meta_/; + +function isSecretSettingsKey(key) { + return SECRET_KEY_PATTERN.test(key) || SECRET_META_KEY_PATTERN.test(key); +} + +function redactSettingsForRenderer(storeObject = {}) { + const redacted = {}; + for (const [key, value] of Object.entries(storeObject)) { + if (isSecretSettingsKey(key)) continue; + redacted[key] = value; + } + return redacted; +} + function secretMetaKey(key) { return `secret_meta_${key}`; } @@ -44,9 +60,7 @@ function setSecret(key, value) { return; } if (!safeStorage.isEncryptionAvailable()) { - settings.set(`secret_${key}`, value); - settings.set(secretMetaKey(key), 'plain'); - return; + throw new Error('safeStorage encryption is not available; refusing to persist secret'); } settings.set(`secret_${key}`, safeStorage.encryptString(String(value)).toString('base64')); settings.set(secretMetaKey(key), 'enc'); @@ -95,6 +109,8 @@ function getHistory(providerId) { module.exports = { settings, + isSecretSettingsKey, + redactSettingsForRenderer, setSecret, getSecret, setProviderDisconnected, diff --git a/tests/main/settings-security.test.js b/tests/main/settings-security.test.js new file mode 100644 index 0000000..77c51b2 --- /dev/null +++ b/tests/main/settings-security.test.js @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const mainSrc = readFileSync(path.join(root, 'main.js'), 'utf8'); +const storeSrc = readFileSync(path.join(root, 'src/main/store.js'), 'utf8'); + +test('isSecretSettingsKey matches secret_ and secret_meta_ store keys', () => { + assert.match(storeSrc, /function isSecretSettingsKey/); + assert.match(storeSrc, /SECRET_KEY_PATTERN = \/\^secret_\//); + assert.match(storeSrc, /SECRET_META_KEY_PATTERN = \/\^secret_meta_\//); +}); + +test('redactSettingsForRenderer strips secret keys before renderer IPC', () => { + assert.match(storeSrc, /function redactSettingsForRenderer/); + assert.match(storeSrc, /isSecretSettingsKey\(key\)/); +}); + +test('settings:get and broadcastSettings redact secrets for renderer', () => { + assert.match(mainSrc, /redactSettingsForRenderer\(settings\.store\)/); + assert.match(mainSrc, /settings:get.*redactSettingsForRenderer/s); + assert.match(mainSrc, /function broadcastSettings/); + assert.match(mainSrc, /settings:updated.*payload/s); +}); + +test('mergeSettingsPatch rejects secret_ patch keys', () => { + assert.match(mainSrc, /isSecretSettingsKey\(key\)/); + assert.match(mainSrc, /if \(isSecretSettingsKey\(key\)\) continue/); +}); + +test('setSecret fails closed when safeStorage is unavailable', () => { + assert.match(storeSrc, /safeStorage encryption is not available; refusing to persist secret/); + assert.doesNotMatch(storeSrc, /settings\.set\(`secret_\$\{key\}`, value\)/); +}); \ No newline at end of file