Skip to content

[APPS-2792] Fix: harden env-guard against bypass and reliability gaps - #510

Open
tyffical wants to merge 1 commit into
tiffany.trinh/apps-2792-secret-store-parityfrom
tiffany.trinh/apps-2792-env-guard-hardening
Open

[APPS-2792] Fix: harden env-guard against bypass and reliability gaps#510
tyffical wants to merge 1 commit into
tiffany.trinh/apps-2792-secret-store-parityfrom
tiffany.trinh/apps-2792-env-guard-hardening

Conversation

@tyffical

@tyffical tyffical commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Motivation

Architecture

All fixes land inside env-guard.ts's existing scoping mechanism (introduced in #504) — this PR closes bypass and reliability gaps in that mechanism rather than changing its shape:

runWithScopedEnv(scopedEnv, fn)
  │
  ├─ fs wrappers — coverage extended to fs.read/readv(Sync)/openAsBlob/
  │    FileReadStream and every FileHandle.prototype read method, with a
  │    real fd resolved via a captured native getter so an own-property
  │    shadow on the handle can't fool the guard; cp/cpSync/promises.cp
  │    reject recursive+dereference outright and read their options once
  │
  ├─ process.env — reassigning to null/undefined/a primitive is rejected;
  │    Object.defineProperty(..., { configurable: false }) throws a clear
  │    guard-specific error instead of a native Proxy invariant violation
  │
  ├─ process.report — writeReport() refuses a non-regular destination,
  │    re-checked immediately before the real write to narrow a symlink-
  │    swap TOCTOU window; a deferred excludeEnv write is now validated
  │    via Node's native setter
  │
  └─ util.inspect(process.env) — Proxy target repointed to a permanently
       empty dummy object, closing the default-mode + showProxy:true leak
16 changes across env-guard.ts, env-guard.test.ts, guarded-wrapper.ts
What changed File
fs.read/readSync/readv/readvSync and every FileHandle.prototype read method (read/readFile/readv/createReadStream/readableWebStream/readLines) are now guarded against the /proc/.../environ bypass packages/plugins/apps/src/vite/env-guard.ts
The FileHandle guard resolves the real fd via a getter captured once from the prototype at patch time, so a customer-controlled own property shadowing .fd with a harmless value can't fool the check packages/plugins/apps/src/vite/env-guard.ts
That fd resolution — and the guard check depending on it — now only runs inside an active scope; the native .fd getter (and the guard's own logic) was previously invoked unconditionally on every call packages/plugins/apps/src/vite/env-guard.ts
fs.cp/cpSync/promises.cp now reject a recursive: true, dereference: true copy outright, since Node's own internal traversal for that case bypasses the top-level source-path check packages/plugins/apps/src/vite/env-guard.ts
guardCpOptions/guardEnvironPathOrFdOption read a getter-backed option value exactly once via destructuring, instead of risking a second, differently-answered read during a later spread packages/plugins/apps/src/vite/env-guard.ts
fs.openAsBlob and fs.FileReadStream are now guarded, each gated on the real function actually existing on the running Node version packages/plugins/apps/src/vite/env-guard.ts
Reassigning process.env to null, undefined, or a primitive (not an object) is now rejected instead of silently corrupting the real environment fallback packages/plugins/apps/src/vite/env-guard.ts
Object.defineProperty(process.env, key, { configurable: false }) now throws a clear, guard-specific error instead of a native Proxy invariant TypeError — a non-configurable definition can never be satisfied against the Proxy's permanently-empty target packages/plugins/apps/src/vite/env-guard.ts
process.env's Proxy now targets a permanently-empty dummy object, closing util.inspect(process.env) reading the real environment straight off the Proxy's internal target — both default mode and showProxy: true bypass every trap by design, per Node's own docs packages/plugins/apps/src/vite/env-guard.ts
process.report.writeReport() refuses a verified non-regular destination (FIFO/socket/character device), re-checked immediately before the real write (not just once, earlier in the function) to narrow the window for an external symlink swap packages/plugins/apps/src/vite/env-guard.ts
A deferred excludeEnv write (made from outside an active scope while a different scope is active) is now validated immediately via Node's native setter instead of skipping that validation packages/plugins/apps/src/vite/env-guard.ts
wrapGuardedAsyncFsFn's onResolved hook (used by fs.promises.open) now forwards the caller's this through to the wrapped function, instead of losing it on a bare call packages/plugins/apps/src/vite/env-guard.ts
Two inlined function-call arguments (a repo convention violation) replaced with named locals packages/plugins/apps/src/vite/env-guard.ts
New tests for every fix above: FileHandle fd-shadow bypass, fd-based reads, openAsBlob/FileReadStream guards, cp TOCTOU, primitive process.env rejection, the defineProperty invariant, and the existing /proc/.../environ/scope tests extended to the newly-guarded entry points packages/plugins/apps/src/vite/env-guard.test.ts
makeGuardWrapper/makeGuardCallbackWrapper doc comment tightened packages/plugins/apps/src/vite/guarded-wrapper.ts

QA Instructions

yarn typecheck:all
# Expected: no errors ✅ VERIFIED

yarn build:all
yarn test:unit
# Expected: 94 suites, 2405 tests (2404 passed, 1 pre-existing unrelated skip) ✅ VERIFIED
Manual QA: standalone-process reproduction for the util.inspect(process.env) fix (click to expand — the only fix here that needs a genuinely fresh OS process, not just an in-process Jest test)

Every other fix in this PR is exercised directly by a named test in env-guard.test.ts (run yarn test:unit packages/plugins/apps/src/vite/env-guard.test.ts to reproduce all of them). The util.inspect fix needs a fresh, never-before-scoped process.env to observe the Proxy's real target, which a single Jest test file (already sharing the module's installed state across its own tests) can't produce on its own — verified instead via a standalone script:

