From 808698dc46c39c821da3d355631660275f7ed2bd Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 22:26:47 +0000 Subject: [PATCH] fix: keep Alta bearer out of proxy command lines --- .github/workflows/deploy-pages.yml | 37 -------------- README.md | 5 +- .../2026-08-19-apt-security-foundation.md | 8 +-- docs/security/2026-08-security-baseline.md | 4 +- index.html | 6 +++ main.js | 2 +- package.json | 1 + preload.js | 2 +- renderer-controller.js | 41 +++++++++++++++ renderer.js | 32 ++++++++---- src/electron-runtime.js | 9 ++-- src/proxy-launch.js | 31 ++++++------ test/proxy-launch.test.js | 39 +++++++-------- test/renderer-controller.test.js | 41 +++++++++++++++ test/runtime-contract.test.js | 50 +++++++++++++++++-- 15 files changed, 207 insertions(+), 101 deletions(-) delete mode 100644 .github/workflows/deploy-pages.yml create mode 100644 renderer-controller.js create mode 100644 test/renderer-controller.test.js diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml deleted file mode 100644 index 4892639..0000000 --- a/.github/workflows/deploy-pages.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Deploy GitHub Pages - -on: - push: - branches: [master] - paths: - - 'docs/**' - - '.github/workflows/deploy-pages.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Configure Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docs - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 3e06161..974b6c2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ APT is a Windows Electron desktop app that imports an existing Alta Video sessio - The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value. - The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin. - The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it. -- Proxy launch is a direct `spawn` of the fixed helper with `shell: false`; stop actions can target only children owned by this app. +- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. Its exact arguments are deployment host, non-secret Alta username/email, and selected device UUID; no session bearer, password, or 2FA value reaches the command line. Stop actions can target only children owned by this app. - Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update. ## Requirements @@ -30,7 +30,8 @@ npm start 2. Start APT. Under **Bridge Pairing**, copy the one-time secret. 3. Open the extension pairing settings, paste the secret, and save it. 4. Visit your Alta deployment in Chrome and use **Send to APT**. -5. Select a local camera and choose **Start Proxy**. +5. Select a local camera, enter your Alta username/email, and choose **Start Proxy**. +6. Complete the password and 2FA prompts in the helper's visible Windows console. APT does not collect or pass those secrets. Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication. diff --git a/docs/plans/2026-08-19-apt-security-foundation.md b/docs/plans/2026-08-19-apt-security-foundation.md index d2484a3..dba08e9 100644 --- a/docs/plans/2026-08-19-apt-security-foundation.md +++ b/docs/plans/2026-08-19-apt-security-foundation.md @@ -36,7 +36,7 @@ Feature discovery begins only after Phase A passes independent security review a ## Non-negotiable guardrails -- GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from GitHub. +- GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from alternate source hosts. - Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer. - Preserve current Tool Hub downloads and production visibility until Zac approves replacement. - Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels. @@ -73,7 +73,7 @@ git worktree add -b hardening/security-foundation /home/peji/worktrees/apt-secur git -C /home/peji/worktrees/apt-security-foundation status --short --branch ``` -**Expected:** clean branch based on exact GitPeji `origin/master`; no GitHub remote used. +**Expected:** clean branch based on exact GitPeji `origin/master`; no alternate-host remote used. **Commit:** `docs: record APT security hardening baseline` @@ -387,7 +387,7 @@ npm run check **Files:** - Create: `.gitea/workflows/ci.yml` - Create: `.gitea/workflows/release.yml` -- Remove or retire: `.github/workflows/deploy-pages.yml` +- Remove or retire the legacy alternate-host Pages workflow. - Modify: `docs/index.html` - Create: `scripts/verify-kit.js` @@ -395,7 +395,7 @@ npm run check 1. CI gate: `npm ci`, syntax, tests, audit policy, Windows unpacked build, kit verification, secret scan. 2. Release gate: tag/version match, clean source, approved helper checksum, exact artifact hashes, SBOM, checksums, retained logs. -3. Point docs/downloads to GitPeji or the approved Tool Hub route, never GitHub. +3. Point docs/downloads to GitPeji or the approved Tool Hub route, never an alternate source host. 4. Publish no artifact automatically until the first manual release rehearsal passes. 5. Rehearse on a non-production candidate tag and verify fresh download/extraction. diff --git a/docs/security/2026-08-security-baseline.md b/docs/security/2026-08-security-baseline.md index d85bfd4..df20ddb 100644 --- a/docs/security/2026-08-security-baseline.md +++ b/docs/security/2026-08-security-baseline.md @@ -7,11 +7,11 @@ - Implementation plan: `docs/plans/2026-08-19-apt-security-foundation.md` - Starting package version: `1.0.0` - Starting audit: 25 findings (1 critical, 22 high, 2 moderate); production-only audit 3 findings (2 high, 1 moderate) -- Starting release state: GitPeji/GitHub split-brain, no application CI, unsafe unsigned updater, no automated tests +- Starting release state: split release hosting, no application CI, unsafe unsigned updater, no automated tests ## Guardrails -- GitPeji only; GitHub remote removed. +- GitPeji only; alternate-host remote removed. - Production Tool Hub and existing downloads remain unchanged. - Synthetic sentinel credentials only in tests. - Work remains on the isolated hardening branch until review gates pass. diff --git a/index.html b/index.html index 9ff6456..f97b005 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,11 @@ +
+ + +
+

