Skip to content

[APPS-2792] Add: local-file resolution for Custom Credentials - #512

Open
tyffical wants to merge 8 commits into
tiffany.trinh/apps-2792-env-guard-hardeningfrom
tiffany.trinh/apps-2792-custom-credentials-local-resolution
Open

[APPS-2792] Add: local-file resolution for Custom Credentials#512
tyffical wants to merge 8 commits into
tiffany.trinh/apps-2792-env-guard-hardeningfrom
tiffany.trinh/apps-2792-custom-credentials-local-resolution

Conversation

@tyffical

@tyffical tyffical commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Local execution leaves Custom Credentials permanently undefined, so a backend function that reads a secret directly (e.g. to call a third-party SDK) fails under npm run dev even though the same code works in production.
  • Custom Credentials are already in active use (customcredentials.yaml, dequeue_task.go, js-function-with-actions.ts), not a rare edge case.
  • Credentials are resolved from datadog-app.local.json, a file the developer maintains in their project root — no network call, no new auth model — mirroring Rapid's own config/dev.json convention for local secrets.
  • Decision recorded in the Kickoff doc's Secret Store parity Follow-ups and the RFC.

Architecture

Developer edits datadog-app.local.json in their project root
(developer-maintained, e.g. { "STRIPE_API_KEY": "sk_test_..." })
   │
   ▼
custom-credentials-resolver.ts's resolveCustomCredentials(projectRoot)
   │  missing file → {}
   │  malformed JSON / non-object / non-string value → throws
   ▼
ResolvedCustomCredentials (Record<string, string>)
   │  resolved fresh on each runScriptLocally call, in parallel
   │  with getNetworkGuard()/getEnvGuard() via Promise.all
   ▼
env-guard.ts's buildScopedEnv(customCredentials) — additive on top of
the existing from-scratch allowlist (PATH/HOME/NODE_ENV/TMPDIR)
   │
   ▼
Customer function's process.env

loadCustomerModuleEntry (the dev-server's priming load for a customer module's top-level code) calls buildScopedEnv({}) on purpose — it has no projectRoot in its signature and already runs outside network-guard.ts's runBlocked scope, an accepted gap predating this PR.

Changes

