-
Notifications
You must be signed in to change notification settings - Fork 863
fix(service): write service definitions owner-only, they can carry a proxy credential #2126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # 140 — final audit of the merged stack, and what it caught | ||
|
|
||
| Run after #2116/#2117/#2118/#2121 landed on `dev`. Verdict: **fail**, and the | ||
| reason was not one of the four fixes. | ||
|
|
||
| ## What the audit confirmed | ||
|
|
||
| - The proxy-env leak is genuinely dead. The auditor re-ran the five affected | ||
| suites from a clean `git archive origin/dev` — 236 pass / 0 fail — **and then | ||
| re-ran them without `--isolate`**, the exact single-process condition that | ||
| produced the original 73 failures. Still green. That second run is the one | ||
| that matters; the first only proves isolation hides it. | ||
| - #2121's gate reason cannot fire on the turn-drain path. | ||
| - Escaping holds across all three builders under newline, quote and `%` | ||
| injection. | ||
| - Full suite on `dev` head `fbc6f26a2`: **13,501 pass / 0 fail**, run on | ||
| `ssh lidge`. | ||
|
|
||
| ## What it caught — P1, and it is real | ||
|
|
||
| #2107 baked the proxy environment into the installed service definition. A proxy | ||
| URL routinely carries `user:password`, so that change quietly made those files | ||
| credential-bearing. They were still written with a bare `writeFileSync`. | ||
|
|
||
| Measured, not assumed: umask 022, `writeFileSync` with no mode → **0644**. | ||
|
|
||
| The precedent was already in the same file and was not followed — the service | ||
| API token (`service.ts:387`) and the install state (`:190`) both write | ||
| `{ mode: 0o600 }` plus a `chmodSync`. The repo also has an explicit convention | ||
| against leaking this exact value: `collectProxyEnv` reports proxy presence as a | ||
| boolean so the URL never escapes, pinned by a `doctor` test asserting the | ||
| serialized rows never contain `"secret"`. | ||
|
|
||
| So the change wrote a credential to a world-readable file in a codebase that | ||
| already treats 0600 as the standard for precisely this data. | ||
|
|
||
| **The uncomfortable part is procedural.** #2116's own body disclosed the risk | ||
| and offered to gate on redaction. That question was never adjudicated — the PR | ||
| merged at `REVIEW_REQUIRED` with only bot comments. `AGENTS.md` requires | ||
| explicit security review for credential handling. Disclosing a risk in a PR body | ||
| is not the same as discharging it, and self-merging past your own open question | ||
| is how a known risk becomes a shipped one. | ||
|
|
||
| ### The fix | ||
|
|
||
| One `writeServiceDefinitionFile()` for the plist, the unit, and the Windows | ||
| scheduler assets: `{ mode: 0o600 }` plus `chmodSync`, plus the Windows ACL. | ||
|
|
||
| The explicit `chmodSync` is not belt-and-braces. `mode` applies only at | ||
| creation, so an install over a definition an earlier version left at 0644 would | ||
| keep the loose mode — and that is the realistic upgrade path, not a hypothetical. | ||
|
|
||
| Red-driven: with the mode argument removed, the three new assertions report | ||
| `644` against an expected `600`. | ||
|
|
||
| ## P2 — the untestable builder was left untested | ||
|
|
||
| `buildWindowsServiceScript` was the only one of the three builders with no proxy | ||
| assertion, and the reason is instructive: the only way to reach it was to assign | ||
| `process.env`, which is the exact pattern whose leak this stack had just removed. | ||
| The refactor fixed the leak where a test existed and left the untestable builder | ||
| untested. | ||
|
|
||
| It now takes the resolved entries like the other two, with a regression covering | ||
| the canonical-name rule. | ||
|
|
||
| ## P2 — the new fix reintroduced the same structural class | ||
|
|
||
| `reportedFenceReasons` in #2121 is process-lifetime module state — structurally | ||
| the same hazard as the proxy leak, one abstraction away. Whichever file | ||
| constructs the error first consumes the one-shot warn, so a later file asserting | ||
| on it would see nothing and **pass vacuously**. | ||
|
|
||
| Current suites pass in both file orders, so this was latent rather than live. The | ||
| reset is now documented as an order-sensitive contract and its caller resets on | ||
| both sides. | ||
|
|
||
| ## P3 — pin-to-line comments were already wrong at merge | ||
|
|
||
| `auth-context.ts:326` (actual: 357/363/370), `lifecycle.ts:180`, | ||
| `native-profile-startup.ts:138-139` (actual: 142-143) and `:311` (actual: 315). | ||
| Replaced with symbol names, which do not drift when a file moves. | ||
|
|
||
| ## The honest gap that remains | ||
|
|
||
| No commit in this stack has a green cross-platform CI run of its own — the runs | ||
| were cancelled by successive force-pushes, and `dev`'s own run was still in | ||
| flight. Both Windows-specific behaviors this stack shipped are unverified on | ||
| Windows: the Windows proxy path had no test until now, and `owner-unavailable` — | ||
| the branch #2108 most needs named — is a Windows icacls path asserted nowhere in | ||
| the suite. | ||
|
|
||
| Stating it rather than filing it as done. | ||
|
|
||
| ## The lesson worth keeping | ||
|
|
||
| An audit that only re-runs what the author ran finds nothing. This one found the | ||
| P1 by asking a question the author never asked — *what mode is that file?* — and | ||
| then measuring it instead of reasoning about it. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1541,7 +1541,11 @@ function taskXmlRunLevelAcceptable(principal: string): boolean { | |
| return value === "leastprivilege" || value === "highestavailable"; | ||
| } | ||
|
|
||
| export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { | ||
| export function buildWindowsServiceScript( | ||
| entry = cliEntry(), | ||
| port = resolveServiceListenPort(), | ||
| proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), | ||
| ): string { | ||
| // Provenance rides along with the entry: a second durableBunRuntime() call here could | ||
| // resolve differently from the binary the caller actually baked. | ||
| const { bun, bunRuntimeSource, cli } = entry; | ||
|
|
@@ -1559,7 +1563,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ | |
| windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), | ||
| windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), | ||
| windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), | ||
| ...resolvedProxyEnv().map(({ name, value }) => windowsBatchSet(name, value)), | ||
| ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)), | ||
| windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), | ||
| windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), | ||
| windowsBatchSet("OCX_BUN", bun, "path"), | ||
|
|
@@ -1881,7 +1885,7 @@ function installLaunchd(): void { | |
| // Capture this BEFORE writing: the write below makes the plist exist unconditionally, | ||
| // so a post-write existsSync would call every fresh install an "installed" service. | ||
| const wasInstalled = existsSync(p); | ||
| writeFileSync(p, buildPlist(), "utf8"); | ||
| writeServiceDefinitionFile(p, buildPlist(), "utf8"); | ||
| // Best-effort: an absent job is fine here, and a failed unload is caught by the | ||
| // load verification below with a better message than a raw unload error. | ||
| runLaunchctl(["unload", p]); | ||
|
|
@@ -1944,6 +1948,27 @@ function uninstallLaunchd(): void { | |
| if (existsSync(p)) unlinkSync(p); | ||
| } | ||
|
|
||
| /** | ||
| * Write a service definition with owner-only permissions. | ||
| * | ||
| * These files carry the outbound proxy environment (#2107), and a proxy URL routinely | ||
| * carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default | ||
| * umask, so the credential would be world-readable on a shared host. Every other | ||
| * secret-bearing write in this file already uses 0600 — the service API token and the | ||
| * install state — and a service definition holding a proxy credential belongs in the same | ||
| * class. | ||
| * | ||
| * The explicit `chmodSync` is not redundant: `mode` only applies when the file is | ||
| * created, so an install over a definition left at 0644 by an earlier version would keep | ||
| * the loose mode. On Windows the POSIX bits are advisory, so the real ACL is applied | ||
| * there the same way the token file does it. | ||
| */ | ||
| export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void { | ||
| writeFileSync(path, content, { encoding, mode: 0o600 }); | ||
| try { chmodSync(path, 0o600); } catch { /* best-effort; the Windows ACL below is authoritative */ } | ||
| if (process.platform === "win32") hardenSecretPath(path, { required: false }); | ||
| } | ||
|
Comment on lines
+1966
to
+1970
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -A35 -B10 \
'function hardenEntry|function hardenSecretPath|hardenSecretPath\(' \
src/lib/windows-secret-acl.ts src/service.tsRepository: lidge-jun/opencodex Length of output: 25904 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- service imports and nearby helpers ---'
sed -n '1,80p;1925,2015p' src/service.ts
printf '%s\n' '--- all writeServiceDefinitionFile call sites and tests ---'
rg -n -A12 -B12 \
'writeServiceDefinitionFile|writeServiceAssetWithRetry|windowsServiceScriptPath|windowsLauncherVbsPath|windowsTaskXmlPath' \
src tests
printf '%s\n' '--- ACL option contract and failure behavior ---'
rg -n -A25 -B15 \
'interface HardenOptions|type HardenOptions|required: false|required: true|sanitizedAclError|runIcacls' \
src/lib/windows-secret-acl.tsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- direct tests for writeServiceDefinitionFile ---'
rg -n -A35 -B15 \
'writeServiceDefinitionFile\(|owner-only permissions|0644|hardenSecretPath' \
tests/service.test.ts tests/cli-ready.test.ts
printf '%s\n' '--- exact hardenSecretPath contract ---'
sed -n '680,770p;819,838p' src/lib/windows-secret-acl.ts
printf '%s\n' '--- read-only source verifier: publication order and failure policy ---'
python3 - <<'PY'
from pathlib import Path
import re
source = Path("src/service.ts").read_text()
match = re.search(
r'export function writeServiceDefinitionFile\(.*?\n\}',
source,
re.S,
)
if not match:
raise SystemExit("writeServiceDefinitionFile not found")
body = match.group(0)
operations = [
"writeFileSync(",
"chmodSync(",
'hardenSecretPath(path, { required: false })',
]
positions = {op: body.index(op) for op in operations}
print("operation order:", " < ".join(sorted(positions, key=positions.get)))
print("uses required:false:", "required: false" in body)
print("chmod errors are caught:", bool(re.search(r"try\\s*\\{\\s*chmodSync\\(.*?\\);\\s*\\}\\s*catch", body, re.S)))
PYRepository: lidge-jun/opencodex Length of output: 12650 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- corrected read-only source verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
source = Path("src/service.ts").read_text()
match = re.search(r"export function writeServiceDefinitionFile\(.*?\n\}", source, re.S)
if not match:
raise SystemExit("writeServiceDefinitionFile not found")
body = match.group(0)
for label, pattern in {
"write-before-permissions": r"writeFileSync\(.*?chmodSync\(",
"chmod-errors-caught": r"try\s*\{\s*chmodSync\(.*?\}\s*catch",
"optional-windows-hardening": r"hardenSecretPath\(path,\s*\{\s*required:\s*false\s*\}\)",
}.items():
print(f"{label}: {bool(re.search(pattern, body, re.S))}")
PY
printf '%s\n' '--- Windows ACL tests for required versus optional failure ---'
rg -n -A20 -B15 \
'required: false|required: true|continuing without NTFS ACL harden|hardenSecretPath' \
tests/windows-secret-acl.test.ts tests tests/service.test.tsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- atomic write helper and call sites ---'
rg -n -A45 -B20 \
'function atomicWriteFile|export .*atomicWriteFile|atomicWriteFile\(' \
src tests
printf '%s\n' '--- service and ACL imports related to atomic publication ---'
sed -n '1,90p' src/lib/windows-secret-acl.ts
rg -n 'atomic|renameSync|truncateSync|unlinkSync|mkdtemp|tmp' src/service.ts src/lib/windows-secret-acl.ts
printf '%s\n' '--- focused ACL atomic-write tests ---'
sed -n '495,530p' tests/windows-secret-acl.test.tsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- atomic-write file locations ---'
git ls-files | rg '(^|/)(atomic|file).*write|atomic-write|fs-utils|filesystem'
printf '%s\n' '--- definition-only search ---'
rg -n --glob '*.ts' \
'export function atomicWriteFile|function atomicWriteFile|const atomicWriteFile|atomicWriteFile\s*=' \
src tests
printf '%s\n' '--- focused imports and ACL atomic tests ---'
rg -n --glob '*.ts' \
'^import .*atomicWriteFile|atomicWriteFile' \
src/lib/windows-secret-acl.ts tests/windows-secret-acl.test.ts
sed -n '1,45p;505,526p' tests/windows-secret-acl.test.tsRepository: lidge-jun/opencodex Length of output: 3707 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '150,315p' src/config.tsRepository: lidge-jun/opencodex Length of output: 6454 Publish hardened service definitions atomically. At 🤖 Prompt for AI Agents |
||
|
|
||
| // ── Windows (Task Scheduler) ── | ||
| /** | ||
| * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows | ||
|
|
@@ -1952,7 +1977,7 @@ function uninstallLaunchd(): void { | |
| function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void { | ||
| for (let attempt = 0; ; attempt++) { | ||
| try { | ||
| writeFileSync(path, content, encoding); | ||
| writeServiceDefinitionFile(path, content, encoding); | ||
| return; | ||
| } catch (err) { | ||
| const code = (err as NodeJS.ErrnoException).code; | ||
|
|
@@ -2520,7 +2545,7 @@ function installSystemd(): void { | |
| recordOwnedConfigPath(getConfigDir(), serviceStatePath()); | ||
| if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); | ||
| writeServiceApiTokenFile(); | ||
| writeFileSync(unitPath(), buildUnit(), "utf8"); | ||
| writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8"); | ||
| sh("systemctl --user daemon-reload"); | ||
| sh(`systemctl --user enable ${TASK}`); | ||
| sh(`systemctl --user restart ${TASK}`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix the issue-number paragraph Markdown.
Line 21 starts with
#2107and triggers Markdownlint MD018. Prefix the text withIssueor wrap the issue number in backticks so the paragraph renders correctly.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 21-21: No space after hash on atx style heading
(MD018, no-missing-space-atx)
🤖 Prompt for AI Agents
Source: Linters/SAST tools