diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index e5af151c9..1fd7026e0 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -10,6 +10,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Configuration](#configuration) - [Development server authentication](#development-server-authentication) +- [Custom Credentials for local execution](#custom-credentials-for-local-execution) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -43,6 +44,16 @@ passes it to the dev server via `DD_OAUTH_ACCESS_TOKEN`. When no credentials are configured, backend function execution is unavailable and the dev server tells you to start it with `datadog-apps dev`. +## Custom Credentials for local execution + +Backend functions read Custom Credentials from a `datadog-app.local.json` file in the project +root — a flat JSON object mapping env var name to value. Add this file to your project's +`.gitignore`; it holds real secret values. + +Values are only available while a backend function body is running — not during a module's +top-level evaluation (e.g. `const client = new Stripe(process.env.STRIPE_API_KEY)` at import +time). Read `process.env` inside the function body instead. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0f62b623b..6c2fbcd8b 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + import * as archive from '@dd/apps-plugin/archive'; import * as assets from '@dd/apps-plugin/assets'; import { getPlugins } from '@dd/apps-plugin'; @@ -143,6 +145,139 @@ describe('Apps Plugin - package output', () => { ); }); + test('never packages datadog-app.local.json, even when options.include matches it', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: localCredentialsPath, relativePath: 'datadog-app.local.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/datadog-app.local.json']), + ); + }); + + // Regression test: a case-insensitive filesystem resolves a differently-cased basename to the + // same file a glob matched, so the exclusion filter must compare case-insensitively. + test('never packages a case-variant of datadog-app.local.json, even when options.include matches it', async () => { + const localCredentialsPath = path.join(root, 'Datadog-App.Local.Json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: localCredentialsPath, relativePath: 'Datadog-App.Local.Json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/Datadog-App.Local.Json']), + ); + }); + + // Regression test: a symlink under a different name still reads the credentials file's real + // content, so the exclusion filter must check the resolved target, not just the discovered + // path's own basename. + test('never packages a symlink pointing at datadog-app.local.json, even under a different name', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const symlinkPath = path.join(root, 'backup-config.json'); + await fs.symlink(localCredentialsPath, symlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: symlinkPath, relativePath: 'backup-config.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/backup-config.json']), + ); + }); + + // Regression test: when datadog-app.local.json is itself a symlink, the file glob-matched at + // its target path carries the same secret bytes under a different name and must be excluded too. + test('never packages the real target of a symlinked datadog-app.local.json', async () => { + const realSecretsPath = path.join(root, 'config', 'dev-secrets.json'); + await fs.mkdir(path.dirname(realSecretsPath), { recursive: true }); + await fs.writeFile(realSecretsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.symlink(realSecretsPath, localCredentialsPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: realSecretsPath, relativePath: 'config/dev-secrets.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/config/dev-secrets.json']), + ); + }); + + // Regression test: a hardlink shares the credentials file's inode without ever being a + // symlink, so an identity check must compare (device, inode), not just resolve symlink targets. + test('never packages a hardlink to datadog-app.local.json, even under a different name', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const hardlinkPath = path.join(root, 'backup-hardlink.json'); + await fs.link(localCredentialsPath, hardlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: hardlinkPath, relativePath: 'backup-hardlink.json' }, + ]); + + await buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })); + + const zip = await JSZip.loadAsync( + await fs.readFile(path.join(packageDirectory, ARCHIVE_FILENAME)), + ); + expect(Object.keys(zip.files)).not.toEqual( + expect.arrayContaining(['frontend/backup-hardlink.json']), + ); + }); + + // Regression test: a stat failure on the candidate asset itself (not on the credentials file) + // must still propagate rather than being swallowed as "not a match" — the mock only intercepts + // the asset's own stat call so a real credentials file resolves normally first. + test('propagates a non-ENOENT stat failure instead of treating an unverifiable asset as safe', async () => { + const localCredentialsPath = path.join(root, 'datadog-app.local.json'); + await fs.writeFile(localCredentialsPath, '{"STRIPE_API_KEY":"sk_test_should_not_ship"}'); + const symlinkPath = path.join(root, 'mystery-config.json'); + await fs.symlink(sourcePath, symlinkPath); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: sourcePath, relativePath: 'index.html' }, + { absolutePath: symlinkPath, relativePath: 'mystery-config.json' }, + ]); + const realStat = fs.stat.bind(fs); + jest.spyOn(fs, 'stat').mockImplementation(async (target, ...args) => { + if (target === symlinkPath) { + const error: NodeJS.ErrnoException = new Error('permission denied'); + error.code = 'EACCES'; + throw error; + } + return realStat(target as string, ...(args as [])); + }); + + await expect( + buildAppPackage(packageOptions({ options: { include: ['**/*.json'] } })), + ).rejects.toThrow('permission denied'); + }); + test('writes manifest.json with only backend function entries', async () => { await buildAppPackage(packageOptions()); diff --git a/packages/plugins/apps/src/vite/build-package.ts b/packages/plugins/apps/src/vite/build-package.ts index 43908fc85..277125a2f 100644 --- a/packages/plugins/apps/src/vite/build-package.ts +++ b/packages/plugins/apps/src/vite/build-package.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + import { getDDEnvValue } from '@dd/core/helpers/env'; import { rm } from '@dd/core/helpers/fs'; import type { GlobalContext } from '@dd/core/types'; @@ -17,6 +19,8 @@ import type { BackendFunction } from '../backend/types'; import { ARCHIVE_FILENAME, PLUGIN_NAME } from '../constants'; import type { AppsManifest, AppsOptionsWithDefaults } from '../types'; +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from './custom-credentials-resolver'; + export interface BuildAppPackageOptions { backendOutputs: Map; backendFunctions: BackendFunction[]; @@ -24,6 +28,52 @@ export interface BuildAppPackageOptions { options: AppsOptionsWithDefaults; } +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +type FileIdentity = { dev: number; ino: number }; + +/** Resolves the root credentials file's (device, inode) identity, or undefined if it doesn't exist. */ +async function resolveCredentialsIdentity(buildRoot: string): Promise { + try { + const stats = await fsp.stat(path.join(buildRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME)); + return { dev: stats.dev, ino: stats.ino }; + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } +} + +/** + * Compares an asset's (device, inode) identity against the credentials file's, since fs.stat + * follows symlinks either way and inode identity also catches a hardlink — cases a path-string + * comparison alone can miss. A vanished asset has nothing left to leak; any other stat failure is + * re-thrown rather than silently treated as safe to package. + */ +async function isCustomCredentialsAsset( + absolutePath: string, + credentialsIdentity: FileIdentity | undefined, +): Promise { + if (path.basename(absolutePath).toLowerCase() === CUSTOM_CREDENTIALS_LOCAL_FILENAME) { + return true; + } + if (!credentialsIdentity) { + return false; + } + try { + const stats = await fsp.stat(absolutePath); + return stats.dev === credentialsIdentity.dev && stats.ino === credentialsIdentity.ino; + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { const functions: AppsManifest['backend']['functions'] = {}; for (const func of backendFunctions) { @@ -88,13 +138,25 @@ export async function buildAppPackage({ try { const generatedPaths = new Set([archivePath, defaultArchivePath]); const backendPaths = new Set(backendOutputs.values()); - const frontendAssets = assets + const candidateAssets = assets .filter((asset) => !generatedPaths.has(path.resolve(asset.absolutePath))) - .filter((asset) => !backendPaths.has(asset.absolutePath)) - .map((asset) => ({ - ...asset, - relativePath: `frontend/${asset.relativePath}`, - })); + .filter((asset) => !backendPaths.has(asset.absolutePath)); + const credentialsIdentity = await resolveCredentialsIdentity(buildRoot); + const nonCredentialsAssets = ( + await Promise.all( + candidateAssets.map(async (asset) => ({ + asset, + isCredentialsAsset: await isCustomCredentialsAsset( + asset.absolutePath, + credentialsIdentity, + ), + })), + ) + ).filter(({ isCredentialsAsset }) => !isCredentialsAsset); + const frontendAssets = nonCredentialsAssets.map(({ asset }) => ({ + ...asset, + relativePath: `frontend/${asset.relativePath}`, + })); const packageAssets: Asset[] = [...frontendAssets]; for (const [bundleName, absolutePath] of backendOutputs) { packageAssets.push({ diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts new file mode 100644 index 000000000..ee3bb525b --- /dev/null +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts @@ -0,0 +1,119 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + CUSTOM_CREDENTIALS_LOCAL_FILENAME, + resolveCustomCredentials, +} from './custom-credentials-resolver'; + +describe('resolveCustomCredentials', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'custom-credentials-resolver-')); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + it('resolves to {} when the file does not exist', async () => { + await expect(resolveCustomCredentials(projectRoot)).resolves.toEqual({}); + }); + + it('resolves the flat object of env var name to value', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_123' }), + ); + + await expect(resolveCustomCredentials(projectRoot)).resolves.toEqual({ + STRIPE_API_KEY: 'sk_test_123', + }); + }); + + it('rejects malformed JSON instead of silently returning {}', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + '{ not valid json', + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(/not valid JSON/); + }); + + it('never echoes a real secret value into the parse-error message', async () => { + // An unquoted JSON value triggers V8's parse error to embed a source-text slice — the + // real bug this guards against. Deliberately not shaped like a real credential (no + // digits, no known prefix) so this fixture doesn't trip secret-scanning on push. + const secret = 'THIS_TOKEN_MUST_NEVER_LEAK_INTO_ANY_ERROR_MESSAGE'; + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + `{"STRIPE_API_KEY": ${secret}}`, + ); + + let thrown: unknown; + try { + await resolveCustomCredentials(projectRoot); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw thrown; + } + expect(thrown.message).not.toContain(secret); + expect(thrown.message).not.toContain(secret.slice(0, 10)); + }); + + it('rejects a top-level array', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify(['STRIPE_API_KEY']), + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(/flat JSON object/); + }); + + it('rejects a non-string value, naming the offending key', async () => { + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + JSON.stringify({ STRIPE_API_KEY: 12345 }), + ); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow( + /"STRIPE_API_KEY".*must be a string/, + ); + }); + + it('resolves a credential literally named "__proto__" instead of silently dropping it', async () => { + // Written as a raw string, not JSON.stringify({...}): object-literal `__proto__` syntax + // special-cases to set the prototype rather than create an own property, so stringifying + // it would silently produce {} here — JSON.parse has no such special case. + await fs.writeFile( + path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME), + '{"__proto__": "sk_test_proto", "STRIPE_API_KEY": "sk_test_123"}', + ); + + // Bracket access via a variable key, not `resolved.__proto__`, since the latter triggers + // eslint's no-proto rule even though this is reading an ordinary data property here. + const protoKey = '__proto__'; + const resolved = await resolveCustomCredentials(projectRoot); + expect(Object.prototype.hasOwnProperty.call(resolved, protoKey)).toBe(true); + expect(resolved[protoKey]).toBe('sk_test_proto'); + expect(resolved.STRIPE_API_KEY).toBe('sk_test_123'); + }); + + it('propagates a non-ENOENT filesystem error instead of treating it as "missing"', async () => { + // A directory where a file is expected fails to read with EISDIR, not ENOENT — resolving + // to {} here would hide a real misconfiguration (e.g. a stray directory shadowing the file). + await fs.mkdir(path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME)); + + await expect(resolveCustomCredentials(projectRoot)).rejects.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/vite/custom-credentials-resolver.ts b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts new file mode 100644 index 000000000..b89947ec1 --- /dev/null +++ b/packages/plugins/apps/src/vite/custom-credentials-resolver.ts @@ -0,0 +1,71 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global NodeJS */ + +import { readFile } from '@dd/core/helpers/fs'; +import path from 'node:path'; + +/** One process.env entry per secret the developer has supplied locally, keyed the same way production's resolved Custom Credentials env vars are (e.g. `STRIPE_API_KEY`). */ +export type ResolvedCustomCredentials = Record; + +/** + * A git-ignored file the developer maintains themselves with real Custom Credentials values for + * local execution — no server call, no new auth model, since no server-side resolution endpoint + * exists for this. Same convention Rapid already documents for local secrets (`config/dev.json`, + * gitignored), applied here to Custom Credentials. + */ +export const CUSTOM_CREDENTIALS_LOCAL_FILENAME = 'datadog-app.local.json'; + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Resolves Custom Credentials for local execution by reading {@link CUSTOM_CREDENTIALS_LOCAL_FILENAME} + * from the project root. A missing file resolves to `{}` — most projects won't have one — but a + * present-and-malformed file throws, since silently ignoring a typo would make a declared secret + * look identical to an undeclared one. + */ +export async function resolveCustomCredentials( + projectRoot: string, +): Promise { + const filePath = path.join(projectRoot, CUSTOM_CREDENTIALS_LOCAL_FILENAME); + let raw: string; + try { + raw = await readFile(filePath); + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') { + return {}; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Never interpolate the underlying JSON.parse error: V8's message can embed a raw slice + // of an unquoted value's source text, echoing a real secret into logs/HTTP responses. + throw new Error(`${CUSTOM_CREDENTIALS_LOCAL_FILENAME} is not valid JSON.`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} must be a flat JSON object mapping env var names to string values.`, + ); + } + + // Object.create(null) rather than {} so a credential literally named "__proto__" round-trips + // as a normal own property instead of silently no-op'ing against Object.prototype's setter. + const resolved: ResolvedCustomCredentials = Object.create(null); + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME}'s "${key}" value must be a string, got ${typeof value}.`, + ); + } + resolved[key] = value; + } + return resolved; +} diff --git a/packages/plugins/apps/src/vite/env-guard.ts b/packages/plugins/apps/src/vite/env-guard.ts index ec2233dd3..5502e51ed 100644 --- a/packages/plugins/apps/src/vite/env-guard.ts +++ b/packages/plugins/apps/src/vite/env-guard.ts @@ -33,7 +33,6 @@ const nativeReadlinkSync = fs.readlinkSync; export const SAFE_ENV_KEYS = ['PATH', 'HOME', 'NODE_ENV', 'TMPDIR'] as const; -// customCredentials is currently always {} — Custom Credential resolution for local execution is still undecided, so those values stay unset here rather than read from the real environment. export function buildScopedEnv(customCredentials: Record): Record { const scoped: Record = {}; for (const key of SAFE_ENV_KEYS) { diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 64d0f9c1e..c673ca574 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -2,7 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from '@dd/apps-plugin/vite/custom-credentials-resolver'; +import { getVitePlugin, VITE_DEFAULT_SERVER_FS_DENY } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; import { InjectPosition } from '@dd/core/types'; @@ -95,6 +96,23 @@ function isDevServerMiddleware(value: unknown): value is DevServerMiddleware { return typeof value === 'function'; } +type ConfigHookResult = { + ssr: { noExternal: string[] }; + server: { fs: { deny: string[] } }; +}; + +// Narrows `plugin.config` to its plain-function hook form via a runtime check, avoiding an `as` +// cast on its return value — mirrors `getConfigureServer` above. +function getConfigHandler(plugin: ReturnType): () => ConfigHookResult { + const { config } = plugin ?? {}; + if (typeof config !== 'function') { + throw new Error('Expected plugin.config to be the plain function-hook form'); + } + return function callConfig(): ConfigHookResult { + return Reflect.apply(config, undefined, []); + }; +} + const functions: BackendFunction[] = [ { relativePath: 'src/backend/myHandler', @@ -660,6 +678,30 @@ describe('Backend Functions - getVitePlugin', () => { }); }); + // Regression test: build-package.ts's exclusion filter only sees the unbundled file, and a + // query- or hash-suffixed specifier (`?raw`, `?url`, `#fragment`) defeats a naive basename + // check — all must be rejected here or Vite inlines the real secret values into a built chunk. + test.each([ + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}`, ssr: false }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?raw`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?url`, ssr: false }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}#fragment`, ssr: true }, + { specifier: `../${CUSTOM_CREDENTIALS_LOCAL_FILENAME}?raw#fragment`, ssr: false }, + ])( + 'Should reject a direct import of the local Custom Credentials file (specifier: $specifier, ssr: $ssr)', + async ({ specifier, ssr }) => { + const plugin = getVitePlugin(defaultOptions); + const resolveIdHandler = getResolveIdHandler(plugin); + + await expect( + resolveIdHandler.call({ resolve: jest.fn() }, specifier, '/build/src/index.ts', { + ssr, + }), + ).rejects.toThrow(/cannot be imported directly/); + }, + ); + test('Should inject the apps runtime', () => { getVitePlugin(defaultOptions); @@ -676,16 +718,31 @@ describe('Backend Functions - getVitePlugin', () => { // module" for them — ssr.noExternal is what server.ssrLoadModule depends on to load them // correctly. const plugin = getVitePlugin(defaultOptions); - const configHook = plugin!.config as () => { ssr: { noExternal: string[] } }; + const configHook = getConfigHandler(plugin); const config = configHook(); expect(config).toEqual({ ssr: { noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], }, + server: { + fs: { + deny: expect.arrayContaining([CUSTOM_CREDENTIALS_LOCAL_FILENAME]), + }, + }, }); }); + // Regression test: a plugin's own server.fs.deny replaces Vite's defaults instead of merging, + // so .env/cert/.git protection must be preserved explicitly alongside this filename. + test("Should preserve Vite's default server.fs.deny patterns alongside the credentials filename", () => { + const plugin = getVitePlugin(defaultOptions); + const configHook = getConfigHandler(plugin); + const { deny } = configHook().server.fs; + + expect(deny).toEqual(expect.arrayContaining(VITE_DEFAULT_SERVER_FS_DENY)); + }); + // Uses the real configureServer hook, not createDevServerMiddleware directly, to catch mode-forwarding regressions. test('Should route /__dd/executeAction to the cloud path when configureServer sees a dev-verify server.config.mode', async () => { const plugin = getVitePlugin(defaultOptions); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index b832668c0..7e7992eef 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -33,6 +33,7 @@ import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; import { buildAppPackage } from './build-package'; +import { CUSTOM_CREDENTIALS_LOCAL_FILENAME } from './custom-credentials-resolver'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createDevServerMiddleware } from './dev-server'; import { localExecutionResolutionContext } from './local-execution'; @@ -98,6 +99,9 @@ function createBackendFunctionRegistry() { const APPS_RUNTIME_PATH = path.join(__dirname, './apps-runtime.mjs'); +// Not exported by Vite; mirrors its server.fs.deny default so it can be spread in below. +export const VITE_DEFAULT_SERVER_FS_DENY = ['.env', '.env.*', '*.{crt,pem}', '**/.git/**']; + /** * Returns the Vite-specific plugin hooks for the apps plugin. * @@ -142,6 +146,14 @@ export const getVitePlugin = ({ ssr: { noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], }, + // Vite replaces its whole server.fs.deny default rather than merging with a + // plugin's own list, so the defaults above must be spread in here or dev-server + // protection for .env/.git/certs silently disappears once this filename is added. + server: { + fs: { + deny: [...VITE_DEFAULT_SERVER_FS_DENY, CUSTOM_CREDENTIALS_LOCAL_FILENAME], + }, + }, }; }, // Propagates LOCAL_EXECUTION_LOAD_SUFFIX through the backend-file dependency graph so a @@ -153,6 +165,19 @@ export const getVitePlugin = ({ // first, short-circuiting the hook chain before this plugin ever sees it. order: 'pre', async handler(source, importer, resolveOptions) { + // Strips the query/hash suffix before comparing, matching Vite's own postfixRE — + // otherwise a `?raw`/`#fragment`-suffixed import bypasses this check and Vite + // inlines the real secret into a chunk build-package.ts's filter never sees. + const sourceWithoutPostfix = source.replace(/[?#].*$/, ''); + if ( + path.basename(sourceWithoutPostfix).toLowerCase() === + CUSTOM_CREDENTIALS_LOCAL_FILENAME + ) { + throw new Error( + `${CUSTOM_CREDENTIALS_LOCAL_FILENAME} cannot be imported directly — read Custom Credentials via process.env instead.`, + ); + } + // Top-level guard (not folded into each branch) so any future branch added below // inherits it automatically: local execution's traversal is always SSR, so without // this a client-mode resolution could inherit the marker and leak real backend code. diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts index ade8f5b0c..4a3482922 100644 --- a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts @@ -15,11 +15,14 @@ import { executeScriptLocally } from './local-execution'; describe('local-execution resilience (in-process execution known limitations)', () => { // A real `while (true) {}` would hang this test forever, since nothing — not even the timeout's // own callback — can run while the event loop is blocked synchronously. This bounded busy-wait - // proves the same point safely: the 20ms timeout can't interrupt it, so it settles at ~80ms. + // proves the same point safely: the timeout can't interrupt it, so it settles once the loop ends. + // Margins are generous because loadCustomerModuleEntry's Custom Credentials priming (a dynamic + // import plus a real fs read) runs before the loop starts with variable cold-start latency, and + // must stay under the timeout for this test to mean anything. test('Should NOT interrupt a synchronous CPU-bound loop with the current timeout — known, accepted v1 limitation', async () => { const resolver = moduleResolverFor(func, { example: () => { - const deadline = Date.now() + 80; + const deadline = Date.now() + 800; // eslint-disable-next-line no-empty while (Date.now() < deadline) {} return 'loop finished on its own'; @@ -36,13 +39,13 @@ describe('local-execution resilience (in-process execution known limitations)', stubGetRuntimeContext, resolver, mockLogger, - 20, + 300, ); const elapsedMs = Date.now() - start; expect(result).toEqual({ data: 'loop finished on its own' }); - expect(elapsedMs).toBeGreaterThanOrEqual(60); + expect(elapsedMs).toBeGreaterThanOrEqual(700); }); // process.exit() would kill this Jest process, so the fixture runs as its own real Jest process — diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index ba54c26d0..ed8307ee9 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -7,12 +7,16 @@ import type { Logger } from '@dd/core/types'; import { installFakeProcessEnv } from '@dd/tests/_jest/helpers/env'; import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; +import fsPromises from 'fs/promises'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import * as shared from '../backend/shared'; import type { BackendFunction } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import * as customCredentialsResolver from './custom-credentials-resolver'; import { forceResetEnv } from './env-guard'; import { func, @@ -25,6 +29,7 @@ import { DEFAULT_LONG_POLLING_CONFIG, DEFAULT_TIMEOUT_MS, deriveActionTimeouts, + executeColdActionLocally, executeScriptLocally as executeScriptLocallyWithRuntimeContext, } from './local-execution'; import { forceReset } from './network-guard'; @@ -33,6 +38,10 @@ const funcWithConnection: BackendFunction = { ...func, allowedConnectionIds: ['c const TEST_PROJECT_ROOT = '/project'; +// Captured before beforeEach's spyOn ever replaces the export — jest.requireActual would return +// this same, by-then-mocked module object instead of a real one, since it was never jest.mock()'d. +const realResolveCustomCredentials = customCredentialsResolver.resolveCustomCredentials; + interface TestGlobalDollar { backendFunctionArgs: unknown[]; // Left untyped: $.Actions is a Proxy of unbounded, dynamic depth ($.Actions.....(...)), the same shape a real customer's untyped code sees. @@ -50,6 +59,8 @@ beforeEach(() => { // Neither optional SDK is installed by default; tests exercising the "installed" path override this. jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); + // Real fs I/O races unpredictably against the fake-timer tests below. + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockResolvedValue({}); }); /** Keeps the existing test call sites concise while every invocation receives a fresh preview context. */ @@ -1182,6 +1193,89 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: 'ok' }); expect(envSeenDuringRegistration).toBeUndefined(); }); + + test('Should expose a value from a real datadog-app.local.json file as process.env in the customer function', async () => { + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockImplementation( + realResolveCustomCredentials, + ); + + const projectRoot = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'local-execution-custom-credentials-'), + ); + try { + await fsPromises.writeFile( + path.join( + projectRoot, + customCredentialsResolver.CUSTOM_CREDENTIALS_LOCAL_FILENAME, + ), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_123' }), + ); + + const result = await executeScriptLocally( + func, + projectRoot, + [], + stubExecuteAction, + loadModuleReturning({ example: () => process.env.STRIPE_API_KEY }), + mockLogger, + ); + + expect(result).toEqual({ data: 'sk_test_123' }); + } finally { + await fsPromises.rm(projectRoot, { recursive: true, force: true }); + } + }); + + // Regression coverage: executeColdActionLocally primes the module before runScriptLocally + // ever runs, so a customer module's own top-level code (e.g. `new Stripe(process.env.X)`) + // executes during the priming load, not during the later invocation-scope call above. + test('Should resolve real Custom Credentials for the priming load too, so module-top-level code sees real values', async () => { + jest.spyOn(customCredentialsResolver, 'resolveCustomCredentials').mockImplementation( + realResolveCustomCredentials, + ); + + const projectRoot = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'local-execution-custom-credentials-'), + ); + try { + await fsPromises.writeFile( + path.join( + projectRoot, + customCredentialsResolver.CUSTOM_CREDENTIALS_LOCAL_FILENAME, + ), + JSON.stringify({ STRIPE_API_KEY: 'sk_test_priming' }), + ); + + let capturedAtModuleLoad: string | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + capturedAtModuleLoad = process.env.STRIPE_API_KEY; + return { example: () => capturedAtModuleLoad }; + } + const error: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeColdActionLocally( + func, + projectRoot, + [], + stubExecuteAction, + stubGetRuntimeContext, + loadModule, + async () => [], + mockLogger, + ); + + expect(result).toEqual({ data: 'sk_test_priming' }); + expect(capturedAtModuleLoad).toBe('sk_test_priming'); + } finally { + await fsPromises.rm(projectRoot, { recursive: true, force: true }); + } + }); }); test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { @@ -2513,6 +2607,7 @@ describe('local-execution — executeScriptLocally', () => { const mod = await isolatedLoadCustomerModuleEntry( loadModule, func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + TEST_PROJECT_ROOT, ); expect(mod).toEqual({ example: expect.any(Function) }); }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index b5cfaa8af..b86c59f5b 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -15,6 +15,7 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; +import { resolveCustomCredentials } from './custom-credentials-resolver'; import type { EnvScopeHandle } from './env-guard'; import { createEpochGuard } from './execution-epoch'; import type { BlockedScopeHandle } from './network-guard'; @@ -229,19 +230,28 @@ export type LoadModule = (specifier: string) => Promise> /** * Loads a customer module under `runScriptLocally`'s top-level-evaluation `$`-scoping (see - * `customerModuleLoadContext`), for callers like dev-server.ts's priming load. Scopes `process.env` - * first so a dependency's top-level code can't capture the real `fs.readFileSync` and bypass the - * guard. Accepted residual gap: runs outside `runBlocked`, so top-level code still has real + * `customerModuleLoadContext`), for callers like dev-server.ts's priming load that trigger real + * top-level evaluation ahead of `executeScriptLocally`. Scopes `process.env` and the network guard + * first so a dependency's top-level code can't capture the real, unwrapped `fs.readFileSync`/network + * APIs and bypass the guard for the rest of the session. Resolves real Custom Credentials via + * `projectRoot` too — module-scope SDK initialization (`new Stripe(process.env.X)`) would otherwise + * always capture `undefined`. `onScopeStarted` lets a caller record this load's abandon token so a + * hung load can be abandoned without affecting any other execution's still-active scope. Accepted + * residual gap: this load still runs outside `runBlocked`, so top-level code has real, unguarded * network/subprocess access — not a hard boundary, matching this file's "no OS sandbox" framing. */ export async function loadCustomerModuleEntry( loadModule: LoadModule, entrySpecifier: string, + projectRoot: string, onScopeStarted?: (handle: EnvScopeHandle) => void, ): Promise> { - await getNetworkGuard(); - const { buildScopedEnv, runWithScopedEnv } = await getEnvGuard(); - const scopedEnv = buildScopedEnv({}); + const [{ buildScopedEnv, runWithScopedEnv }, customCredentials] = await Promise.all([ + getEnvGuard(), + resolveCustomCredentials(projectRoot), + getNetworkGuard(), + ]); + const scopedEnv = buildScopedEnv(customCredentials); return localExecutionResolutionContext.run(new Set(), () => customerModuleLoadContext.run({ assigned: false, value: undefined }, () => runWithScopedEnv(scopedEnv, () => loadModule(entrySpecifier), onScopeStarted), @@ -716,9 +726,14 @@ export async function executeColdActionLocally( ); const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; let primingEnvScope: EnvScopeHandle | undefined; - const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier, (handle) => { - primingEnvScope = handle; - }); + const primingPromise = loadCustomerModuleEntry( + loadModule, + entrySpecifier, + projectRoot, + (handle) => { + primingEnvScope = handle; + }, + ); let primedEntry: Record | undefined; try { primedEntry = await withTimeout(primingPromise, timeoutMs, `Loading "${displayName}"`); @@ -868,6 +883,7 @@ async function runScriptLocally( (await loadCustomerModuleEntry( loadModule, func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + projectRoot, )); const fn = mod[func.name]; if (typeof fn !== 'function') { @@ -901,10 +917,18 @@ async function runScriptLocally( // toJSON()/getter must run while access is still blocked/scoped. const networkGuardPromise = getNetworkGuard(); const envGuardPromise = getEnvGuard(); - const [{ runBlocked }, { buildScopedEnv, runWithScopedEnv }] = - await Promise.all([networkGuardPromise, envGuardPromise]); + const customCredentialsPromise = resolveCustomCredentials(projectRoot); + const [ + { runBlocked }, + { buildScopedEnv, runWithScopedEnv }, + customCredentials, + ] = await Promise.all([ + networkGuardPromise, + envGuardPromise, + customCredentialsPromise, + ]); rejectIfAbandoned(); - const scopedEnv = buildScopedEnv({}); + const scopedEnv = buildScopedEnv(customCredentials); const data = await runWithScopedEnv( scopedEnv, () =>