// /tmp/inspect-proxy-repro.mjs
import util from 'util';

process.env.REAL_SECRET_ONLY_HERE = 'sk_should_never_leak_via_inspect';

const { runWithScopedEnv } = await import(
    '/path/to/build-plugins/packages/plugins/apps/src/vite/env-guard.ts'
);

await runWithScopedEnv({ PATH: '/scoped' }, async () => {
    const withoutShowProxy = util.inspect(process.env);
    const withShowProxy = util.inspect(process.env, { showProxy: true });
    const leaked =
        withoutShowProxy.includes('sk_should_never_leak_via_inspect') ||
        withShowProxy.includes('sk_should_never_leak_via_inspect');
    console.log(leaked ? 'LEAKED' : 'NOT LEAKED');
});
npx tsx /tmp/inspect-proxy-repro.mjs
# NOT LEAKED ✅ VERIFIED
Manual QA: env-guard's process.env scoping under a real end-to-end $.Actions call (click to expand — exercises the guard inside the actual dev-server request path, not just Jest)
  • The tests above exercise env-guard in isolation. This reproduction drives it through the real path a customer function takes: POST /__dd/executeAction → staging auth → runtime-context priming (preview-async, long-polled) → in-process execution of the function body under runWithScopedEnv → a live $.Actions proxy call.
  • Reproduced against a scaffolded consumer app with @datadog/vite-plugin built from this branch (yarn build in packages/published/vite-plugin) and imported by absolute path in the app's vite.config.ts, since publishConfig-based exports only resolve via a real npm publish, not a local link.
// src/envGuardActionsCheck.backend.ts
export async function envGuardActionsCheck() {
    const results: Record<string, unknown> = {};
    results.pathVisible = process.env.PATH !== undefined;
    results.realSecretHidden = process.env.QA_REAL_SECRET_MARKER === undefined;
    try {
        Object.defineProperty(process.env, 'LOCKED', { value: 'x', configurable: false });
        results.defineNonConfigurable = 'did not throw (unexpected)';
    } catch (error) {
        results.defineNonConfigurable = (error as Error).message;
    }
    try {
        const actionResult = await $.Actions.slack.chat.postMessage({
            inputs: { channel: '#test', text: 'env-guard QA' },
            connectionId: 'qa-connection-id',
        });
        results.actionsCallSucceeded = true;
        results.actionsCallResult = actionResult;
    } catch (error) {
        results.actionsCallSucceeded = false;
        results.actionsCallError = (error as Error).message;
    }
    return results;
}
# Import the function above from App.tsx, then start the dev server with real staging credentials
# (site set to dd.datad0g.com in vite.config.ts's auth block):
dd-auth --domain dd.datad0g.com -- sh -c 'npm run dev'