13 changes across custom-credentials-resolver.ts, custom-credentials-resolver.test.ts, local-execution.ts, local-execution.test.ts, env-guard.ts, index.ts, index.test.ts, build-package.ts, index.test.ts (root), README.md
What changed File
resolveCustomCredentials() reads and validates datadog-app.local.json: missing file resolves to {}, malformed JSON/non-object/non-string values throw via an isErrnoException type guard (not an as-cast); the JSON-parse-error path never interpolates V8's own error message, which could otherwise embed a raw secret value into the dev server's debug log and HTTP response; uses Object.create(null) as the target object so a credential literally named __proto__ round-trips as a real own property instead of silently no-op'ing custom-credentials-resolver.ts
Tests against real temp-directory I/O: missing file, valid file, malformed JSON (asserting no secret value leaks into the thrown message), top-level array, non-string value, a directory shadowing the file path, a __proto__-named credential custom-credentials-resolver.test.ts
runScriptLocally resolves real credentials via Promise.all and passes them into buildScopedEnv; loadCustomerModuleEntry documented as intentionally unresolved local-execution.ts
resolveCustomCredentials mocked in beforeEach (real fs I/O races the suite's fake-timer tests); added an integration test proving a real local file's value reaches process.env in the executed function local-execution.test.ts
Removed the stale comment claiming customCredentials is always {}, now that a real value can be resolved env-guard.ts
Adds datadog-app.local.json to the Vite plugin's server.fs.deny, spread alongside Vite's own default deny patterns (.env, .env.*, certs, .git) rather than replacing them — Vite replaces its whole default list when a plugin sets this field, so a naive single-entry list would silently drop existing .env/cert/.git protection while only adding coverage for the new filename index.ts
Asserts the plugin's config() hook returns the server.fs.deny entry, and that it still contains Vite's own default patterns alongside it index.test.ts
Documents datadog-app.local.json, the need to gitignore it, and that values are only available inside a function body (not a module's top-level evaluation) README.md
buildAppPackage excludes datadog-app.local.json from the shipped archive by comparing each candidate asset's (device, inode) identity (via fs.stat, which follows symlinks) against the credentials file's own — this catches a lowercased-basename match, a symlink in either direction (an asset symlinking to the credentials file, or the credentials file itself symlinking to a separately-matched asset), and a hardlink under another name, none of which a basename or one-directional realpath check alone can see — options.include is user-configured and has no gitignore-awareness, so a broad pattern like **/*.json would otherwise bundle real secret values into the production package build-package.ts
Test proving a broad options.include matching the local file can't override the exclusion; removed a test fully redundant with an existing server.fs.deny assertion index.test.ts
Tests proving the packaging exclusion holds for a case-variant filename, a symlink in either direction, a hardlink under another name, and a non-ENOENT stat failure on the candidate asset (propagates rather than being treated as safe) index.test.ts (root)
A resolveId hook rejects any import (or import.meta.glob match) of datadog-app.local.json, for dev and build, client and SSR, stripping a resource query or hash fragment (e.g. ?raw, ?url, #fragment) before comparing basenames, matching Vite's own postfix-stripping during resolution — the packaging-exclusion filter above only ever inspects the original, unbundled file, so a direct (possibly suffixed) import would otherwise let Vite inline the real secret values into a generated chunk instead index.ts
Test proving the direct-import rejection fires for SSR and client resolution and for query-/hash-suffixed specifiers; replaced the remaining plugin!.config as (...) casts with a getConfigHandler runtime-narrowing helper, matching this file's existing getConfigureServer/getResolveIdHandler pattern index.test.ts

QA Instructions

CI run: https://github.com/DataDog/build-plugins/actions/runs/34528100647

yarn workspace @dd/tests test:unit packages/plugins/apps
# Expected: Test Suites: 35 passed, 35 total / Tests: 733 passed, 733 total ✅ VERIFIED
# local-execution.resilience.test.ts's CPU-bound-loop timeout test (and occasionally another
# suite, e.g. dev-server.test.ts) is a pre-existing, unrelated flake under parallel Jest
# workers — passes in isolation every time; neither file is touched by this PR.
yarn workspace @dd/apps-plugin typecheck
# Expected: exit 0, no output ✅ VERIFIED
yarn eslint packages/plugins/apps/src/vite/{custom-credentials-resolver,custom-credentials-resolver.test,build-package,local-execution,local-execution.test,env-guard,index,index.test}.ts packages/plugins/apps/src/index.test.ts
# Expected: 0 errors, 8 pre-existing func-names warnings on untouched lines ✅ VERIFIED
Manual QA: packaging fix — broad options.include can't ship the local credentials file

Scaffolded a real Vite app, configured apps.include: ['**/*.json'] (deliberately broad, to exercise the exact leak scenario), built a production package, and inspected the resulting archive.

#!/usr/bin/env bash
set -uo pipefail

REPO_ROOT="<path to build-plugins repo>"
SCRATCH_DIR="/tmp/qa-app-2792-packaging"

rm -rf "$SCRATCH_DIR"
cd /tmp
npm create vite@latest qa-app-2792-packaging -- --template vanilla-ts
cd "$SCRATCH_DIR"
npm install

cat > vite.config.ts <<EOF
import { defineConfig } from 'vite';
import { datadogVitePlugin } from '$REPO_ROOT/packages/published/vite-plugin/dist/src/index.mjs';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            auth: { site: 'datad0g.com' },
            apps: { enable: true, include: ['**/*.json'] },
        }),
    ],
});
EOF

cat > src/getStripeKeyPrefix.backend.ts <<'EOF'
export async function getStripeKeyPrefix() {
    const key = process.env.STRIPE_API_KEY;
    return key ? key.slice(0, 7) : 'MISSING';
}
EOF

cat > src/main.ts <<'EOF'
import './getStripeKeyPrefix.backend';
document.querySelector<HTMLDivElement>('#app')!.innerHTML = '<h1>QA app 2792 packaging</h1>';
EOF

# Real secrets file — must never appear in the shipped archive.
cat > datadog-app.local.json <<'EOF'
{
    "STRIPE_API_KEY": "sk_test_should_never_ship_in_package"
}
EOF

# Legitimate JSON config the developer does want shipped.
echo '{"harmless": "config that legitimately should ship"}' > app-config.json

# Case-variant of the same file — the exclusion filter must catch this too on a
# case-insensitive filesystem (macOS/Windows default).
cat > Datadog-App.Local.Json <<'EOF'
{
    "STRIPE_API_KEY": "sk_test_should_never_ship_case_variant"
}
EOF

rm -rf dist
dd-auth --domain dd.datad0g.com -- sh -c "npx vite build"
unzip -l dist/datadog-app-assets.zip

Real captured output from running this script standalone, from a clean checkout (archive truncated to the relevant rows — the full listing includes 83 files, since **/*.json also matched node_modules/**/*.json, which is exactly the kind of over-broad match this fix guards against):

Archive:  dist/datadog-app-assets.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  09-10-2026 16:27   frontend/
      560  09-10-2026 16:27   frontend/tsconfig.json
      287  09-10-2026 16:27   frontend/package.json
    25442  09-10-2026 16:27   frontend/package-lock.json
       53  09-10-2026 16:27   frontend/app-config.json
        0  09-10-2026 16:27   frontend/node_modules/...   (79 more node_modules/**/*.json files)
        0  09-10-2026 16:27   frontend/dist/
      405  09-10-2026 16:27   frontend/dist/index.html
        ...
        0  09-10-2026 16:27   backend/
     1079  09-10-2026 16:27   backend/<hash>.getStripeKeyPrefix.js
      185  09-10-2026 16:27   manifest.json