The proxy helper opens a Windows console and prompts for your password and 2FA. APT never sends those secrets on its command line.

@@ -97,6 +102,7 @@
+ diff --git a/main.js b/main.js index f2dfea9..8a08077 100644 --- a/main.js +++ b/main.js @@ -77,7 +77,7 @@ function registerIpcHandlers() { registerIpc('get-devices', () => runtime.getDevices()); registerIpc('get-device-sites', () => runtime.getDeviceSites()); registerIpc('get-auth-info', () => runtime.getAuthInfo()); - registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId)); + registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username)); registerIpc('stop-proxy', async (key) => { const result = await runtime.stopProxy(key); sendConnectionState(); diff --git a/package.json b/package.json index db926ae..fec0d8e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "main.js", "preload.js", "renderer.js", + "renderer-controller.js", "index.html", "styles.css", "src/**/*", diff --git a/preload.js b/preload.js index 56855af..93f85ba 100644 --- a/preload.js +++ b/preload.js @@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', Object.freeze({ getDevices: () => ipcRenderer.invoke('get-devices'), getDeviceSites: () => ipcRenderer.invoke('get-device-sites'), getAuthInfo: () => ipcRenderer.invoke('get-auth-info'), - launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId), + launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username), stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key), disconnect: () => ipcRenderer.invoke('disconnect'), getConnectionState: () => ipcRenderer.invoke('get-connection-state'), diff --git a/renderer-controller.js b/renderer-controller.js new file mode 100644 index 0000000..84fded4 --- /dev/null +++ b/renderer-controller.js @@ -0,0 +1,41 @@ +'use strict'; + +(function exposeRendererController(globalScope) { + function createRendererController({ + disconnect, + renderConnectionState, + clearDisconnectedState, + showConnectionStatus, + } = {}) { + if ( + typeof disconnect !== 'function' || + typeof renderConnectionState !== 'function' || + typeof clearDisconnectedState !== 'function' || + typeof showConnectionStatus !== 'function' + ) { + throw new TypeError('Renderer controller dependencies are invalid.'); + } + + return Object.freeze({ + async disconnect() { + const result = await disconnect(); + if (!result || result.success !== true) { + const message = result && typeof result.message === 'string' + ? result.message + : 'Failed to disconnect from Alta. The current connection remains active.'; + showConnectionStatus(message, 'error'); + return result; + } + + renderConnectionState(result); + clearDisconnectedState(); + showConnectionStatus('Disconnected from Alta.', 'info'); + return result; + }, + }); + } + + const api = Object.freeze({ createRendererController }); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (globalScope) globalScope.AptRendererController = api; +}(typeof window !== 'undefined' ? window : undefined)); diff --git a/renderer.js b/renderer.js index 8b45106..a0ed42e 100644 --- a/renderer.js +++ b/renderer.js @@ -7,6 +7,7 @@ const statusIndicator = document.getElementById('statusIndicator'); const deviceSearch = document.getElementById('deviceSearch'); const disconnectBtn = document.getElementById('disconnectBtn'); const selectedDeviceId = document.getElementById('selectedDeviceId'); +const altaUsername = document.getElementById('altaUsername'); const startProxyBtn = document.getElementById('startProxyBtn'); const stopProxyBtn = document.getElementById('stopProxyBtn'); const checkUpdateBtn = document.getElementById('checkUpdateBtn'); @@ -54,7 +55,8 @@ function renderConnectionState(state) { function updateProxyButtons() { const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; - startProxyBtn.disabled = !connection.connected || !id || active; + const hasUsername = altaUsername.value.trim().length > 0; + startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active; stopProxyBtn.disabled = !id || !active; } @@ -204,14 +206,21 @@ async function loadDevices() { } } +const rendererController = window.AptRendererController.createRendererController({ + disconnect: () => window.electronAPI.disconnect(), + renderConnectionState, + clearDisconnectedState: () => { + allDevices = []; + allSites = {}; + collapsedSites.clear(); + altaUsername.value = ''; + clearDeviceList(); + }, + showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type), +}); + async function handleDisconnect() { - const state = await window.electronAPI.disconnect(); - renderConnectionState(state); - allDevices = []; - allSites = {}; - collapsedSites.clear(); - clearDeviceList(); - showStatus(connectionStatus, 'Disconnected from Alta.', 'info'); + await rendererController.disconnect(); } async function handleStartProxy() { @@ -219,7 +228,7 @@ async function handleStartProxy() { if (!id) return; startProxyBtn.disabled = true; showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); - const result = await window.electronAPI.launchProxy(id); + const result = await window.electronAPI.launchProxy(id, altaUsername.value); if (result.success) { connection = await window.electronAPI.getConnectionState(); renderConnectionState(connection); @@ -284,8 +293,10 @@ async function initialize() { renderPairingStatus(await window.electronAPI.getPairingStatus()); if (connection.connected) await loadDevices(); window.electronAPI.onConnectionStateChanged(async (state) => { + const wasConnected = connection.connected; + const previousOrigin = connection.origin; renderConnectionState(state); - if (state.connected) { + if (state.connected && (!wasConnected || state.origin !== previousOrigin)) { allDevices = []; allSites = {}; clearDeviceList(); @@ -300,6 +311,7 @@ disconnectBtn.addEventListener('click', handleDisconnect); startProxyBtn.addEventListener('click', handleStartProxy); stopProxyBtn.addEventListener('click', handleStopProxy); deviceSearch.addEventListener('input', filterDevices); +altaUsername.addEventListener('input', updateProxyButtons); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; }); diff --git a/src/electron-runtime.js b/src/electron-runtime.js index a65926c..cfdf200 100644 --- a/src/electron-runtime.js +++ b/src/electron-runtime.js @@ -10,7 +10,7 @@ const { readJsonBody, } = require('./bridge-auth'); const { RELEASES_PAGE_URL } = require('./update-policy'); -const { validateDeviceId } = require('./proxy-launch'); +const { validateDeviceId, validateUsername } = require('./proxy-launch'); const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json'; @@ -19,7 +19,7 @@ function safeErrorMessage(error, fallback) { 'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR', 'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE', 'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND', - 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'SPAWN_FAILED', + 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED', ]); return error && allowed.has(error.code) && typeof error.message === 'string' ? error.message @@ -253,9 +253,10 @@ class AppRuntime { } } - async launchProxy(deviceId) { + async launchProxy(deviceId, username) { try { const validatedId = validateDeviceId(deviceId); + const validatedUsername = validateUsername(username); this._reconcileProxies(); if (!this.allowedDeviceIds.has(validatedId)) { return { success: false, message: 'Select a device from the current Alta device list.' }; @@ -266,7 +267,7 @@ class AppRuntime { const session = this.sessionStore.requireSession(); const result = this.proxyManager.launchProxy({ deploymentHost: new URL(session.origin).hostname, - cookie: session.cookie, + username: validatedUsername, deviceId: validatedId, }); this.proxyByDevice.set(validatedId, result.processId); diff --git a/src/proxy-launch.js b/src/proxy-launch.js index aef53c2..2954d1b 100644 --- a/src/proxy-launch.js +++ b/src/proxy-launch.js @@ -5,7 +5,7 @@ const nodePath = require('node:path'); const { spawn: nodeSpawn } = require('node:child_process'); const HELPER_FILENAME = 'aware-cam-proxy.exe'; -const MAX_COOKIE_LENGTH = 4096; +const MAX_USERNAME_LENGTH = 254; const MAX_HOST_LENGTH = 253; const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; @@ -41,20 +41,19 @@ function validateDeviceId(value) { return value.toLowerCase(); } -function validateCookie(value) { - if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) { - throw new ProxyLaunchError('Cookie is invalid.', 'INVALID_COOKIE'); +function validateUsername(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) { + throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME'); } - if (/[\u0000-\u001f\u007f]/.test(value)) { - throw new ProxyLaunchError('Cookie must not contain control characters.', 'INVALID_COOKIE'); + if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) { + throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME'); } return value; } -function redactMessage(error, secret) { +function safeProcessError(error) { const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error'; - const withoutSecret = secret ? source.split(secret).join('[REDACTED]') : source; - return withoutSecret.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 512); + return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512); } function safeMetadata(entry, status = entry.status) { @@ -98,7 +97,7 @@ function createProxyManager({ const deploymentHost = validateDeploymentHost(request.deploymentHost); const deviceId = validateDeviceId(request.deviceId); - const cookie = validateCookie(request.cookie); + const username = validateUsername(request.username); if (!fs.existsSync(helperPath)) { throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND'); @@ -108,17 +107,17 @@ function createProxyManager({ try { child = spawn( helperPath, - ['-a', deploymentHost, '-d', deviceId, '-k', cookie], + ['-a', deploymentHost, '-u', username, '-d', deviceId], { shell: false, - detached: false, + detached: true, stdio: 'ignore', windowsHide: false } ); } catch (error) { throw new ProxyLaunchError( - `Failed to launch proxy helper: ${redactMessage(error, cookie)}`, + `Failed to launch proxy helper: ${safeProcessError(error)}`, 'SPAWN_FAILED' ); } @@ -202,10 +201,10 @@ function createProxyManager({ module.exports = { HELPER_FILENAME, - MAX_COOKIE_LENGTH, + MAX_USERNAME_LENGTH, ProxyLaunchError, createProxyManager, - validateCookie, validateDeploymentHost, - validateDeviceId + validateDeviceId, + validateUsername }; diff --git a/test/proxy-launch.test.js b/test/proxy-launch.test.js index 373e7ff..b95307e 100644 --- a/test/proxy-launch.test.js +++ b/test/proxy-launch.test.js @@ -11,7 +11,7 @@ const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool'; const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe'; const VALID_HOST = 'tenant.avasecurity.com'; const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000'; -const SYNTHETIC_COOKIE = 'va=HERMES_SENTINEL_SECRET'; +const VALID_USERNAME = 'proxy.operator+apt@example.com'; function loadModule() { return require(MODULE_PATH); @@ -56,7 +56,7 @@ function validRequest(overrides = {}) { return { deploymentHost: VALID_HOST, deviceId: VALID_DEVICE_ID, - cookie: SYNTHETIC_COOKIE, + username: VALID_USERNAME, ...overrides }; } @@ -72,10 +72,10 @@ test('launches the approved helper directly with exact argv and shell disabled', assert.deepEqual(calls, [[ APPROVED_HELPER, - ['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE], + ['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID], { shell: false, - detached: false, + detached: true, stdio: 'ignore', windowsHide: false } @@ -89,13 +89,13 @@ test('launches the approved helper directly with exact argv and shell disabled', }); }); -test('passes cookie metacharacters literally in argv without invoking a shell', () => { - const cookie = 'va=abc&whoami|calc.exe;<>()^%!"`$'; +test('passes username punctuation literally in argv without invoking a shell', () => { + const username = 'proxy+apt&literal|name@example.com'; const { manager, calls } = createHarness(); - manager.launchProxy(validRequest({ cookie })); + manager.launchProxy(validRequest({ username })); - assert.equal(calls[0][1][5], cookie); + assert.equal(calls[0][1][3], username); assert.equal(calls[0][2].shell, false); }); @@ -104,8 +104,8 @@ test('rejects the CRLF calc.exe reproducer in every structured input', () => { const attacks = [ { deploymentHost: `${VALID_HOST}\r\ncalc.exe` }, { deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` }, - { cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` }, - { cookie: `${SYNTHETIC_COOKIE}\0calc.exe` } + { username: `${VALID_USERNAME}\r\ncalc.exe` }, + { username: `${VALID_USERNAME}\0calc.exe` } ]; for (const attack of attacks) { @@ -178,10 +178,10 @@ test('normalizes a valid Alta hostname to lowercase', () => { assert.equal(calls[0][1][1], 'tenant.avigilon.com'); }); -test('rejects empty, oversized, and control-character cookies', () => { - for (const cookie of ['', 'x'.repeat(4097), 'va=abc\nxyz', 'va=abc\rxyz', 'va=abc\0xyz']) { +test('rejects empty, oversized, non-string, and control-character usernames', () => { + for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) { const { manager } = createHarness(); - assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i); + assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i); } }); @@ -205,27 +205,25 @@ test('requires an absolute approved application directory and Windows platform', ); }); -test('redacts the cookie if spawn throws an error containing it', () => { +test('reports a bounded spawn failure without credential redaction machinery', () => { const { createProxyManager } = loadModule(); const manager = createProxyManager({ appDirectory: APPROVED_DIRECTORY, fs: { existsSync: () => true }, platform: 'win32', - spawn: () => { throw new Error(`spawn failed for ${SYNTHETIC_COOKIE}`); } + spawn: () => { throw new Error('spawn failed'); } }); assert.throws( () => manager.launchProxy(validRequest()), (error) => { assert.match(error.message, /spawn failed/); - assert.match(error.message, /\[REDACTED\]/); - assert.doesNotMatch(error.message, /HERMES_SENTINEL_SECRET/); return true; } ); }); -test('tracks only safe process metadata and never exposes or persists cookies', () => { +test('tracks only safe process metadata and never exposes usernames', () => { const { manager } = createHarness(); manager.launchProxy(validRequest()); @@ -236,7 +234,7 @@ test('tracks only safe process metadata and never exposes or persists cookies', startedAt: 1_777_777_777_777, status: 'running' }]); - assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SECRET/); + assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/); }); test('stopping one tracked process leaves the other tracked process alive', () => { @@ -309,8 +307,9 @@ test('exit events remove only the matching owned child', () => { assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]); }); -test('source contains no shell launchers, broad process killers, or persistence APIs', () => { +test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8'); + assert.doesNotMatch(source, /cookie|bearer|token|-k/i); assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i); assert.doesNotMatch(source, /\.(?:bat|command)\b/i); assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/); diff --git a/test/renderer-controller.test.js b/test/renderer-controller.test.js new file mode 100644 index 0000000..841097e --- /dev/null +++ b/test/renderer-controller.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createRendererController } = require('../renderer-controller'); + +function harness(result) { + const calls = []; + const controller = createRendererController({ + disconnect: async () => result, + renderConnectionState: (state) => calls.push(['render', state]), + clearDisconnectedState: () => calls.push(['clear']), + showConnectionStatus: (message, type) => calls.push(['status', message, type]), + }); + return { controller, calls }; +} + +test('failed disconnect retains visible connection, device, and proxy state', async () => { + const result = { + success: false, + connected: true, + activeProxies: [{ deviceId: '550e8400-e29b-41d4-a716-446655440000', processId: 4101 }], + message: 'Could not stop every active proxy. The Alta session remains connected.', + }; + const { controller, calls } = harness(result); + + assert.equal(await controller.disconnect(), result); + assert.deepEqual(calls, [['status', result.message, 'error']]); +}); + +test('successful disconnect renders disconnected state and clears device state', async () => { + const result = { success: true, connected: false, origin: null, activeProxies: [] }; + const { controller, calls } = harness(result); + + assert.equal(await controller.disconnect(), result); + assert.deepEqual(calls, [ + ['render', result], + ['clear'], + ['status', 'Disconnected from Alta.', 'info'], + ]); +}); diff --git a/test/runtime-contract.test.js b/test/runtime-contract.test.js index 80ad2db..5d4d074 100644 --- a/test/runtime-contract.test.js +++ b/test/runtime-contract.test.js @@ -79,11 +79,14 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- }); assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] }); - const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000'); + const launched = await runtime.launchProxy( + '550e8400-e29b-41d4-a716-446655440000', + 'proxy.operator@example.com' + ); assert.equal(launched.success, true); assert.deepEqual(calls[0], { deploymentHost: 'customer.avasecurity.com', - cookie: 'top-secret-cookie', + username: 'proxy.operator@example.com', deviceId: '550e8400-e29b-41d4-a716-446655440000', }); assert.deepEqual(runtime.getConnectionState(), { @@ -99,6 +102,29 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- assert.equal((await runtime.stopProxy(999)).success, false); }); +test('runtime rejects invalid usernames before calling the proxy manager', async () => { + const sessionStore = createSessionStore(); + sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie'); + const deviceId = '550e8400-e29b-41d4-a716-446655440000'; + let launches = 0; + const runtime = new AppRuntime({ + sessionStore, + altaClient: { getDevices: async () => [{ guid: deviceId }] }, + proxyManager: { + launchProxy() { launches += 1; }, + listTrackedProxies() { return []; }, + }, + }); + await runtime.getDevices(); + + for (const username of ['', 'x'.repeat(255), 'operator@example.com\r\n-k secret', null]) { + const result = await runtime.launchProxy(deviceId, username); + assert.equal(result.success, false); + assert.match(result.message, /username/i); + } + assert.equal(launches, 0); +}); + test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => { const protect = (value) => Buffer.from(`protected:${value}`); const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length); @@ -223,10 +249,10 @@ test('runtime reconciles exited children and permits relaunch for the same devic }, }); await runtime.getDevices(); - assert.equal((await runtime.launchProxy(deviceId)).processId, 4101); + assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101); tracked.length = 0; assert.deepEqual(runtime.getConnectionState().activeProxies, []); - assert.equal((await runtime.launchProxy(deviceId)).processId, 4102); + assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102); }); test('disconnect stops every owned proxy before clearing the Alta session', async () => { @@ -304,8 +330,12 @@ test('preload and renderer expose only narrow, credential-free contracts', () => ]; for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`)); assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/); + assert.match(preload, /launchProxy:\s*\(deviceId, username\)/); + assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i); assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/); + assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/); assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/); + assert.match(html, /id="altaUsername"/); assert.match(renderer, /openFixedReleasesPage/); assert.match(html, /Bridge Pairing/); }); @@ -327,3 +357,15 @@ test('source policy removes legacy credential IPC, shell launch, broad kill, and assert.match(read('main.js'), /18247/); assert.match(read('main.js'), /shell\.openExternal/); }); + +test('production source and documentation are GitPeji-only with no active GitHub workflow', () => { + assert.equal(fs.existsSync(path.join(ROOT, '.github', 'workflows', 'deploy-pages.yml')), false); + const sourceAndDocs = [ + 'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'index.html', + 'src/electron-runtime.js', 'src/proxy-launch.js', 'README.md', 'CLAUDE.md', + 'docs/security/2026-08-security-baseline.md', + 'docs/plans/2026-08-19-apt-security-foundation.md', + ].map(read).join('\n'); + assert.doesNotMatch(sourceAndDocs, /github/i); + assert.match(sourceAndDocs, /GitPeji/); +});