mirror of https://github.com/garrytan/gstack.git
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
/**
|
|
* Cross-platform file permission restriction for sensitive gstack state.
|
|
*
|
|
* Why this exists
|
|
* ----------------
|
|
* POSIX mode bits (`0o600` for files, `0o700` for dirs) are how gstack marks
|
|
* sensitive state files — auth tokens, canary tokens, chat history, agent
|
|
* queue, device salt, per-tab security decisions. On Linux and macOS,
|
|
* `fs.chmodSync(path, 0o600)` and `fs.writeFileSync(path, data, { mode: 0o600 })`
|
|
* do exactly what you'd hope: the file ends up readable and writable only
|
|
* by the owning user, no access for group / other.
|
|
*
|
|
* On Windows, both calls are effectively no-ops. NTFS uses ACLs, not POSIX
|
|
* mode bits, and Node's fs module doesn't translate. So on every Windows
|
|
* install, sensitive gstack state files inherit whatever ACL the parent
|
|
* directory grants — typically user-full + inherited admin-full. That's
|
|
* fine on a single-user laptop but leaks on:
|
|
*
|
|
* - Self-hosted CI runners (GitHub Actions / GitLab / Jenkins agents
|
|
* running as a different service account on the same box — they can
|
|
* read developer state)
|
|
* - Shared development machines (agencies, studios, lab machines)
|
|
* - Multi-tenant servers with shared home directories
|
|
* - Malware running as the same user (no in-user-account isolation)
|
|
*
|
|
* This module wraps the platform-correct call. POSIX: chmod. Windows:
|
|
* icacls with inheritance break + explicit user grant. Failures on either
|
|
* platform are best-effort — the filesystem is still functional if ACL
|
|
* restriction fails; we just don't hit the intended hardening target.
|
|
*
|
|
* Warning behavior: to avoid spamming the console on a machine where
|
|
* icacls is unavailable (rare — it ships in System32 on every Windows
|
|
* version since 7), we log the first failure per process and stay silent
|
|
* afterward. The warning includes the advice "sensitive files may be
|
|
* readable by other accounts on this machine" so operators know to audit
|
|
* their runner / share setup.
|
|
*/
|
|
|
|
import { execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
|
|
let warnedOnce = false;
|
|
|
|
function warnIcaclsFailure(fsPath: string, err: unknown): void {
|
|
if (warnedOnce) return;
|
|
warnedOnce = true;
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
|
|
console.warn(
|
|
`[gstack] Failed to restrict Windows ACL on ${fsPath}: ${msg}\n` +
|
|
` Sensitive files may be readable by other accounts on this machine.\n` +
|
|
` This warning appears once per process; subsequent failures are silent.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Restrict a file to owner-only access (POSIX 0o600 equivalent).
|
|
*
|
|
* POSIX: `fs.chmodSync(path, 0o600)`. Idempotent if the file was already
|
|
* written with `{ mode: 0o600 }`, so safe to call regardless.
|
|
*
|
|
* Windows: invokes `icacls /inheritance:r /grant:r <user>:(F)` to remove
|
|
* any inherited ACLs and replace the ACL with a single entry granting the
|
|
* current user full control.
|
|
*/
|
|
export function restrictFilePermissions(filePath: string): void {
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
const user = os.userInfo().username;
|
|
execFileSync(
|
|
'icacls',
|
|
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
} catch (err) {
|
|
warnIcaclsFailure(filePath, err);
|
|
}
|
|
return;
|
|
}
|
|
try { fs.chmodSync(filePath, 0o600); } catch { /* best-effort */ }
|
|
}
|
|
|
|
/**
|
|
* Restrict a directory to owner-only access (POSIX 0o700 equivalent),
|
|
* with new children inheriting the restricted ACL.
|
|
*
|
|
* POSIX: `fs.chmodSync(path, 0o700)`. Idempotent if the dir was already
|
|
* created with `{ mode: 0o700 }`.
|
|
*
|
|
* Windows: `icacls /inheritance:r /grant:r <user>:(OI)(CI)(F)`. The
|
|
* `(OI)(CI)` flags make new files (OI = object inherit) and subdirs
|
|
* (CI = container inherit) inherit the single-user-full ACL — important
|
|
* because child creations in `fs.writeFileSync(...)` without explicit
|
|
* `restrictFilePermissions` still end up owner-only.
|
|
*/
|
|
export function restrictDirectoryPermissions(dirPath: string): void {
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
const user = os.userInfo().username;
|
|
execFileSync(
|
|
'icacls',
|
|
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
} catch (err) {
|
|
warnIcaclsFailure(dirPath, err);
|
|
}
|
|
return;
|
|
}
|
|
try { fs.chmodSync(dirPath, 0o700); } catch { /* best-effort */ }
|
|
}
|
|
|
|
/**
|
|
* Write a file and restrict it to owner-only access, cross-platform.
|
|
* Replaces `fs.writeFileSync(path, data, { mode: 0o600 })` + Windows ACL.
|
|
*/
|
|
export function writeSecureFile(
|
|
filePath: string,
|
|
data: string | NodeJS.ArrayBufferView,
|
|
): void {
|
|
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
|
restrictFilePermissions(filePath);
|
|
}
|
|
|
|
/**
|
|
* Append to a file with owner-only permissions, cross-platform.
|
|
* Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.
|
|
*
|
|
* ACL is applied only on first write — subsequent appends are fire-and-forget
|
|
* (no need to re-run icacls on every log line).
|
|
*/
|
|
export function appendSecureFile(
|
|
filePath: string,
|
|
data: string | NodeJS.ArrayBufferView,
|
|
): void {
|
|
const existed = fs.existsSync(filePath);
|
|
fs.appendFileSync(filePath, data, { mode: 0o600 });
|
|
if (!existed) restrictFilePermissions(filePath);
|
|
}
|
|
|
|
/**
|
|
* `mkdir -p` with owner-only directory permissions, cross-platform.
|
|
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
|
* Safe to call on an existing directory — re-applies the ACL idempotently.
|
|
*/
|
|
export function mkdirSecure(dirPath: string): void {
|
|
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
restrictDirectoryPermissions(dirPath);
|
|
}
|
|
|
|
/**
|
|
* Reset the once-per-process warning gate. Test-only.
|
|
*/
|
|
export function __resetWarnedForTests(): void {
|
|
warnedOnce = false;
|
|
}
|