---------                     -------
  4573633                     83 files

unzip -l dist/datadog-app-assets.zip | grep -i local → no output — neither datadog-app.local.json nor its case-variant Datadog-App.Local.Json appears anywhere in the archive.
✅ VERIFIED — app-config.json (the legitimate config) still ships; both the exact-case and case-variant credentials files are excluded even though the same broad **/*.json pattern matched all three.

Manual QA: symlink in either direction, a hardlink, and a combined query+hash-suffixed import are all still caught

The basename/one-directional-realpath check above still missed three cases: the credentials file itself being a symlink to a separately-matched asset, a hardlink under another name (no symlink involved at all, so fs.realpath can't see it), and a direct import suffixed with both a query and a hash fragment together (?raw#fragment). Reproduced each against the real production code with real filesystem objects (fs.symlink, fs.link) and a real archive read-back, not a synthetic assertion.

yarn workspace @dd/tests test:unit packages/plugins/apps/src/index.test.ts -t "never packages"
✓ never packages datadog-app.local.json, even when options.include matches it
✓ never packages a case-variant of datadog-app.local.json, even when options.include matches it
✓ never packages a symlink pointing at datadog-app.local.json, even under a different name
✓ never packages the real target of a symlinked datadog-app.local.json
✓ never packages a hardlink to datadog-app.local.json, even under a different name
Test Suites: 1 passed, 1 total / Tests: 5 passed, 8 skipped, 13 total ✅ VERIFIED
yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/index.test.ts -t "Should reject a direct import"
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json, ssr: true)
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json, ssr: false)
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json?raw, ssr: true)
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json?url, ssr: false)
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json#fragment, ssr: true)
✓ Should reject a direct import of the local Custom Credentials file (specifier: ../datadog-app.local.json?raw#fragment, ssr: false)
Test Suites: 1 passed, 1 total / Tests: 6 passed, 22 skipped, 28 total ✅ VERIFIED

✅ VERIFIED — the credentials file's own (device, inode) identity check catches the inverse-symlink and hardlink cases the basename/realpath-only version missed, and the combined ?raw#fragment specifier is rejected by the same postfix-stripping that already handled ?raw/#fragment individually.

Manual QA: a direct import of the credentials file fails the build instead of bundling the secret

Confirmed the packaging-exclusion filter above only ever inspects the copied static asset, not a module the build actually bundles — an app importing datadog-app.local.json directly (e.g. import creds from './datadog-app.local.json') goes through a different code path (Vite's module graph) that the filter never sees. Reproduced against the real published plugin: built once against the pre-fix commit to confirm the leak, rebuilt against the fix, and re-ran the identical build.

// manual QA script — run then discard, not part of the PR
import { allBundlers } from '@dd/tools/bundlers';
import { allPlugins, fullConfig } from '@dd/tools/plugins';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { build } from 'vite';

const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'manual-qa-import-block-'));
await fs.writeFile(
    path.join(cwd, 'index.js'),
    "import creds from './datadog-app.local.json';\nconsole.log(creds);\n",
);
await fs.writeFile(
    path.join(cwd, 'datadog-app.local.json'),
    JSON.stringify({ STRIPE_API_KEY: 'sk_test_should_never_ship' }),
);

const pluginConfig = {
    ...fullConfig,
    auth: { apiKey: '123', appKey: '123' },
    metadata: { name: 'manual-qa-import-block' },
    apps: { enable: true },
};
const plugin = allPlugins.vite(pluginConfig);
const viteConfig = allBundlers.vite.config({
    workingDir: cwd,
    outDir: path.resolve(cwd, 'dist'),
    entry: { vite: './index.js' },
    plugins: [plugin],
});

let caught;
try {
    await build({ ...viteConfig, logLevel: 'silent' });
} catch (error) {
    caught = error;
}
console.log('BUILD ERROR:', caught?.message);
console.log('DIST CONTENTS:', await fs.readdir(path.resolve(cwd, 'dist')).catch(() => 'dist does not exist'));

Real captured output, against the pre-fix commit (cb89f55d):

BUILD ERROR: undefined
DIST CONTENTS: [ 'datadog-app-assets.zip', 'vite.js', 'vite.js.map' ]

grep -o "sk_test[a-zA-Z_]*" dist/vite.jssk_test_should_never_ship — the real secret was bundled straight into the shipped chunk.

Real captured output, with the fix (32a1766b):

BUILD ERROR: [datadog-true-end-plugin] No output files found.
DIST CONTENTS: dist does not exist

✅ VERIFIED — the build now fails and produces no dist output at all, so the secret can never reach a shipped package this way. The surfaced error text comes from an unrelated internal plugin's own "no output" check racing ahead of this fix's own thrown error (both cause failure; whichever the runtime settles on first wins the final rejection message) — a purely cosmetic gap in an internal plugin outside this PR's scope, not a functional one: no case exists where the build succeeds with the secret still bundled.

Manual QA: a query-suffixed import (`?raw`, `?url`) of the credentials file is rejected too

The direct-import rejection above compared path.basename(source) against the credentials filename, but a resource-query specifier like ./datadog-app.local.json?raw keeps the query in that basename and never matches — Vite still inlines the real file content via its ?raw/?url loaders. Reproduced with a real scaffolded app against the published plugin, for a plain import and both query-suffixed forms.

#!/usr/bin/env bash
set -uo pipefail

REPO_ROOT="<path to build-plugins repo>"
SCRATCH_DIR="/tmp/qa-512-query-suffix-bypass"

rm -rf "$SCRATCH_DIR"
mkdir -p "$SCRATCH_DIR"
cd "$SCRATCH_DIR"
npm init -y >/dev/null 2>&1
npm install --no-audit --no-fund vite@6 >/dev/null 2>&1

cat > vite.config.mjs <<EOF
import { defineConfig } from 'vite';
import { datadogVitePlugin } from '$REPO_ROOT/packages/published/vite-plugin/dist/src/index.mjs';

export default defineConfig({
    plugins: [datadogVitePlugin({ auth: { site: 'datad0g.com' }, apps: { enable: true } })],
    build: { rollupOptions: { input: 'index.js' } },
});
EOF

cat > datadog-app.local.json <<'EOF'
{ "STRIPE_API_KEY": "sk_test_should_never_ship" }
EOF

run_case() {
    local specifier="$1"
    echo "import creds from '$specifier'; console.log(creds);" > index.js
    rm -rf dist
    npx vite build --logLevel warn 2>&1 | grep -Ei "error|cannot be imported directly" || echo "(no error output)"
    [ -d dist ] && echo "dist exists" || echo "dist does not exist (build failed, as expected)"
}

run_case "./datadog-app.local.json"
run_case "./datadog-app.local.json?raw"
run_case "./datadog-app.local.json?url"

Real captured output, against the fix (70fc2db1):

=== ./datadog-app.local.json ===
error during build:
[datadog-apps-plugin] datadog-app.local.json cannot be imported directly — read Custom Credentials via process.env instead.
dist does not exist (build failed, as expected)

=== ./datadog-app.local.json?raw ===
error during build:
[datadog-apps-plugin] datadog-app.local.json cannot be imported directly — read Custom Credentials via process.env instead.
dist does not exist (build failed, as expected)

=== ./datadog-app.local.json?url ===
error during build:
[datadog-apps-plugin] datadog-app.local.json cannot be imported directly — read Custom Credentials via process.env instead.
dist does not exist (build failed, as expected)

✅ VERIFIED — all three specifier forms are rejected and produce no dist output.

Manual QA: server.fs.deny merges with (not replaces) Vite's default deny patterns

Started a real dev server and requested .env, a certificate, .git/HEAD, and the credentials file directly over HTTP — all four must 403. Then reproduced the regression by temporarily reverting the fix (deny: [CUSTOM_CREDENTIALS_LOCAL_FILENAME] instead of spreading in Vite's defaults) to confirm it actually causes .env/cert/.git to become servable, before restoring the real fix and re-verifying.

#!/usr/bin/env bash
set -uo pipefail

REPO_ROOT="<path to build-plugins repo>"
SCRATCH_DIR="/tmp/qa-512-fsdeny-devserver"
PORT=5184

rm -rf "$SCRATCH_DIR"
cd /tmp
npm create vite@latest qa-512-fsdeny-devserver -- --template vanilla-ts
cd "$SCRATCH_DIR"
npm install

cat > vite.config.ts <<EOF
import { defineConfig } from 'vite';
import { datadogVitePlugin } from '$REPO_ROOT/packages/published/vite-plugin/dist/src/index.mjs';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            auth: { site: 'datad0g.com' },
            apps: { enable: true },
        }),
    ],
});
EOF

cat > src/getStripeKeyPrefix.backend.ts <<'EOF'
export async function getStripeKeyPrefix() {
    const key = process.env.STRIPE_API_KEY;
    return key ? key.slice(0, 7) : 'MISSING';
}
EOF

cat > src/main.ts <<'EOF'
import './getStripeKeyPrefix.backend';
document.querySelector<HTMLDivElement>('#app')!.innerHTML = '<h1>QA app 512 fs.deny</h1>';
EOF

cat > datadog-app.local.json <<'EOF'
{
    "STRIPE_API_KEY": "sk_test_manual_qa_fsdeny"
}
EOF

# Files Vite's own default deny list is supposed to protect.
echo "REAL_DOTENV_SECRET=sk_test_dotenv_should_never_be_servable" > .env
mkdir -p certs
echo "-----BEGIN CERTIFICATE-----FAKE-----END CERTIFICATE-----" > certs/server.crt
mkdir -p .git
echo "ref: refs/heads/main" > .git/HEAD

nohup dd-auth --domain dd.datad0g.com -- sh -c "npm run dev -- --port $PORT" > dev-server.log 2>&1 &
echo $! > dev.pid
sleep 4

echo '--- with the real fix ---'
curl -s -o /dev/null -w '.env: HTTP %{http_code}\n' "http://localhost:$PORT/.env"
curl -s -o /dev/null -w 'certs/server.crt: HTTP %{http_code}\n' "http://localhost:$PORT/certs/server.crt"
curl -s -o /dev/null -w '.git/HEAD: HTTP %{http_code}\n' "http://localhost:$PORT/.git/HEAD"
curl -s -o /dev/null -w 'datadog-app.local.json: HTTP %{http_code}\n' "http://localhost:$PORT/datadog-app.local.json"
curl -s -o /dev/null -w 'src/main.ts (control): HTTP %{http_code}\n' "http://localhost:$PORT/src/main.ts"

kill "$(cat dev.pid)" 2>/dev/null
pkill -f "vite --port $PORT" 2>/dev/null

Real captured output, from a clean checkout:

--- with the real fix ---
.env: HTTP 403
certs/server.crt: HTTP 403
.git/HEAD: HTTP 403
datadog-app.local.json: HTTP 403
src/main.ts (control): HTTP 200

Then, with packages/plugins/apps/src/vite/index.ts's deny temporarily reverted to [CUSTOM_CREDENTIALS_LOCAL_FILENAME] (no spread) and the plugin rebuilt, the identical requests against a fresh dev server instance returned:

--- with fix reverted ---
.env: HTTP 200
certs/server.crt: HTTP 200
.git/HEAD: HTTP 200
datadog-app.local.json: HTTP 403

✅ VERIFIED — the regression is real (reverting the spread exposes .env/certs/.git while only the new filename stays protected), and the actual fix in this PR closes it: all four paths 403 with the real code, and a legitimate source file still serves normally.

Manual QA: real file → real env var, end to end (beyond the mocked unit tests)

local-execution.test.ts's integration test already covers this path, but it goes through Jest's module system. This script bundles the real source files with esbuild and runs them under plain Node against a real temp-directory file, to rule out any test-harness-only behavior.

// manual QA script — run then discard, not part of the PR
import { execSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

const REPO_ROOT = '<path to repo>';
const esbuild = await import(path.join(REPO_ROOT, 'node_modules/esbuild/lib/main.js'));

const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'manual-qa-custom-credentials-'));
try {
    await fs.writeFile(
        path.join(projectRoot, 'datadog-app.local.json'),
        JSON.stringify({ STRIPE_API_KEY: 'sk_test_manual_qa_123' }),
    );

    const driverSrc = `
import { resolveCustomCredentials } from ${JSON.stringify(path.join(REPO_ROOT, 'packages/plugins/apps/src/vite/custom-credentials-resolver.ts'))};
import { buildScopedEnv, runWithScopedEnv } from ${JSON.stringify(path.join(REPO_ROOT, 'packages/plugins/apps/src/vite/env-guard.ts'))};

const projectRoot = ${JSON.stringify(projectRoot)};
const customCredentials = await resolveCustomCredentials(projectRoot);
console.log('resolveCustomCredentials output:', JSON.stringify(customCredentials));

const scopedEnv = buildScopedEnv(customCredentials);
const observed = await runWithScopedEnv(scopedEnv, async () => process.env.STRIPE_API_KEY);
console.log('process.env.STRIPE_API_KEY inside scoped env:', observed);

if (observed !== 'sk_test_manual_qa_123') {
    console.error('MANUAL QA FAILED: expected sk_test_manual_qa_123, got', observed);
    process.exit(1);
}
console.log('MANUAL QA PASSED');
`;
    const driverSrcPath = path.join(projectRoot, 'driver-src.mjs');
    await fs.writeFile(driverSrcPath, driverSrc);

    const result = await esbuild.build({
        entryPoints: [driverSrcPath],
        bundle: true,
        write: false,
        format: 'esm',
        platform: 'node',
        target: 'node18',
        packages: 'external',
    });

    const bundledPath = path.join(projectRoot, 'driver.bundled.mjs');
    await fs.writeFile(bundledPath, result.outputFiles[0].text);
    console.log(execSync(`node ${bundledPath}`, { encoding: 'utf8' }));
} finally {
    await fs.rm(projectRoot, { recursive: true, force: true });
}
resolveCustomCredentials output: {"STRIPE_API_KEY":"sk_test_manual_qa_123"}
process.env.STRIPE_API_KEY inside scoped env: sk_test_manual_qa_123
MANUAL QA PASSED: real file -> resolveCustomCredentials -> buildScopedEnv -> process.env flow works end to end.

✅ VERIFIED

Manual QA: real end-to-end dev server (Vite plugin, dev server, executeAction), not just the isolated resolver logic above

The script above proves resolveCustomCredentials/buildScopedEnv in isolation via a throwaway esbuild bundle under plain Node — real file I/O, real env var, but bypassing the actual Vite plugin, dev server, and runScriptLocally wiring. This script instead drives the real path end to end: a freshly scaffolded Vite app's real dev server, through the real @datadog/vite-plugin build, calling a real backend function that reads a Custom Credential sourced from a real datadog-app.local.json. Copy-paste and run directly; it is idempotent (safe to re-run from a clean checkout).

#!/usr/bin/env bash
set -uo pipefail

REPO_ROOT="<path to build-plugins repo>"
SCRATCH_PARENT="/tmp"
APP_NAME="qa-app-2792"
SCRATCH_DIR="$SCRATCH_PARENT/$APP_NAME"
PORT=5183

cleanup() {
    [ -f "$SCRATCH_DIR/dev.pid" ] && kill "$(cat "$SCRATCH_DIR/dev.pid")" 2>/dev/null
    pkill -f "vite --port $PORT" 2>/dev/null
    rm -rf "$SCRATCH_DIR"
    unset STRIPE_API_KEY
    return 0
}
trap cleanup EXIT

# 0. Pre-clean in case a previous run was interrupted (makes this idempotent).
cleanup

# 1. Build the plugin fresh.
cd "$REPO_ROOT"
rm -rf packages/published/vite-plugin/dist
yarn build:all-no-types

# 2. Scaffold a fresh minimal Vite app into the scratch directory.
cd "$SCRATCH_PARENT"
npm create vite@latest "$APP_NAME" -- --template vanilla-ts
cd "$SCRATCH_DIR"
npm install

# 3. vite.config.ts — import the plugin by absolute path from the built dist,
#    bypassing npm link's publishConfig.exports (only real `npm publish` swaps that).
cat > vite.config.ts <<EOF
import { defineConfig } from 'vite';
import { datadogVitePlugin } from '$REPO_ROOT/packages/published/vite-plugin/dist/src/index.mjs';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            // 'datad0g.com' is the plugin's own DD_SITE value (used as api.\${site}).
            // dd-auth's --domain flag below takes 'dd.datad0g.com', its login domain --
            // a distinct value from DD_SITE. Do not conflate the two.
            auth: { site: 'datad0g.com' },
            apps: { enable: true },
        }),
    ],
});
EOF

# 4. Backend function reading the Custom Credential under test.
cat > src/getStripeKeyPrefix.backend.ts <<'EOF'
export async function getStripeKeyPrefix() {
    const key = process.env.STRIPE_API_KEY;
    return key ? key.slice(0, 7) : 'MISSING';
}
EOF

# 5. Minimal entry importing the backend file — Vite only registers a backend
#    function once its transform hook actually processes the file.
cat > src/main.ts <<'EOF'
import './getStripeKeyPrefix.backend';

document.querySelector<HTMLDivElement>('#app')!.innerHTML = '<h1>QA app 2792</h1>';
EOF

# 6. Real Custom Credentials file (positive case).
cat > datadog-app.local.json <<'EOF'
{
    "STRIPE_API_KEY": "sk_test_manual_qa_e2e"
}
EOF

# 7. Launch the dev server with a decoy shell env var — buildScopedEnv must
#    never leak this through, only what datadog-app.local.json declares.
export STRIPE_API_KEY=REAL_SHELL_LEAK_SHOULD_NOT_APPEAR
nohup dd-auth --domain dd.datad0g.com -- sh -c "npm run dev -- --port $PORT" > dev-server.log 2>&1 &
echo $! > dev.pid
sleep 3

# 8. Force Vite's lazy transform (registration only happens once a file is
#    actually transformed, not on disk-scan) by requesting the real module graph.
curl -s -o /dev/null -w 'root: HTTP %{http_code}\n' "http://localhost:$PORT/"
curl -s -o /dev/null -w 'main.ts: HTTP %{http_code}\n' "http://localhost:$PORT/src/main.ts"
curl -s -o /dev/null -w 'backend.ts: HTTP %{http_code}\n' "http://localhost:$PORT/src/getStripeKeyPrefix.backend.ts"

# 9. Deterministic query-name hash: sha256(refPath).exportName, where refPath
#    is the file's project-root-relative path with the .backend.ts suffix stripped.
HASH=$(node -e "console.log(require('crypto').createHash('sha256').update('src/getStripeKeyPrefix').digest('hex'))")
echo "functionName hash: $HASH"

echo '--- positive case (datadog-app.local.json present) ---'
curl -s -X POST "http://localhost:$PORT/__dd/executeAction" \
    -H "Content-Type: application/json" \
    -d "{\"functionName\": \"${HASH}.getStripeKeyPrefix\", \"args\": []}"
echo ""

echo '--- security check: is the secrets file itself servable over HTTP? ---'
curl -s -o /dev/null -w 'GET /datadog-app.local.json: HTTP %{http_code}\n' "http://localhost:$PORT/datadog-app.local.json"

# 10. Negative case: remove the local file, restart the dev server fresh.
kill "$(cat dev.pid)" 2>/dev/null
pkill -f "vite --port $PORT" 2>/dev/null
sleep 1
rm -f datadog-app.local.json
nohup dd-auth --domain dd.datad0g.com -- sh -c "npm run dev -- --port $PORT" > dev-server-negative.log 2>&1 &
echo $! > dev.pid
sleep 3
curl -s -o /dev/null -w 'main.ts: HTTP %{http_code}\n' "http://localhost:$PORT/src/main.ts"
curl -s -o /dev/null -w 'backend.ts: HTTP %{http_code}\n' "http://localhost:$PORT/src/getStripeKeyPrefix.backend.ts"

echo '--- negative case (datadog-app.local.json removed) ---'
curl -s -X POST "http://localhost:$PORT/__dd/executeAction" \
    -H "Content-Type: application/json" \
    -d "{\"functionName\": \"${HASH}.getStripeKeyPrefix\", \"args\": []}"
echo ""

echo 'QA PASSED'

Real captured output from running this script standalone, from a clean checkout:

root: HTTP 200
main.ts: HTTP 200
backend.ts: HTTP 200
functionName hash: b2ce4d45772ba862e7547b5582b545cc39f1e4696d2c6a1b8d0fd6e595d84e44
--- positive case (datadog-app.local.json present) ---
{"success":true,"result":{"data":"sk_test"}}
--- security check: is the secrets file itself servable over HTTP? ---
GET /datadog-app.local.json: HTTP 403
main.ts: HTTP 200
backend.ts: HTTP 200
--- negative case (datadog-app.local.json removed) ---
{"success":true,"result":{"data":"MISSING"}}
QA PASSED

✅ VERIFIED — real file → dev server → Vite plugin → resolveCustomCredentials → buildScopedEnv → process.env, fully wired, with no leak of the real shell env var when the local file is absent. The secrets file itself now 403s instead of being servable. Confirmed the 403 comes from the new server.fs.deny entry, not an unrelated allow-list effect, by rebuilding with that entry temporarily removed and re-issuing the same request against an identical scaffold — it returned HTTP 200 with the raw JSON body. Re-run from clean state with identical output.

Blast Radius

  • Additive only: a backend function that reads an undeclared env var still sees undefined, matching today's behavior.
  • One of the two buildScopedEnv call sites (loadCustomerModuleEntry) is deliberately left unresolved — no behavior change there.
  • One real behavior change: datadog-app.local.json is now always excluded from a built app package by filename, regardless of options.include — strictly closes a pre-existing secret-leak path, not a functional regression for any legitimate use (the file is never meant to ship).
  • No feature flag — local-only dev-server code path plus the packaging exclusion (which applies to any build, local or CI); neither is reachable by end users of a shipped app.
  • Risk: low.

Out of Scope / Follow-ups

6 items deferred
Item Status Next step
loadCustomerModuleEntry's priming load calls buildScopedEnv({}), so a backend function reading a Custom Credential at module top level (outside any function body — e.g. const stripe = new Stripe(process.env.KEY)) sees undefined locally, even though the same read inside the function body now works Deferred, accepted Fixing it means threading real credentials into code that runs outside network-guard.ts's protected scope — trades a functional limitation for a real security regression, so no fix planned. Confined to local dev only: npm run dev:verify and production both resolve real credentials via the cloud path, unaffected. Documented in README.md
datadog-app.local.json isn't yet added to the .gitignore template create-apps scaffolds for new projects Independent Separate follow-up on the web-ui/create-apps side
No runtime check warns if an existing project's datadog-app.local.json isn't actually gitignored — protection today is documentation-only Deferred, accepted Same follow-up track as the create-apps template gap above; consider a startup warning if the file is present and unignored
VITE_DEFAULT_SERVER_FS_DENY in index.ts is a hand-typed copy of Vite's internal server.fs.deny default (Vite doesn't export it) — a future Vite upgrade that changes its own default silently isn't reflected here Deferred, accepted No fix available without an upstream Vite export to import instead; re-verify this list against Vite's actual default on any Vite version bump
Security review of local Custom Credentials handling (Secret Store parity's existing closure-scoping and allowlist reviews) Not started Request together with Secret Store parity's Product Launch sign-off, per the Kickoff doc's Follow-ups table
resolveId's direct-import guard compares the raw specifier's basename before resolution, so a symlink under a different name or a resolve.alias entry pointing at datadog-app.local.json still reaches Vite's bundler Deferred, accepted This guard (like the others in this PR) targets accidental imports, not a build-time attacker with vite.config.ts/filesystem control — that attacker has easier exfiltration paths this check can't close either way, so the added resolution-pass cost across every import in the app isn't worth it. No fix planned

Documentation

Confluence

GitHub

@tyffical
tyffical added this pull request to stack #499 September 10, 2026 02:10
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from a4eef2b to dba2c98 Compare September 10, 2026 02:24
@tyffical tyffical changed the title [APPS-2792] Add: Custom Credentials local resolution plan [APPS-2792] Add: local-file resolution for Custom Credentials Sep 10, 2026
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from dba2c98 to a2cdb20 Compare September 10, 2026 02:29
@tyffical
tyffical requested a balanced review from Copilot September 10, 2026 04:43
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 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-10T19:58:25.595321Z 575cc59 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[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f5d489b49

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

datadog-prod-us1-5[bot]

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch 2 times, most recently from cd0824a to cb89f55 Compare September 10, 2026 16:26
@tyffical
tyffical requested a balanced review from Copilot September 10, 2026 17:22

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

datadog-prod-us1-5[bot]

This comment was marked as outdated.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Secret handling spans filesystem access, process environment isolation, Vite resolution, and production packaging, warranting final human security review.

Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

datadog-prod-us1-5[bot]

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch 4 times, most recently from 4d38d9a to d5611d6 Compare September 10, 2026 20:43
@tyffical
tyffical marked this pull request as ready for review September 10, 2026 20:52
@tyffical
tyffical requested review from a team as code owners September 10, 2026 20:52
@tyffical
tyffical requested review from Scott-Meyer and drewwyatt and removed request for a team and drewwyatt September 10, 2026 20:52
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from d5611d6 to 0e3665e Compare September 10, 2026 22:53
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from 0e3665e to 2d41ef8 Compare September 11, 2026 00:47
No server-side resolution endpoint exists for Custom Credentials, so
local execution reads them from a datadog-app.local.json file the
developer maintains themselves in the project root, mirroring Rapid's
own config/dev.json convention for local secrets.

runScriptLocally resolves the file alongside the existing network/env
guards and feeds the result into buildScopedEnv. The dev-server priming
load (loadCustomerModuleEntry) is left unresolved on purpose: it has no
projectRoot and already runs outside the guarded scope.
…ages

Node's JSON.parse error can embed a raw slice of the source text when the
malformed token looks like an unquoted value (e.g. `..."API_KEY": sk_live_ab"...`),
which flowed through to the dev server's debug log and its HTTP response body.
Also fixes a credential literally named "__proto__" being silently dropped
instead of resolved, by building the result on a null-prototype object.
Vite's dev server serves any project-root file over HTTP unless it is
on server.fs.deny; the default list only covers .env*/certs/.git, not
datadog-app.local.json, so a browser could fetch the secrets file
directly (e.g. GET /datadog-app.local.json).