# Load the app once in a browser so Vite's transform hook registers the function, then call it:
HASH=$(node -e "console.log(require('crypto').createHash('sha256').update('src/envGuardActionsCheck').digest('hex'))")
curl -s -X POST http://localhost:5173/__dd/executeAction \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\": \"${HASH}.envGuardActionsCheck\", \"args\": []}"
# Expected: pathVisible: true, realSecretHidden: true, defineNonConfigurable: guard-specific
# error message (not a native Proxy invariant TypeError), actionsCallSucceeded: false,
# actionsCallError: "Action $.Actions.slack.chat.postMessage used connection \"qa-connection-id\",
# which is not in this function's allowed connections: []" ✅ VERIFIED
  • The connection-allowlist rejection at the end confirms the call reached PR [APPS-2792] Add: in-process local execution for backend functions #479's real enforcement layer — i.e. every layer in front of it (staging auth, runtime-context priming, in-process execution under this PR's env-guard fixes) ran for real rather than being mocked or short-circuited.

Blast Radius

Out of Scope / Follow-ups

1 item deferred
Item Status Next step
process.report.writeReport()'s TOCTOU window is narrowed (re-checked immediately before the real write) but not fully closed — a symlink swap in the remaining gap between that check and Node's own write syscall is still theoretically possible Deferred, accepted No action planned: exploiting this requires the backend function itself (or a coordinated external process) to actively race its own call, which is outside this guard's stated "JS-level defense-in-depth, not a hard security boundary" threat model

Documentation

@tyffical tyffical changed the title [APPS-2792] Fix: close env-guard bypass and reliability gaps found in review [APPS-2792] Fix: harden env-guard against bypass and reliability gaps Sep 9, 2026
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch from ea56b9c to 1e863ea Compare September 9, 2026 05:25
@tyffical
tyffical requested a balanced review from Copilot September 9, 2026 05:33
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T05:39:45.243361Z 1e863ea Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

This comment was marked as resolved.

@chatgpt-codex-connector

This comment was marked as outdated.

@tyffical
tyffical added this pull request to stack #499 September 9, 2026 13:12
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch from 1e863ea to 417f35c Compare September 9, 2026 15:33
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch from bdc8106 to 602f611 Compare September 9, 2026 16:49
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch from 602f611 to b3d78a9 Compare September 9, 2026 18:28
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch 3 times, most recently from 609af8e to d717cda Compare September 9, 2026 19:54
…, cp options, and process.env assignment

Fixes four bypass/reliability gaps found in review:
- A FileHandle's own fd is caller-controlled and could be shadowed with
  an own property to report a harmless value to the environ-path check
  while the real read still operated on the handle's actual fd. Fixed
  by capturing FileHandle.prototype's native fd getter once, at
  patch-install time, and invoking it directly to bypass any later
  own-property shadow.
- fs.cp/cpSync/promises.cp's recursive+dereference check read
  options.recursive/dereference once for its decision, then forwarded
  the caller's original options object to the real implementation,
  which read the same properties again — a getter-backed options
  object could report false to the check and true to the real call.
  Fixed by snapshotting both values into plain data properties before
  forwarding, the same pattern already used for options.fd.
- Reassigning process.env rejected only null/undefined, letting a
  primitive (e.g. a number) through to become the new realEnv fallback
  and break every later unscoped access. Fixed by validating the
  runtime value is a non-null object.
- fs.openAsBlob was wrapped unconditionally, even though it's absent on
  Node 18 — replacing the real `undefined` with an always-defined
  wrapper broke the existing feature-detection fallback in
  packages/core/src/helpers/fs.ts. Fixed by feature-detecting the same
  way before wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-env-guard-hardening branch from d717cda to 2f5e268 Compare September 9, 2026 19:56
@tyffical
tyffical marked this pull request as ready for review September 9, 2026 20:53
@tyffical
tyffical requested review from a team as code owners September 9, 2026 20:53
@tyffical
tyffical requested review from ksun154 and setnilson and removed request for a team and setnilson September 9, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants