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
96 changes: 87 additions & 9 deletions src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,43 +205,121 @@ describe('ensureRestrictiveMode', () => {
expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow();
});

it('tightens the Windows ACL with icacls instead of POSIX chmod', () => {
it('tightens the Windows ACL with icacls using /reset then /inheritance:r /grant:r *S-1-3-4:F', () => {
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(
// Step 1: /reset clears broken inheritance state.
expect(spawn).toHaveBeenNthCalledWith(1, 'icacls', [credentialsPath, '/reset'], {
shell: false,
stdio: 'ignore',
windowsHide: true,
});
// Step 2: /inheritance:r removes inherited ACEs and /grant:r grants Full Control
// to the OWNER RIGHTS SID (*S-1-3-4), avoiding dependency on USERNAME resolution.
expect(spawn).toHaveBeenNthCalledWith(
2,
'icacls',
[credentialsPath, '/inheritance:r', '/grant:r', 'alice:F'],
[credentialsPath, '/inheritance:r', '/grant:r', '*S-1-3-4:F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true,
},
);
expect(spawn).toHaveBeenCalledTimes(2);
});

it('warns on Windows when credentials ACL tightening cannot run', () => {
it('warns on Windows when icacls /reset fails with an error', () => {
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(credentialsPath, 'data');
const warnings: string[] = [];
const spawn = vi.fn(() => ({ status: 0, signal: null, output: [], pid: 123 })) as never;
const spawn = vi.fn(() => ({
error: new Error('spawnSync icacls ENOENT'),
status: null,
signal: null,
output: [],
pid: 123,
})) as never;

ensureRestrictiveMode(credentialsPath, {
platform: 'win32',
spawnSync: spawn,
warn: line => warnings.push(line),
});

expect(spawn).toHaveBeenCalledTimes(1);
expect(warnings.join('\n')).toContain(
'icacls failed while resetting credentials file permissions',
);
});

it('warns on Windows when icacls /reset exits non-zero', () => {
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(credentialsPath, 'data');
const warnings: string[] = [];
const spawn = vi.fn(() => ({ status: 1, signal: null, output: [], pid: 123 })) as never;

ensureRestrictiveMode(credentialsPath, {
platform: 'win32',
spawnSync: spawn,
warn: line => warnings.push(line),
});

expect(spawn).toHaveBeenCalledTimes(1);
expect(warnings.join('\n')).toContain('icacls /reset exited with status 1');
});

it('warns on Windows when /reset succeeds but /grant:r fails with an error', () => {
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(credentialsPath, 'data');
const warnings: string[] = [];
const spawn = vi
.fn()
.mockReturnValueOnce({ status: 0, signal: null, output: [], pid: 123 })
.mockReturnValueOnce({
error: new Error('permission denied'),
status: null,
signal: null,
output: [],
pid: 123,
}) as never;

ensureRestrictiveMode(credentialsPath, {
platform: 'win32',
spawnSync: spawn,
warn: line => warnings.push(line),
});

expect(spawn).toHaveBeenCalledTimes(2);
expect(warnings.join('\n')).toContain(
'icacls failed while tightening credentials file permissions',
);
});

it('warns on Windows when /reset succeeds but /grant:r exits non-zero', () => {
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(credentialsPath, 'data');
const warnings: string[] = [];
const spawn = vi
.fn()
.mockReturnValueOnce({ status: 0, signal: null, output: [], pid: 123 })
.mockReturnValueOnce({ status: 5, 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');
expect(spawn).toHaveBeenCalledTimes(2);
expect(warnings.join('\n')).toContain('icacls exited with status 5');
});

// POSIX-only premise: Windows has no 0644/0600 distinction to downgrade.
Expand Down
49 changes: 33 additions & 16 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ interface CredentialsLock {

interface RestrictiveModeOptions {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
spawnSync?: (
command: string,
args: readonly string[],
Expand Down Expand Up @@ -217,36 +216,54 @@ export function ensureRestrictiveMode(path: string, options: RestrictiveModeOpti
}

/**
* Restrict a Windows credentials file to the current user using icacls.
* Restrict a Windows credentials file to the file owner using icacls.
* The command is invoked with an args array so credential paths are never shell-interpreted.
*
* Uses /reset to clear any broken inheritance state, followed by
* /inheritance:r /grant:r *S-1-3-4:F which decouples the file from parent ACLs
* and grants Full Control to the OWNER RIGHTS SID (S-1-3-4). This avoids
* dependency on resolving %USERNAME% (which fails on Microsoft Account or
* domain-joined machines).
*/
function ensureWindowsRestrictiveAcl(path: string, options: RestrictiveModeOptions): void {
const username = (options.env ?? process.env).USERNAME?.trim();
if (!username) {
const run = options.spawnSync ?? spawnSync;
const icaclsOptions = {
shell: false as const,
stdio: 'ignore' as const,
windowsHide: true as const,
};

const resetResult = run('icacls', [path, '/reset'], icaclsOptions);
if (resetResult.error) {
warnWindowsAcl(
'could not determine the Windows username; credentials file permissions were not tightened',
`icacls failed while resetting credentials file permissions: ${resetResult.error.message}`,
options,
);
return;
}
if (resetResult.status !== 0) {
warnWindowsAcl(
`icacls /reset exited with status ${resetResult.status ?? 'unknown'}; credentials file permissions may be too broad`,
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) {
const grantResult = run(
'icacls',
[path, '/inheritance:r', '/grant:r', '*S-1-3-4:F'],
icaclsOptions,
);
if (grantResult.error) {
warnWindowsAcl(
`icacls failed while tightening credentials file permissions: ${result.error.message}`,
`icacls failed while tightening credentials file permissions: ${grantResult.error.message}`,
options,
);
return;
}
if (result.status !== 0) {
if (grantResult.status !== 0) {
warnWindowsAcl(
`icacls exited with status ${result.status ?? 'unknown'}; credentials file permissions may be too broad`,
`icacls exited with status ${grantResult.status ?? 'unknown'}; credentials file permissions may be too broad`,
options,
);
}
Expand Down
Loading