Skip to content
Open
11 changes: 11 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned
<!-- #toc -->
- [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)
Expand Down Expand Up @@ -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.
Comment thread
tyffical marked this conversation as resolved.
Comment thread
tyffical marked this conversation as resolved.

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.
Expand Down
135 changes: 135 additions & 0 deletions packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());

Expand Down
74 changes: 68 additions & 6 deletions packages/plugins/apps/src/vite/build-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,13 +19,61 @@ 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<string, string>;
backendFunctions: BackendFunction[];
context: GlobalContext;
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<FileIdentity | undefined> {
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<boolean> {
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) {
Expand Down Expand Up @@ -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({
Expand Down
119 changes: 119 additions & 0 deletions packages/plugins/apps/src/vite/custom-credentials-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading