diff --git a/src/lib/credentials.test.ts b/src/lib/credentials.test.ts index d50ad52..194c1ff 100644 --- a/src/lib/credentials.test.ts +++ b/src/lib/credentials.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_PROFILE, assertValidProfileName, @@ -191,6 +191,45 @@ describe('ensureRestrictiveMode', () => { expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow(); }); + it('tightens the Windows ACL with icacls instead of POSIX chmod', () => { + mkdirSync(tmpRoot, { recursive: true }); + writeFileSync(credentialsPath, 'data', { mode: 0o666 }); + const spawn = vi.fn(() => ({ status: 0, signal: null, output: [], pid: 123 })) as never; + + ensureRestrictiveMode(credentialsPath, { + platform: 'win32', + env: { USERNAME: 'alice' } as NodeJS.ProcessEnv, + spawnSync: spawn, + }); + + expect(spawn).toHaveBeenCalledWith( + 'icacls', + [credentialsPath, '/inheritance:r', '/grant:r', 'alice:F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true, + }, + ); + }); + + it('warns on Windows when credentials ACL tightening cannot run', () => { + mkdirSync(tmpRoot, { recursive: true }); + writeFileSync(credentialsPath, 'data'); + const warnings: string[] = []; + const spawn = vi.fn(() => ({ status: 0, signal: null, output: [], pid: 123 })) as never; + + ensureRestrictiveMode(credentialsPath, { + platform: 'win32', + env: {} as NodeJS.ProcessEnv, + spawnSync: spawn, + warn: line => warnings.push(line), + }); + + expect(spawn).not.toHaveBeenCalled(); + expect(warnings.join('\n')).toContain('credentials file permissions were not tightened'); + }); + // POSIX-only premise: Windows has no 0644/0600 distinction to downgrade. it.skipIf(process.platform === 'win32')('downgrades over-permissive modes', () => { mkdirSync(tmpRoot, { recursive: true }); diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 21e2ac0..6c20b1f 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -7,6 +7,7 @@ import { statSync, writeFileSync, } from 'node:fs'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { localValidationError } from './errors.js'; @@ -61,6 +62,17 @@ export interface CredentialsOptions { path?: string; } +interface RestrictiveModeOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + spawnSync?: ( + command: string, + args: readonly string[], + options: { shell: false; stdio: 'ignore'; windowsHide: true }, + ) => SpawnSyncReturns; + warn?: (line: string) => void; +} + const FILE_KEY_TO_FIELD: Record = { api_key: 'apiKey', api_url: 'apiUrl', @@ -172,12 +184,62 @@ export function deleteProfile(profile: string, options: CredentialsOptions = {}) return true; } -export function ensureRestrictiveMode(path: string): void { +/** + * Enforce restrictive access on the credentials file after atomic writes. + * POSIX hosts use chmod(0600); Windows hosts use ACL tightening via icacls. + */ +export function ensureRestrictiveMode(path: string, options: RestrictiveModeOptions = {}): void { if (!existsSync(path)) return; + if ((options.platform ?? process.platform) === 'win32') { + ensureWindowsRestrictiveAcl(path, options); + return; + } const overpermissive = (statSync(path).mode & 0o077) !== 0; if (overpermissive) chmodSync(path, 0o600); } +/** + * Restrict a Windows credentials file to the current user using icacls. + * The command is invoked with an args array so credential paths are never shell-interpreted. + */ +function ensureWindowsRestrictiveAcl(path: string, options: RestrictiveModeOptions): void { + const username = (options.env ?? process.env).USERNAME?.trim(); + if (!username) { + warnWindowsAcl( + 'could not determine the Windows username; credentials file permissions were not tightened', + options, + ); + return; + } + + const run = options.spawnSync ?? spawnSync; + const result = run('icacls', [path, '/inheritance:r', '/grant:r', `${username}:F`], { + shell: false, + stdio: 'ignore', + windowsHide: true, + }); + + if (result.error) { + warnWindowsAcl( + `icacls failed while tightening credentials file permissions: ${result.error.message}`, + options, + ); + return; + } + if (result.status !== 0) { + warnWindowsAcl( + `icacls exited with status ${result.status ?? 'unknown'}; credentials file permissions may be too broad`, + options, + ); + } +} + +/** Emit an explicit warning when Windows ACL tightening cannot be completed. */ +function warnWindowsAcl(message: string, options: RestrictiveModeOptions): void { + const warn = options.warn ?? ((line: string) => process.stderr.write(`${line}\n`)); + warn(`[warning] ${message}`); +} + function resolvePath(options: CredentialsOptions): string { return options.path ?? defaultCredentialsPath(); }