Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
64 changes: 63 additions & 1 deletion src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Buffer>;
warn?: (line: string) => void;
}

const FILE_KEY_TO_FIELD: Record<string, keyof ProfileEntry> = {
api_key: 'apiKey',
api_url: 'apiUrl',
Expand Down Expand Up @@ -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();
}
Expand Down
Loading