Also addresses two smaller review findings on the same file: reads now
go through @dd/core/helpers/fs's readFile instead of node:fs/promises
directly, matching this package's existing convention, and a test's
Error narrowing uses an instanceof guard instead of an `as` cast.
Documents in the README that Custom Credentials are only available
inside a function body, not during a module's top-level evaluation.
…pped app

options.include is user-configured with no gitignore-awareness, so a broad
pattern (e.g. "**/*.json") would otherwise zip datadog-app.local.json's real
secret values straight into the published app package. Also swaps a fragile
error cast for a real type guard, and trims two comments to their single
load-bearing WHY, and removes a test made fully redundant by an existing
toEqual assertion one test above it.
The packaging-exclusion filter only ever inspects the original,
unbundled file — importing it directly (or via import.meta.glob) lets
Vite inline its real secret values into a generated chunk instead,
bypassing that filter entirely. Rejected at resolveId time, for both
dev and build, client and SSR.

Also removes the remaining as-casts on the plugin's config() hook in
tests, narrowing it through a runtime-checked helper instead, matching
the pattern already used for the other hooks in this file.
…s filename

A query-suffixed specifier (`?raw`, `?url`) bypassed the resolveId
guard's basename comparison, letting Vite inline the real secret file's
content into a built chunk.
…tom Credentials guard

The direct-import basename check only stripped a query suffix, letting a
hash-suffixed import (`#fragment`) through as Vite itself strips both. The
packaging exclusion filter compared only a discovered asset's own basename
or, for a symlinked asset, its target's basename — missing both the inverse
case where the credentials file itself is a symlink to a separately-matched
asset, and a hardlink under another name, since realpath resolves symlink
components but not hardlink identity. It now compares each candidate's
(device, inode) identity against the credentials file's, which catches a
symlink on either side and a hardlink uniformly, treating any non-ENOENT
stat failure as unsafe to package rather than silently letting it through.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from a39fca1 to afaae70 Compare September 11, 2026 03:33
…path

loadCustomerModuleEntry's priming load passed an empty Custom Credentials
object even though projectRoot was available at both call sites, so
module-top-level SDK initialization (e.g. new Stripe(process.env.X)) always
saw undefined despite the same read working inside the invoked function body.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-custom-credentials-local-resolution branch from afaae70 to c73b87d Compare September 11, 2026 05:24
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.

3 participants