fix(security): restrict settings.json permissions and stop caching the raw apiKey - #359
raymondginger2018-sudo wants to merge 3 commits into
Conversation
`writeSettingsFile` created the settings directory with
`fs.mkdirSync(..., { recursive: true })` and wrote `settings.json` with
`fs.writeFileSync` defaults. The file therefore ended up world-readable under a
permissive umask, and `Authenticated Users` could read it under the default
Windows profile ACL. `settings.json` stores the API key.
Add `common/private-storage.ts` with two helpers:
- `writePrivateFile` — 0600 on POSIX; on Windows, mode bits are ignored by the
OS, so it also drops inherited ACEs (`icacls /inheritance:r`) and grants the
current user exclusive full control (`icacls /grant:r <user>:F`).
- `ensurePrivateDirectory` — 0700, or a current-user-only ACL on Windows, for
`~/.deepcode`.
Both are best-effort: when `whoami`/`icacls` is unavailable the file is still
written, matching the POSIX path where mode bits are subject to umask.
Tests (`tests/private-storage.test.ts`): POSIX mode bits 0600/0700, Windows ACL
restriction, idempotency, and that the current user keeps full control. The
Windows assertion accepts both the `(F)` and `(I)(F)` forms, because `icacls`
prints inherited ACEs differently depending on the Windows build.
The module-level client cache stored `cachedOpenAIKey = `${apiKey}::${baseURL}``,
so the raw API key stayed resident in module state for the lifetime of the
process: a heap dump, crash report, or memory leak would contain it verbatim.
Cache on `sha256(apiKey).slice(0, 16)::baseURL` instead. Behaviour is unchanged —
one cached client per key and baseURL pair — but no plaintext secret is retained.
`restrictWindowsAcl` passed the program name as the first argument, so icacls
received two operands ("icacls" and the target) and exited with
ERROR_INVALID_PARAMETER (87). The failure was swallowed as best-effort, so the
ACL was never touched while the caller kept going: on Windows the settings file
(which stores the API key) stayed readable by whatever the parent directory
grants, and the 0600 intent was silently unmet.
- drop the duplicated "icacls" operand
- resolve the principal from USERDOMAIN/USERNAME before falling back to whoami,
which under MSYS/Git-Bash answers with a bare, ambiguous name
- return whether the ACL is now user-only instead of failing invisibly
- tests: assert that no inherited ACE survives (this is the assertion that
catches the no-op), read the exact principal back instead of matching a loose
pattern, and skip loudly when icacls cannot run
Verified on Windows: the file and its directory now carry a single explicit
ACE for the current user, with no Authenticated Users/Everyone entry.
|
Correction — the first push of this branch was red on Windows, and it exposed a real bug The initial 2-commit push failed the Root cause (not a flaky test):
The same defect proved the original test was vacuous: locally the ACL still contained Fixed in
CI at The description above has been updated accordingly; the earlier claim that the Windows |
Summary
Two secret-hygiene fixes, both about the API key being reachable outside the app:
settings.jsonis now written with user-only permissions.Problem
writeSettingsFileinpackages/core/src/settings.tsused the plainfsdefaults:~/.deepcode/settings.jsonstores the API key, so it was created world-readable under apermissive umask and inherited the profile ACL on Windows (where
Authenticated Userscan read it by default).
packages/core/src/common/openai-client.tscached its client ascachedOpenAIKey = \${connection.apiKey}::${connection.baseURL}``, leaving the plaintext keyresident in module state — visible in a heap dump or crash report.
Changes
packages/core/src/common/private-storage.ts(+122)writePrivateFile—0600on POSIX. Windows ignores mode bits, so it additionallydrops inherited ACEs (
icacls <path> /inheritance:r) and grants the current userexclusive full control (
icacls <path> /grant:r <user>:F).ensurePrivateDirectory—0700, or a current-user-only ACL on Windows.invisibly; they report, they do not throw, so a caller can still write the file when a
tool is missing (POSIX mode bits are likewise subject to
umask). The Windows principalis resolved from
USERDOMAIN/USERNAMEfirst — under MSYS/Git-Bash a POSIXwhoamisits earlier in
PATHand answers with a bare, ambiguous name.packages/core/src/settings.ts(+3/-2) —writeSettingsFileroutes through those helpers.packages/core/src/common/openai-client.ts(+4/-1) — cache onsha256(apiKey).slice(0, 16)::baseURL: still one client per key + baseURL pair, noplaintext secret retained.
packages/core/src/tests/private-storage.test.ts(+100) — POSIX0600/0700modebits, Windows ACL restriction, and idempotency. The Windows test asserts that no inherited
ACE survives (
icaclsprints:(I)for inherited entries), reads the exact principal back(
DOMAIN\user:(F)), and rejectsAuthenticated Users/Everyone. Whenicaclscannot run itreports a skip, never a vacuous pass — a file that could not be restricted must not look
like a green ACL test.
Correction: the first push was red, and it was a real bug
The first push of this branch (2 commits) failed CI on both Windows legs:
Investigating that red found a genuine defect in the implementation, not just a brittle
assertion:
restrictWindowsAclpassed the program name as the first argument, soicaclsreceived two operands and exited with
ERROR_INVALID_PARAMETER(87), while thecatchturned that hard failure into a silent no-op — the ACL was never touched and the file stayed
as readable as its parent directory allowed. Proven by A/B on Windows:
icacls <path> /inheritance:r(intended)user:(F)icacls icacls <path> /inheritance:r(what the code did)The same defect also exposed a vacuous test: locally the ACL did still contain inherited
ACEs, yet the old assertions passed — they matched only English principal names absent from
this machine, and the
(F)/(I)(F)pattern was inverted. Fixed in the third commit(
fix(security): apply the settings ACL for real and assert it), which removes the duplicatedoperand, resolves the principal from the environment first, and makes both helpers report
success so the failure can no longer be swallowed. The rewritten test fails against the old
implementation and passes against the new one.
Verification
npm run check(typecheck + eslint + prettier)npm test(core workspace)npm run build --workspace=@vegamo/deepcode-corenpm run bundlepackages/cli/dist/cli.js:writePrivateFile×3,private+inheritance:r×1,USERDOMAIN×3,sha256present; the broken["icacls", path]argv form is absentwriteSettingsin an isolatedUSERPROFILE(I), noAuthenticated Users, noEveryoneOn Windows the file additionally keeps explicit
SYSTEMandBUILTIN\AdministratorsACEs;both can take ownership regardless of the DACL, so removing them would be cosmetic. What the
change removes is the inherited group access (
Authenticated Usersand friends) that madethe key readable to other interactive users.
The Windows ACL case was exercised locally on Windows (
icaclspath, executed — not skipped).Local toolchain was Node v25.2.1 / npm v12.0.2 while CI uses Node 22/24, so local results are
supporting evidence only; the upstream CI result is what counts.
Overlap
Checked every open PR by file list (50 PRs, excluding this one): 23 files match the paths this
PR touches, but a hunk-level scan for implementation markers (
icacls,inheritance:r,chmod,0600,sha256,createHash,writePrivateFile) finds no competing fix, and noopen PR touches
packages/core/src/common/private-storage.ts. Base is up to date withmain(3 ahead / 0 behind) and a test merge is clean.
Notes
umaskcan only clear bits, so the explicit0600/0700can never end up more permissivethan intended; the Windows ACL path is independent of
umask.hardening commits from that branch are not part of this diff.