From 665832584e1731ca749f4272d0b19378d7791fa4 Mon Sep 17 00:00:00 2001 From: Hesam Samani Date: Sun, 21 Jun 2026 06:39:33 +0200 Subject: [PATCH 1/4] fix(audit): cycle 001 P1 security fixes APIM-001: Redact secret_/secret_meta_ keys from settings:get, broadcastSettings, and settings:update IPC responses via redactSettingsForRenderer(). APIM-003: setSecret() throws when safeStorage encryption is unavailable instead of persisting plaintext secrets. APIM-005: mergeSettingsPatch() skips secret_ patch keys; secrets must use setSecret() only. APIM-002: Bump electron ^28.3.3 -> ^39.0.0 (resolves 39.8.10). Tested ^39 first; all 157 npm tests pass. Fallback to ^33/^34 not required. --- main.js | 9 +++--- package-lock.json | 44 ++++++++++++++++------------ package.json | 2 +- src/main/store.js | 22 ++++++++++++-- tests/main/settings-security.test.js | 36 +++++++++++++++++++++++ 5 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 tests/main/settings-security.test.js 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..2a6e972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^28.3.3", + "electron": "^39.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": "39.8.10", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", + "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { @@ -7393,6 +7394,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..a123151 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^28.3.3", + "electron": "^39.0.0", "electron-builder": "^24.9.1", "playwright": "^1.60.0" }, 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 From 1babe13d3873dff027c3d3a6cb506a65d1f825c6 Mon Sep 17 00:00:00 2001 From: Hesam Samani Date: Sun, 21 Jun 2026 06:41:37 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(audit):=20use=20electron=20^34=20?= =?UTF-8?q?=E2=80=94=20^39=20lacks=20better-sqlite3=20prebuilds=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron ^39.8.10 passes unit tests locally but npm ci fails on Windows CI: better-sqlite3 has no prebuilt binary for electron-v140 and native rebuild requires VS. Fallback to ^34.0.0 (resolves 34.5.8): all 157 npm tests pass and better-sqlite3 prebuild installs cleanly. --- package-lock.json | 20 +++++++++++++++----- package.json | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a6e972..af862a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^39.0.0", + "electron": "^34.0.0", "electron-builder": "^24.9.1", "playwright": "^1.60.0" }, @@ -4065,15 +4065,15 @@ } }, "node_modules/electron": { - "version": "39.8.10", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", - "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", + "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": "^22.7.7", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -6058,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", diff --git a/package.json b/package.json index a123151..617ceb8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "koffi": "^3.0.2" }, "devDependencies": { - "electron": "^39.0.0", + "electron": "^34.0.0", "electron-builder": "^24.9.1", "playwright": "^1.60.0" }, From 5f2215b6a92b6f0c87ed03452cbb1f3911af471d Mon Sep 17 00:00:00 2001 From: Hesam Samani Date: Sun, 21 Jun 2026 06:44:02 +0200 Subject: [PATCH 3/4] fix(ci): use per-directory test globs for Windows pwsh --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 617ceb8..58f8e54 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 --test tests/main/*.test.js tests/providers/*.test.js tests/renderer/*.test.js tests/shared/*.test.js tests/smoke.test.js", "icons:generate": "node scripts/generate-icons.js", "screenshots": "node scripts/capture-readme-screenshots.mjs", "build": "electron-builder", From c9e13e53db6d47ec26471a3b9dc8f0624f79f4a5 Mon Sep 17 00:00:00 2001 From: Hesam Samani Date: Sun, 21 Jun 2026 06:45:45 +0200 Subject: [PATCH 4/4] fix(ci): cross-platform test runner for Windows GHA pwsh --- package.json | 2 +- scripts/run-tests.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 scripts/run-tests.mjs diff --git a/package.json b/package.json index 58f8e54..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/main/*.test.js tests/providers/*.test.js tests/renderer/*.test.js tests/shared/*.test.js tests/smoke.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", 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