|
7 | 7 | statSync, |
8 | 8 | writeFileSync, |
9 | 9 | } from 'node:fs'; |
| 10 | +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; |
10 | 11 | import { homedir } from 'node:os'; |
11 | 12 | import { dirname, join } from 'node:path'; |
12 | 13 | import { localValidationError } from './errors.js'; |
@@ -61,6 +62,17 @@ export interface CredentialsOptions { |
61 | 62 | path?: string; |
62 | 63 | } |
63 | 64 |
|
| 65 | +interface RestrictiveModeOptions { |
| 66 | + platform?: NodeJS.Platform; |
| 67 | + env?: NodeJS.ProcessEnv; |
| 68 | + spawnSync?: ( |
| 69 | + command: string, |
| 70 | + args: readonly string[], |
| 71 | + options: { shell: false; stdio: 'ignore'; windowsHide: true }, |
| 72 | + ) => SpawnSyncReturns<Buffer>; |
| 73 | + warn?: (line: string) => void; |
| 74 | +} |
| 75 | + |
64 | 76 | const FILE_KEY_TO_FIELD: Record<string, keyof ProfileEntry> = { |
65 | 77 | api_key: 'apiKey', |
66 | 78 | api_url: 'apiUrl', |
@@ -172,12 +184,62 @@ export function deleteProfile(profile: string, options: CredentialsOptions = {}) |
172 | 184 | return true; |
173 | 185 | } |
174 | 186 |
|
175 | | -export function ensureRestrictiveMode(path: string): void { |
| 187 | +/** |
| 188 | + * Enforce restrictive access on the credentials file after atomic writes. |
| 189 | + * POSIX hosts use chmod(0600); Windows hosts use ACL tightening via icacls. |
| 190 | + */ |
| 191 | +export function ensureRestrictiveMode(path: string, options: RestrictiveModeOptions = {}): void { |
176 | 192 | if (!existsSync(path)) return; |
| 193 | + if ((options.platform ?? process.platform) === 'win32') { |
| 194 | + ensureWindowsRestrictiveAcl(path, options); |
| 195 | + return; |
| 196 | + } |
177 | 197 | const overpermissive = (statSync(path).mode & 0o077) !== 0; |
178 | 198 | if (overpermissive) chmodSync(path, 0o600); |
179 | 199 | } |
180 | 200 |
|
| 201 | +/** |
| 202 | + * Restrict a Windows credentials file to the current user using icacls. |
| 203 | + * The command is invoked with an args array so credential paths are never shell-interpreted. |
| 204 | + */ |
| 205 | +function ensureWindowsRestrictiveAcl(path: string, options: RestrictiveModeOptions): void { |
| 206 | + const username = (options.env ?? process.env).USERNAME?.trim(); |
| 207 | + if (!username) { |
| 208 | + warnWindowsAcl( |
| 209 | + 'could not determine the Windows username; credentials file permissions were not tightened', |
| 210 | + options, |
| 211 | + ); |
| 212 | + return; |
| 213 | + } |
| 214 | + |
| 215 | + const run = options.spawnSync ?? spawnSync; |
| 216 | + const result = run('icacls', [path, '/inheritance:r', '/grant:r', `${username}:F`], { |
| 217 | + shell: false, |
| 218 | + stdio: 'ignore', |
| 219 | + windowsHide: true, |
| 220 | + }); |
| 221 | + |
| 222 | + if (result.error) { |
| 223 | + warnWindowsAcl( |
| 224 | + `icacls failed while tightening credentials file permissions: ${result.error.message}`, |
| 225 | + options, |
| 226 | + ); |
| 227 | + return; |
| 228 | + } |
| 229 | + if (result.status !== 0) { |
| 230 | + warnWindowsAcl( |
| 231 | + `icacls exited with status ${result.status ?? 'unknown'}; credentials file permissions may be too broad`, |
| 232 | + options, |
| 233 | + ); |
| 234 | + } |
| 235 | +} |
| 236 | + |
| 237 | +/** Emit an explicit warning when Windows ACL tightening cannot be completed. */ |
| 238 | +function warnWindowsAcl(message: string, options: RestrictiveModeOptions): void { |
| 239 | + const warn = options.warn ?? ((line: string) => process.stderr.write(`${line}\n`)); |
| 240 | + warn(`[warning] ${message}`); |
| 241 | +} |
| 242 | + |
181 | 243 | function resolvePath(options: CredentialsOptions): string { |
182 | 244 | return options.path ?? defaultCredentialsPath(); |
183 | 245 | } |
|
0 commit comments