diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 2a737b1e2..2b520d481 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -32,13 +32,14 @@ "dependencies": { "@dd/core": "workspace:*", "chalk": "2.3.1", + "estree-walker": "2.0.2", "glob": "11.1.0", "jszip": "3.10.1", - "pretty-bytes": "5.6.0" + "pretty-bytes": "5.6.0", + "vite": "6.3.5" }, "devDependencies": { "@types/estree": "1.0.8", - "typescript": "5.4.3", - "vite": "6.3.5" + "typescript": "5.4.3" } } diff --git a/packages/plugins/apps/src/backend/discovery.ts b/packages/plugins/apps/src/backend/discovery.ts index f489d1480..e776911e1 100644 --- a/packages/plugins/apps/src/backend/discovery.ts +++ b/packages/plugins/apps/src/backend/discovery.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Declaration, Expression, Program } from 'estree'; +import type { Declaration, Expression, Node, Program } from 'estree'; import type { AstNode } from 'rollup'; export interface BackendFunction { @@ -12,34 +12,60 @@ export interface BackendFunction { name: string; /** Absolute path to the .backend.ts source file */ absolutePath: string; + /** Connection IDs statically extracted from `request({ connectionId })` call sites. */ + allowedConnectionIds: string[]; } /** - * Extract exported value (non-type) symbols from an ESTree AST. - * Expects plain JavaScript — TypeScript types must already be stripped - * (e.g. by Vite's built-in esbuild transform that runs before our hook). + * Describes a single named export from a backend file, with enough information + * to locate the function body (for static analysis) or identify a re-exported + * import (whose body lives in another module). * - * Throws on invalid exports (e.g. default exports) and unexpected AST shapes. - * Returns an empty array when the file has no named exports. - * - * @param ast - AstNode from `this.parse()` in unplugin's transform hook - * @param filePath - Path to the source file (used in error messages) + * The two `body`-carrying variants differ only in origin — both resolve to a + * function body we can scan in this file — so they share a single kind. + * `imported` has no locally-visible body; callers must handle that case. */ -function isProgramNode(node: AstNode): node is AstNode & Program { - return node.type === 'Program'; -} +export type ExportedBinding = + | { + /** `export function foo() {}` or `function foo() {}; export { foo }` or arrow-const equivalents */ + kind: 'local'; + name: string; + body: Node; + } + | { + /** `import { foo } from './x'; export { foo }` — body is in another module */ + kind: 'imported'; + name: string; + /** Module specifier, e.g. `'./handlers'` */ + source: string; + /** The remote name being imported (may differ from `name` when aliased) */ + imported: string; + }; -export function extractExportedFunctions(ast: AstNode, filePath: string): string[] { +/** + * Enumerate every named export in a backend file along with the information + * needed to locate its implementation. Validates that each export is function-like + * and rejects unsupported shapes (default exports, `export *`, class exports, + * non-callable variable exports, destructured exports). + * + * Expects plain JavaScript — TypeScript types must already be stripped. + * + * This is the single source of truth for "what backend export shapes are + * supported." Both name discovery and connection-id extraction consume it so + * support for a new shape only has to be added here. + */ +export function enumerateBackendExports(ast: AstNode, filePath: string): ExportedBinding[] { if (!isProgramNode(ast)) { throw new Error( `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, ); } - // Build a map of top-level declarations so we can validate export specifiers. + // Map of top-level declarations keyed by local name, used both to validate + // specifier exports and to locate bodies for `function foo(){}; export { foo }`. const declarations = buildDeclarationMap(ast); - const names: string[] = []; + const bindings: ExportedBinding[] = []; for (const node of ast.body) { // handles: export default ... if (node.type === 'ExportDefaultDeclaration') { @@ -59,7 +85,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string // handles: export function add() {} / export const add = ... if (node.declaration) { - names.push(...namesFromDeclaration(node.declaration, filePath)); + bindings.push(...bindingsFromDeclaration(node.declaration, filePath)); } for (const spec of node.specifiers) { @@ -72,17 +98,39 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string `Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`, ); } - // Validate specifier binding is callable when we can resolve it. - // e.g. `const VERSION = '1.0'; export { VERSION };` — rejected - // e.g. `function add() {}; export { add };` — allowed - if (spec.local.type === 'Identifier') { - validateSpecifierBinding(spec.local.name, declarations, filePath); + if (spec.local.type !== 'Identifier') { + continue; + } + // Re-export from another module: `export { X } from './foo'` / `export { Y as X } from './foo'`. + // Not currently surfaced as a separate kind — treat as imported so consumers can log + // and skip. If we ever need to distinguish, add a new kind here. + if (node.source && typeof node.source.value === 'string') { + bindings.push({ + kind: 'imported', + name: spec.exported.name, + source: node.source.value, + imported: spec.local.name, + }); + continue; } - // handles: export { add, multiply } - names.push(spec.exported.name); + const info = declarations.get(spec.local.name); + validateSpecifierBinding(spec.local.name, info, filePath); + bindings.push(bindingFromDeclInfo(spec.exported.name, spec.local.name, info)); } } - return names; + return bindings; +} + +/** + * Back-compat name-only view used by callers that just want the list of + * exported names (e.g. proxy codegen, logging). + */ +export function extractExportedFunctions(ast: AstNode, filePath: string): string[] { + return enumerateBackendExports(ast, filePath).map((b) => b.name); +} + +function isProgramNode(node: AstNode): node is AstNode & Program { + return node.type === 'Program'; } /** Init types that are definitively non-callable at runtime. */ @@ -104,14 +152,14 @@ function isNonCallableInit(init: Expression | null | undefined): boolean { } /** - * Extract identifier names from an exported declaration node. + * Extract bindings from an exported declaration node. * Handles `export function foo()` and `export const foo = ...` forms. * Throws when a variable export has a non-callable initializer. */ -function namesFromDeclaration(decl: Declaration, filePath: string): string[] { +function bindingsFromDeclaration(decl: Declaration, filePath: string): ExportedBinding[] { // export function add(a, b) { return a + b; } if (decl.type === 'FunctionDeclaration' && decl.id) { - return [decl.id.name]; + return [{ kind: 'local', name: decl.id.name, body: decl.body }]; } // export class MyClass {} — classes are not callable as RPC endpoints if (decl.type === 'ClassDeclaration') { @@ -120,7 +168,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] { ); } if (decl.type === 'VariableDeclaration') { - return decl.declarations.flatMap((d) => { + return decl.declarations.flatMap((d): ExportedBinding[] => { // export const { a, b } = obj; // export const [a, b] = arr; if (d.id.type !== 'Identifier') { @@ -136,8 +184,34 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] { ); } // export const add = (a, b) => a + b; - // export const handler = importedFn; — ambiguous, allowed - return [d.id.name]; + if ( + d.init && + (d.init.type === 'ArrowFunctionExpression' || d.init.type === 'FunctionExpression') + ) { + return [{ kind: 'local', name: d.id.name, body: d.init.body }]; + } + // export const handler = importedFn; — ambiguous, conservatively treat as imported + // so connection-id extraction can skip/log without failing the build. + if (d.init && d.init.type === 'Identifier') { + return [ + { + kind: 'imported', + name: d.id.name, + // We don't know the original source here; leaving blank signals "local relay". + source: '', + imported: d.init.name, + }, + ]; + } + // Other ambiguous forms (CallExpression, etc.): treat as imported/opaque. + return [ + { + kind: 'imported', + name: d.id.name, + source: '', + imported: d.id.name, + }, + ]; }); } throw new Error( @@ -151,19 +225,22 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] { * 'class' is rejected. 'variable' is checked via its initializer. */ type DeclInfo = - | { kind: 'function' | 'import' | 'class' } - | { kind: 'variable'; init: Expression | null | undefined }; + | { kind: 'function'; body: Node } + | { kind: 'class' } + | { kind: 'variable'; init: Expression | null | undefined } + | { kind: 'import'; source: string; imported: string }; /** * Build a map from identifier name → declaration info for all top-level - * statements. Used to validate `export { name }` specifiers. + * statements. Used both to validate `export { name }` specifiers and to + * locate function bodies for specifier-form exports. */ function buildDeclarationMap(ast: Program): Map { const map = new Map(); for (const node of ast.body) { if (node.type === 'FunctionDeclaration' && node.id) { // handles: function add(a, b) { return a + b; } - map.set(node.id.name, { kind: 'function' }); + map.set(node.id.name, { kind: 'function', body: node.body }); } else if (node.type === 'ClassDeclaration' && node.id) { // handles: class MyService {} map.set(node.id.name, { kind: 'class' }); @@ -174,11 +251,25 @@ function buildDeclarationMap(ast: Program): Map { map.set(d.id.name, { kind: 'variable', init: d.init }); } } - } else if (node.type === 'ImportDeclaration') { + } else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { // handles: import { handler } from './other'; - // For this case, we allow exporting handler and accept that it may not be a function. + const source = node.source.value; for (const spec of node.specifiers) { - map.set(spec.local.name, { kind: 'import' }); + if (spec.type === 'ImportSpecifier') { + const imported = + spec.imported.type === 'Identifier' + ? spec.imported.name + : String(spec.imported.value); + map.set(spec.local.name, { kind: 'import', source, imported }); + } else { + // Default / namespace imports: we allow exporting them but can't + // statically locate a function body. + map.set(spec.local.name, { + kind: 'import', + source, + imported: spec.local.name, + }); + } } } } @@ -192,10 +283,9 @@ function buildDeclarationMap(ast: Program): Map { */ function validateSpecifierBinding( localName: string, - declarations: Map, + info: DeclInfo | undefined, filePath: string, ): void { - const info = declarations.get(localName); if (!info) { // Unresolved — could come from a pattern we don't track. Allow it. return; @@ -211,3 +301,43 @@ function validateSpecifierBinding( ); } } + +/** + * Turn a resolved specifier binding into an {@link ExportedBinding}. Called + * after {@link validateSpecifierBinding}, so opaque/rejected shapes won't + * reach this point. + */ +function bindingFromDeclInfo( + exportedName: string, + localName: string, + info: DeclInfo | undefined, +): ExportedBinding { + if (info?.kind === 'function') { + return { kind: 'local', name: exportedName, body: info.body }; + } + if (info?.kind === 'variable' && info.init) { + if ( + info.init.type === 'ArrowFunctionExpression' || + info.init.type === 'FunctionExpression' + ) { + return { kind: 'local', name: exportedName, body: info.init.body }; + } + } + if (info?.kind === 'import') { + return { + kind: 'imported', + name: exportedName, + source: info.source, + imported: info.imported, + }; + } + // Ambiguous (e.g. `const handler = someCall(); export { handler }`) or a + // binding we can't resolve — treat as imported/opaque so connection-id + // extraction skips it rather than failing the build. + return { + kind: 'imported', + name: exportedName, + source: '', + imported: localName, + }; +} diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts new file mode 100644 index 000000000..a668abd4b --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -0,0 +1,719 @@ +// 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 { extractConnectionIds } from '@dd/apps-plugin/backend/extract-connection-ids'; +import { parse } from 'acorn'; +import type { AstNode, PluginContext } from 'rollup'; + +/** + * Build a mock PluginContext that can parse code and resolve/load files from + * an in-memory file map. Enough surface to exercise the extractor without + * starting a real Rollup build. + */ +function createCtx(files: Record): PluginContext { + const ctx = { + parse: (code: string): AstNode => + parse(code, { ecmaVersion: 2022, sourceType: 'module', locations: true }) as AstNode, + resolve: async (source: string, importer?: string) => { + const resolvedId = resolveSimple(source, importer, files); + if (!resolvedId) { + return null; + } + return { id: resolvedId, external: false }; + }, + load: async ({ id }: { id: string }) => { + if (!(id in files)) { + throw new Error(`mock load: no file ${id}`); + } + return { id, code: files[id], ast: null }; + }, + debug: (_msg: string) => { + /* no-op for tests */ + }, + }; + return ctx as unknown as PluginContext; +} + +function resolveSimple( + source: string, + importer: string | undefined, + files: Record, +): string | null { + if (!importer) { + return source in files ? source : null; + } + if (source.startsWith('/') && source in files) { + return source; + } + + const base = importer.replace(/\/[^/]+$/, ''); + const joined = source.startsWith('.') ? `${base}/${source.replace(/^\.\//, '')}` : source; + // Normalise `../` segments. + const parts = joined.split('/'); + const out: string[] = []; + for (const p of parts) { + if (p === '..') { + out.pop(); + } else if (p !== '.' && p !== '') { + out.push(p); + } + } + const candidate = `/${out.join('/')}`; + if (candidate in files) { + return candidate; + } + for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) { + if (`${candidate}${ext}` in files) { + return `${candidate}${ext}`; + } + } + return null; +} + +/** Standard action-catalog import prepended to fixtures so `request(…)` is recognised. */ +const CATALOG_IMPORT = `import { request } from '@datadog/action-catalog/http/http';\n`; + +function run(files: Record, entry: string) { + const ctx = createCtx(files); + const ast = ctx.parse(files[entry]); + return extractConnectionIds(ctx, ast, entry); +} + +describe('extractConnectionIds', () => { + describe('inline literals', () => { + test('extracts a string-literal connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: 'abc-123', url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('extracts a plain template literal connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: \`abc-123\`, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('dedupes repeated IDs and sorts', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: 'b', url: '/x' }); + request({ connectionId: 'a', url: '/y' }); + request({ connectionId: 'a', url: '/z' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['a', 'b']); + }); + }); + + describe('same-file consts', () => { + test('resolves const to a string literal', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const CONNECTION_ID = 'xyz-1'; + export function foo() { + request({ connectionId: CONNECTION_ID, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['xyz-1']); + }); + + test('resolves const to a plain template literal', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const CONNECTION_ID = \`xyz-1\`; + export function foo() { + request({ connectionId: CONNECTION_ID, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['xyz-1']); + }); + + test('resolves const-through-const chain', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const A = 'deep'; + const B = A; + export function foo() { request({ connectionId: B }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['deep']); + }); + }); + + describe('specifier exports', () => { + test('resolves `function foo(){}; export { foo }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function foo() { request({ connectionId: 'abc-123' }); } + export { foo }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('resolves `const foo = () => {}; export { foo }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const foo = () => { request({ connectionId: 'abc-123' }); }; + export { foo }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('resolves `export { foo as bar }` (alias)', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function foo() { request({ connectionId: 'abc-123' }); } + export { foo as bar }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('bar')).toEqual(['abc-123']); + }); + + test('traverses `import { handler } from "./x"; export { handler }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { handler } from './handlers'; + export { handler }; + `, + '/app/handlers.ts': `${CATALOG_IMPORT} + export function handler() { request({ connectionId: 'abc-123' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('handler')).toEqual(['abc-123']); + }); + + test('traverses `export { X } from "./x"` re-exports', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export { handler } from './handlers'; + `, + '/app/handlers.ts': `${CATALOG_IMPORT} + export function handler() { request({ connectionId: 'abc-123' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('handler')).toEqual(['abc-123']); + }); + }); + + describe('imported consts — transitive', () => { + test('resolves `export const` in a sibling file', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './constants'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/constants.ts': `export const CONN = 'imported-1';`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['imported-1']); + }); + + test('resolves through a barrel re-export', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': `export { CONN } from './real';`, + '/app/real.ts': `export const CONN = 'barrelled';`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['barrelled']); + }); + + test('resolves through `export { X as Y } from`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': `export { INNER as CONN } from './real';`, + '/app/real.ts': `export const INNER = 'renamed';`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['renamed']); + }); + + test('resolves through `import { X } from; export { X }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './mid'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/mid.ts': ` + import { CONN } from './real'; + export { CONN }; + `, + '/app/real.ts': `export const CONN = 'relayed';`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['relayed']); + }); + + test('resolves via `export * from`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': ` + export * from './other'; + export * from './real'; + `, + '/app/other.ts': `export const UNUSED = 'u';`, + '/app/real.ts': `export const CONN = 'star';`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['star']); + }); + + test('throws on cyclic re-export chain', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './a'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/a.ts': `export { CONN } from './b';`, + '/app/b.ts': `export { CONN } from './a';`, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/cyclic re-export or import chain/); + }); + + test('throws with clear message when export not found', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './constants'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/constants.ts': `export const OTHER = 'x';`, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/export 'CONN' not found/); + }); + }); + + describe('callee scoping', () => { + test('ignores `connectionId` passed to non-action-catalog callees', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { logger } from './logger'; + export function foo() { + logger.info({ connectionId: process.env.WHATEVER }); + request({ connectionId: 'abc-123' }); + } + `, + '/app/logger.ts': `export const logger = { info: () => {} };`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('recognises namespace-imported action-catalog calls', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import * as http from '@datadog/action-catalog/http/http'; + export function foo() { + http.request({ connectionId: 'abc-123' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('recognises default-imported action-catalog calls', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import request from '@datadog/action-catalog/http/http'; + export function foo() { + request({ connectionId: 'abc-123' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('ignores a locally-defined function with the same name as a catalog call', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + function request(_opts) {} + export function foo() { + request({ connectionId: process.env.WHATEVER }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual([]); + }); + }); + + describe('unresolvable forms throw', () => { + test('dynamic template literal', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const prefix = 'a'; + export function foo() { + request({ connectionId: \`\${prefix}-b\` }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must not contain interpolations/); + }); + + test('concatenation', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: 'a' + 'b' }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must be a static string/); + }); + + test('env var (member expression)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: process.env.CONN }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/member expressions must read from a const object/); + }); + + test('function call', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: getConn() }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must be a static string/); + }); + + test('undefined identifier', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { + request({ connectionId: MYSTERY }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/not defined .* and is not imported/); + }); + + test('let binding (reassignable)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + let CONN = 'initial'; + export function foo() { + request({ connectionId: CONN }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'let'/); + }); + + test('var binding (reassignable)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + var CONN = 'initial'; + export function foo() { + request({ connectionId: CONN }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'var'/); + }); + + test('imported let binding (from another file)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './mutable'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/mutable.ts': `export let CONN = 'initial';`, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'let'/); + }); + }); + + describe('multiple exports', () => { + test('applies the file-level module graph allowlist to every export', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const A = 'aaa'; + export function foo() { request({ connectionId: A }); } + export function bar() { request({ connectionId: 'bbb' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['aaa', 'bbb']); + expect(result.get('bar')).toEqual(['aaa', 'bbb']); + }); + }); + + describe('reachable module graph', () => { + test('includes action calls inside same-file helpers', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function helper() { request({ connectionId: 'helper-id' }); } + export function foo() { return helper(); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['helper-id']); + }); + + test('includes action calls inside imported helpers', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { getHosts } from './helpers/hosts'; + export function foo() { return getHosts(); } + `, + '/app/helpers/hosts.ts': `${CATALOG_IMPORT} + export function getHosts() { + return request({ connectionId: 'helper-id' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['helper-id']); + }); + + test('includes action calls from static re-export sources', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export { getHosts } from './helpers'; + export function foo() {} + `, + '/app/helpers.ts': ` + export { getHosts } from './real-helper'; + `, + '/app/real-helper.ts': `${CATALOG_IMPORT} + export function getHosts() { + return request({ connectionId: 'reexport-helper-id' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['reexport-helper-id']); + expect(result.get('getHosts')).toEqual(['reexport-helper-id']); + }); + + test('resolves imported CONNECTIONS object member access', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { getHosts } from './helpers/hosts'; + export function foo() { return getHosts(); } + `, + '/app/helpers/hosts.ts': `${CATALOG_IMPORT} + import { CONNECTIONS } from '../connections'; + export function getHosts() { + return request({ connectionId: CONNECTIONS.DD }); + } + `, + '/app/connections.ts': ` + export const CONNECTIONS = { + DD: 'dd-connection', + OTHER: 'other', + }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['dd-connection']); + }); + + test('handles cycles in the reachable module graph', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { helper } from './a'; + export function foo() { return helper(); } + `, + '/app/a.ts': ` + import './b'; + export function helper() {} + `, + '/app/b.ts': ` + import './a'; + ${CATALOG_IMPORT} + request({ connectionId: 'cycle-id' }); + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['cycle-id']); + }); + + test('rejects dynamic local imports', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export async function foo() { + await import('./helpers'); + } + `, + '/app/helpers.ts': `${CATALOG_IMPORT} + request({ connectionId: 'hidden' }); + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/dynamic import of local module/); + }); + + test('rejects local require calls', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + require('./helpers'); + } + `, + '/app/helpers.ts': `${CATALOG_IMPORT} + request({ connectionId: 'hidden' }); + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/require of local module/); + }); + }); + + describe('no connectionId', () => { + test('returns empty list when the export never mentions connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + export function foo() { request({ url: '/x' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual([]); + }); + }); +}); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts new file mode 100644 index 000000000..8b61c20ab --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -0,0 +1,806 @@ +// 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 { walk } from 'estree-walker'; +import type { + CallExpression, + ExportNamedDeclaration, + Expression, + ImportDeclaration, + ImportSpecifier, + MemberExpression, + Node, + ObjectExpression, + Program, + Property, + TemplateLiteral, +} from 'estree'; +import fsp from 'fs/promises'; +import path from 'path'; +import type { AstNode, PluginContext } from 'rollup'; +import { transformWithEsbuild } from 'vite'; + +import { enumerateBackendExports } from './discovery'; + +const MAX_HOPS = 32; +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +const GENERATED_SEGMENT_RE = /[/\\](?:dist|build|\.vite)(?:[/\\]|$)/; + +type MutableKind = 'let' | 'var'; + +type ImportBinding = { + source: string; + imported: string; +}; + +type LocalSymbols = { + localConsts: Map; + localMutables: Map; + importBindings: Map; + namespaceImports: Map; + actionFunctions: Set; + actionNamespaces: Set; +}; + +type ParsedModule = { + id: string; + ast: Program; + symbols: LocalSymbols; +}; + +type ResolutionState = { + visited: Set; + hops: number; + originFile: string; + label: string; +}; + +type ConnectionIdCallSite = { + module: ParsedModule; + valueNode: Expression; + loc: Node['loc']; +}; + +class ExtractionError extends Error {} + +class ExportNotFoundError extends ExtractionError {} + +/** + * Statically extract every action-catalog `connectionId` used by the local + * module graph reachable from one `*.backend.*` entry file. + * + * The result is intentionally file-level: every supported backend export in the + * entry file receives the same sorted allowlist. This mirrors the conservative + * module-graph design: if a reachable helper module contains an action call, any + * export from the backend entry may be able to reach it at runtime. + */ +export async function extractConnectionIds( + ctx: PluginContext, + ast: AstNode, + filePath: string, + buildRoot = path.dirname(filePath), +): Promise> { + const bindings = enumerateBackendExports(ast, filePath); + if (!isProgram(ast)) { + throw new Error(`Expected a Program node from this.parse() for ${filePath}`); + } + + const modules = await buildReachableModuleGraph(ctx, ast, filePath, buildRoot); + const ids = new Set(); + + for (const mod of modules) { + for (const callSite of findConnectionIdCallSites(mod)) { + ids.add( + await resolveValue(ctx, callSite.valueNode, callSite.module, { + visited: new Set(), + hops: 0, + originFile: filePath, + label: 'module graph', + }), + ); + } + } + + const sortedIds = [...ids].sort(); + return new Map(bindings.map((binding) => [binding.name, sortedIds])); +} + +function isProgram(node: AstNode): node is AstNode & Program { + return node.type === 'Program'; +} + +function isMutableKind(kind: string): kind is MutableKind { + return kind === 'let' || kind === 'var'; +} + +function isTypeOnlyImport(node: ImportDeclaration): boolean { + return (node as ImportDeclaration & { importKind?: string }).importKind === 'type'; +} + +function isTypeOnlyImportSpecifier(node: ImportSpecifier): boolean { + return (node as ImportSpecifier & { importKind?: string }).importKind === 'type'; +} + +function isTypeOnlyExport(node: ExportNamedDeclaration): boolean { + return (node as ExportNamedDeclaration & { exportKind?: string }).exportKind === 'type'; +} + +function isActionCatalogSource(source: string): boolean { + return source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`); +} + +function isLocalSourceSpecifier(source: string): boolean { + return source.startsWith('.') || source.startsWith('/'); +} + +function stripQuery(id: string): string { + return id.replace(/\?.*$/, ''); +} + +function toPosix(id: string): string { + return id.split(path.sep).join('/'); +} + +function isInsideBuildRoot(id: string, buildRoot: string): boolean { + const rel = path.relative(buildRoot, stripQuery(id)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function isGeneratedOutput(id: string): boolean { + return GENERATED_SEGMENT_RE.test(stripQuery(id)); +} + +function shouldTraverseResolvedId(id: string, buildRoot: string): boolean { + const cleanId = stripQuery(id); + if (cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\')) { + return false; + } + if (cleanId.startsWith('\0')) { + return false; + } + if (!isInsideBuildRoot(cleanId, buildRoot)) { + return false; + } + return !isGeneratedOutput(cleanId); +} + +function buildSymbolTable(ast: Program): LocalSymbols { + const localConsts = new Map(); + const localMutables = new Map(); + const importBindings = new Map(); + const namespaceImports = new Map(); + const actionFunctions = new Set(); + const actionNamespaces = new Set(); + + const recordVariableDeclaration = (decl: { + kind: string; + declarations: Array<{ + id: Node; + init?: Expression | null; + }>; + }): void => { + for (const d of decl.declarations) { + if (d.id.type !== 'Identifier') { + continue; + } + if (decl.kind === 'const' && d.init) { + localConsts.set(d.id.name, d.init); + } else if (isMutableKind(decl.kind)) { + localMutables.set(d.id.name, { kind: decl.kind, loc: d.id.loc ?? null }); + } + } + }; + + for (const node of ast.body) { + if (node.type === 'VariableDeclaration') { + recordVariableDeclaration(node); + } else if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + recordVariableDeclaration(node.declaration); + } else if ( + node.type === 'ImportDeclaration' && + !isTypeOnlyImport(node) && + typeof node.source.value === 'string' + ) { + const source = node.source.value; + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier') { + if (isTypeOnlyImportSpecifier(spec)) { + continue; + } + const imported = + spec.imported.type === 'Identifier' + ? spec.imported.name + : String(spec.imported.value); + if (isActionCatalogSource(source)) { + actionFunctions.add(spec.local.name); + } else { + importBindings.set(spec.local.name, { source, imported }); + } + } else if (spec.type === 'ImportDefaultSpecifier') { + if (isActionCatalogSource(source)) { + actionFunctions.add(spec.local.name); + } else { + importBindings.set(spec.local.name, { source, imported: 'default' }); + } + } else if (spec.type === 'ImportNamespaceSpecifier') { + if (isActionCatalogSource(source)) { + actionNamespaces.add(spec.local.name); + } else { + namespaceImports.set(spec.local.name, source); + } + } + } + } + } + + return { + localConsts, + localMutables, + importBindings, + namespaceImports, + actionFunctions, + actionNamespaces, + }; +} + +async function buildReachableModuleGraph( + ctx: PluginContext, + entryAst: Program, + entryId: string, + buildRoot: string, +): Promise { + const normalizedBuildRoot = stripQuery(buildRoot); + const cache = new Map(); + const ordered: ParsedModule[] = []; + const queue: ParsedModule[] = []; + + const entry = makeParsedModule(entryId, entryAst); + cache.set(entryId, entry); + ordered.push(entry); + queue.push(entry); + + for (let i = 0; i < queue.length; i += 1) { + const mod = queue[i]; + assertNoUnsupportedDynamicLocalDependencies(mod); + + for (const source of collectStaticDependencySpecifiers(mod.ast)) { + if (isActionCatalogSource(source)) { + continue; + } + const resolvedId = await resolveModuleId(ctx, mod.id, source, { + required: isLocalSourceSpecifier(source), + }); + if (!resolvedId || !shouldTraverseResolvedId(resolvedId, normalizedBuildRoot)) { + continue; + } + if (cache.has(resolvedId)) { + continue; + } + const loaded = await loadParsedModule(ctx, resolvedId); + cache.set(resolvedId, loaded); + ordered.push(loaded); + queue.push(loaded); + } + } + + return ordered; +} + +function makeParsedModule(id: string, ast: Program): ParsedModule { + return { id: toPosix(id), ast, symbols: buildSymbolTable(ast) }; +} + +function collectStaticDependencySpecifiers(ast: Program): string[] { + const sources: string[] = []; + for (const node of ast.body) { + if ( + node.type === 'ImportDeclaration' && + !isTypeOnlyImport(node) && + typeof node.source.value === 'string' + ) { + sources.push(node.source.value); + } else if ( + node.type === 'ExportNamedDeclaration' && + !isTypeOnlyExport(node) && + node.source && + typeof node.source.value === 'string' + ) { + sources.push(node.source.value); + } else if (node.type === 'ExportAllDeclaration' && typeof node.source.value === 'string') { + sources.push(node.source.value); + } + } + return sources; +} + +async function resolveModuleId( + ctx: PluginContext, + importer: string, + source: string, + opts: { required: boolean }, +): Promise { + const resolved = await ctx.resolve(source, importer, { skipSelf: false }); + if (!resolved || resolved.external) { + if (opts.required) { + throw new ExtractionError( + `[connectionId manifest] could not resolve local module '${source}' imported from ${importer}`, + ); + } + return undefined; + } + return toPosix(resolved.id); +} + +async function loadParsedModule(ctx: PluginContext, id: string): Promise { + const { code, ast: loadedAst } = await loadModule(ctx, id); + if (code === null || code === undefined) { + throw new ExtractionError( + `[connectionId manifest] module '${id}' produced no code during module graph analysis`, + ); + } + const ast = (loadedAst || ctx.parse(code)) as unknown as Program; + return makeParsedModule(id, ast); +} + +async function loadModule( + ctx: PluginContext, + id: string, +): Promise<{ code: string | null | undefined; ast?: AstNode | null }> { + try { + const loaded = await ctx.load({ id }); + if (typeof loaded === 'string') { + return { code: loaded, ast: null }; + } + return { code: loaded.code, ast: loaded.ast }; + } catch (error) { + if (!isUnsupportedModuleInfoCodeError(error)) { + throw error; + } + } + + const source = await fsp.readFile(stripQuery(id), 'utf8'); + const transformed = await transformWithEsbuild(source, stripQuery(id), { + loader: getEsbuildLoader(id), + sourcemap: false, + target: 'esnext', + }); + return { code: transformed.code, ast: null }; +} + +function isUnsupportedModuleInfoCodeError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes('The "code" property of ModuleInfo is not supported') + ); +} + +function getEsbuildLoader(id: string): 'js' | 'jsx' | 'ts' | 'tsx' { + const ext = path.extname(stripQuery(id)); + if (ext === '.tsx') { + return 'tsx'; + } + if (ext === '.ts' || ext === '.mts' || ext === '.cts') { + return 'ts'; + } + if (ext === '.jsx') { + return 'jsx'; + } + return 'js'; +} + +function assertNoUnsupportedDynamicLocalDependencies(mod: ParsedModule): void { + walk(mod.ast, { + enter(node) { + if (isDynamicImportExpression(node)) { + const source = node.source; + if (!source || source.type !== 'Literal' || typeof source.value !== 'string') { + failBase( + `dynamic import in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + if (isLocalSourceSpecifier(source.value)) { + failBase( + `dynamic import of local module '${source.value}' in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + } + if (node.type === 'CallExpression' && isRequireCall(node)) { + const source = node.arguments[0]; + if (!source || source.type !== 'Literal' || typeof source.value !== 'string') { + failBase( + `dynamic require in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + if (isLocalSourceSpecifier(source.value)) { + failBase( + `require of local module '${source.value}' in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + } + }, + }); +} + +function isDynamicImportExpression(node: Node): node is Node & { source?: Expression } { + return (node as { type: string }).type === 'ImportExpression'; +} + +function isRequireCall(node: CallExpression): boolean { + return ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length > 0 + ); +} + +function findConnectionIdCallSites(mod: ParsedModule): ConnectionIdCallSite[] { + const callSites: ConnectionIdCallSite[] = []; + walk(mod.ast, { + enter(node) { + if ( + node.type !== 'CallExpression' || + !isActionCatalogCallee(node.callee, mod.symbols) + ) { + return; + } + const firstArg = node.arguments[0]; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + return; + } + const prop = findConnectionIdProp(firstArg); + if (!prop) { + return; + } + callSites.push({ + module: mod, + valueNode: prop.value as Expression, + loc: prop.loc ?? null, + }); + }, + }); + return callSites; +} + +function isActionCatalogCallee(callee: Node, symbols: LocalSymbols): boolean { + if (callee.type === 'Identifier') { + return symbols.actionFunctions.has(callee.name); + } + if (callee.type !== 'MemberExpression') { + return false; + } + const root = getMemberRoot(callee); + return root ? symbols.actionNamespaces.has(root.name) : false; +} + +function getMemberRoot(member: MemberExpression): { name: string } | undefined { + let current = member.object; + while (current.type === 'MemberExpression') { + current = current.object; + } + return current.type === 'Identifier' ? current : undefined; +} + +function findConnectionIdProp(obj: ObjectExpression): Property | undefined { + for (const prop of obj.properties) { + if (prop.type !== 'Property' || prop.computed) { + continue; + } + if (prop.key.type === 'Identifier' && prop.key.name === 'connectionId') { + return prop; + } + if (prop.key.type === 'Literal' && prop.key.value === 'connectionId') { + return prop; + } + } + return undefined; +} + +async function resolveValue( + ctx: PluginContext, + node: Expression, + mod: ParsedModule, + state: ResolutionState, +): Promise { + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral') { + return requireStaticTemplate(node, state, node.loc); + } + if (node.type === 'Identifier') { + return resolveIdentifier(ctx, node.name, mod, state, node.loc); + } + if (node.type === 'MemberExpression') { + return resolveMemberExpression(ctx, node, mod, state); + } + fail( + state, + `'connectionId' must be a static string, static template, const identifier, or object member; got ${node.type}`, + node.loc, + ); +} + +function requireStaticTemplate( + node: TemplateLiteral, + state: ResolutionState, + loc: Node['loc'], +): string { + if (node.expressions.length > 0) { + fail(state, `'connectionId' template literals must not contain interpolations`, loc); + } + const quasi = node.quasis[0]; + return quasi.value.cooked ?? quasi.value.raw; +} + +async function resolveIdentifier( + ctx: PluginContext, + name: string, + mod: ParsedModule, + state: ResolutionState, + loc: Node['loc'], +): Promise { + const mutable = mod.symbols.localMutables.get(name); + if (mutable) { + fail( + state, + `'connectionId' must resolve to a 'const' binding; '${name}' is declared with '${mutable.kind}' and can be reassigned`, + loc, + ); + } + const localInit = mod.symbols.localConsts.get(name); + if (localInit) { + return resolveValue(ctx, localInit, mod, state); + } + const binding = mod.symbols.importBindings.get(name); + if (binding) { + return resolveExportedValue(ctx, mod.id, binding.source, binding.imported, state); + } + fail(state, `identifier '${name}' is not defined in ${mod.id} and is not imported`, loc); +} + +async function resolveMemberExpression( + ctx: PluginContext, + node: MemberExpression, + mod: ParsedModule, + state: ResolutionState, +): Promise { + if (node.computed) { + fail(state, `'connectionId' computed member expressions are not supported`, node.loc); + } + if (node.object.type !== 'Identifier') { + fail(state, `'connectionId' member expressions must read from a const object`, node.loc); + } + const propertyName = readPropertyName(node.property); + if (!propertyName) { + fail(state, `'connectionId' member property must be static`, node.property.loc); + } + + const objectName = node.object.name; + const mutable = mod.symbols.localMutables.get(objectName); + if (mutable) { + fail( + state, + `'connectionId' object '${objectName}' must resolve to a 'const' binding; it is declared with '${mutable.kind}'`, + node.object.loc, + ); + } + const localInit = mod.symbols.localConsts.get(objectName); + if (localInit) { + return resolveObjectMember(ctx, localInit, mod, propertyName, state, node.loc); + } + const binding = mod.symbols.importBindings.get(objectName); + if (binding) { + const exported = await resolveExportedExpression( + ctx, + mod.id, + binding.source, + binding.imported, + state, + ); + return resolveObjectMember( + ctx, + exported.expression, + exported.module, + propertyName, + state, + node.loc, + ); + } + fail(state, `connectionId object '${objectName}' is not defined in ${mod.id}`, node.object.loc); +} + +function readPropertyName(node: Node): string | undefined { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return undefined; +} + +async function resolveObjectMember( + ctx: PluginContext, + expression: Expression, + mod: ParsedModule, + propertyName: string, + state: ResolutionState, + loc: Node['loc'], +): Promise { + if (expression.type === 'Identifier') { + const binding = mod.symbols.importBindings.get(expression.name); + if (binding) { + const exported = await resolveExportedExpression( + ctx, + mod.id, + binding.source, + binding.imported, + state, + ); + return resolveObjectMember( + ctx, + exported.expression, + exported.module, + propertyName, + state, + loc, + ); + } + const localInit = mod.symbols.localConsts.get(expression.name); + if (localInit) { + return resolveObjectMember(ctx, localInit, mod, propertyName, state, loc); + } + } + if (expression.type !== 'ObjectExpression') { + fail(state, `'connectionId' object member must resolve to an object literal`, loc); + } + + for (const prop of expression.properties) { + if (prop.type === 'SpreadElement') { + fail(state, `'connectionId' object spreads are not supported`, prop.loc); + } + if (prop.computed) { + fail(state, `'connectionId' object computed properties are not supported`, prop.loc); + } + const key = readPropertyName(prop.key); + if (key === propertyName) { + return resolveValue(ctx, prop.value as Expression, mod, state); + } + } + + fail(state, `connectionId object has no '${propertyName}' property`, loc); +} + +async function resolveExportedValue( + ctx: PluginContext, + importer: string, + source: string, + exportName: string, + state: ResolutionState, +): Promise { + const resolved = await resolveExportedExpression(ctx, importer, source, exportName, state); + return resolveValue(ctx, resolved.expression, resolved.module, state); +} + +async function resolveExportedExpression( + ctx: PluginContext, + importer: string, + source: string, + exportName: string, + state: ResolutionState, +): Promise<{ module: ParsedModule; expression: Expression }> { + const nextState = nextResolutionState(state, `${importer}::${source}::${exportName}`); + const resolvedId = await resolveModuleId(ctx, importer, source, { required: true }); + if (!resolvedId) { + fail(nextState, `could not resolve module '${source}' imported from ${importer}`); + } + const target = await loadParsedModule(ctx, resolvedId); + + for (const node of target.ast.body) { + if (node.type === 'ExportNamedDeclaration') { + if (node.source && typeof node.source.value === 'string') { + for (const spec of node.specifiers) { + if (spec.exported.type === 'Identifier' && spec.exported.name === exportName) { + const reName = + spec.local.type === 'Identifier' + ? spec.local.name + : String(spec.local.value); + return resolveExportedExpression( + ctx, + target.id, + node.source.value, + reName, + nextState, + ); + } + } + continue; + } + + for (const spec of node.specifiers) { + if (spec.exported.type === 'Identifier' && spec.exported.name === exportName) { + const localName = + spec.local.type === 'Identifier' + ? spec.local.name + : String(spec.local.value); + return { + module: target, + expression: { type: 'Identifier', name: localName } as Expression, + }; + } + } + + if (node.declaration?.type === 'VariableDeclaration') { + if (node.declaration.kind !== 'const') { + fail( + nextState, + `'connectionId' must resolve to a 'const' binding; '${exportName}' in '${target.id}' is declared with '${node.declaration.kind}'`, + ); + } + for (const d of node.declaration.declarations) { + if (d.id.type === 'Identifier' && d.id.name === exportName && d.init) { + return { module: target, expression: d.init }; + } + } + } + } else if (node.type === 'ExportAllDeclaration') { + if (node.exported || typeof node.source.value !== 'string') { + continue; + } + try { + return await resolveExportedExpression( + ctx, + target.id, + node.source.value, + exportName, + nextState, + ); + } catch (error) { + if (error instanceof ExportNotFoundError) { + continue; + } + throw error; + } + } + } + + throw new ExportNotFoundError( + `[connectionId manifest] export '${exportName}' not found in '${target.id}' while resolving connectionId`, + ); +} + +function nextResolutionState(state: ResolutionState, key: string): ResolutionState { + if (state.hops + 1 > MAX_HOPS) { + fail(state, `import tracing depth exceeded (${MAX_HOPS} hops)`); + } + if (state.visited.has(key)) { + fail(state, `cyclic re-export or import chain detected at ${key}`); + } + const visited = new Set(state.visited); + visited.add(key); + return { ...state, visited, hops: state.hops + 1 }; +} + +function fail(state: ResolutionState, reason: string, loc?: Node['loc']): never { + const where = loc?.start + ? `${state.originFile}:${loc.start.line}:${loc.start.column + 1}` + : state.originFile; + throw new ExtractionError(`[connectionId manifest] ${reason} (${state.label} at ${where})`); +} + +function failBase(reason: string, loc: Node['loc'], filePath: string): never { + const where = loc?.start ? `${filePath}:${loc.start.line}:${loc.start.column + 1}` : filePath; + throw new ExtractionError(`[connectionId manifest] ${reason} (${where})`); +} diff --git a/packages/plugins/apps/src/estree-walker.d.ts b/packages/plugins/apps/src/estree-walker.d.ts new file mode 100644 index 000000000..8cc482fe2 --- /dev/null +++ b/packages/plugins/apps/src/estree-walker.d.ts @@ -0,0 +1,17 @@ +declare module 'estree-walker' { + import type { Node } from 'estree'; + + type WalkerContext = { + skip(): void; + remove(): void; + replace(node: Node): void; + }; + + export function walk( + ast: T, + walker: { + enter?(this: WalkerContext, node: Node, parent: Node, key: string, index: number): void; + leave?(this: WalkerContext, node: Node, parent: Node, key: string, index: number): void; + }, + ): T; +} diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 553f29c2a..88bcd8854 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -17,6 +17,7 @@ import { mockLogFn, } from '@dd/tests/_jest/helpers/mocks'; import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; @@ -149,10 +150,18 @@ describe('Apps Plugin - getPlugins', () => { ]; jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets); jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); - jest.spyOn(archive, 'createArchive').mockResolvedValue({ - archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', - assets: mockedAssets, - size: 10, + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: archiveAssets, + size: 10, + }; }); jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], @@ -168,7 +177,11 @@ describe('Apps Plugin - getPlugins', () => { absolutePath: '/project/dist/index.js', relativePath: path.join('frontend', 'dist/index.js'), }, + expect.objectContaining({ + relativePath: 'manifest.json', + }), ]); + expect(manifest).toEqual({ backend: { functions: {} } }); expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 13af8ff23..fb585fe62 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -6,7 +6,10 @@ import { rm } from '@dd/core/helpers/fs'; import type { GetPlugins } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; +import fsp from 'fs/promises'; +import os from 'os'; import path from 'path'; +import type { PluginContext } from 'rollup'; import { createArchive } from './archive'; import type { Asset } from './assets'; @@ -14,10 +17,11 @@ import { collectAssets } from './assets'; import type { BackendFunction } from './backend/discovery'; import { extractExportedFunctions } from './backend/discovery'; import { encodeQueryName } from './backend/encodeQueryName'; +import { extractConnectionIds } from './backend/extract-connection-ids'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; -import type { AppsOptions } from './types'; +import type { AppsManifest, AppsOptions } from './types'; import { uploadArchive } from './upload'; import { validateOptions } from './validate'; import { getVitePlugin } from './vite/index'; @@ -32,6 +36,7 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, + connectionIdsByExport: Map, ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -40,7 +45,12 @@ function buildProxyModule( const proxyExports: Array<{ exportName: string; queryName: string }> = []; for (const exportName of exportNames) { - const func = { relativePath: refPath, name: exportName, absolutePath: id }; + const func: BackendFunction = { + relativePath: refPath, + name: exportName, + absolutePath: id, + allowedConnectionIds: connectionIdsByExport.get(exportName) ?? [], + }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); } @@ -71,9 +81,27 @@ function createBackendFunctionRegistry() { }; } +function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { + const functions: AppsManifest['backend']['functions'] = {}; + for (const fn of backendFunctions) { + functions[encodeQueryName(fn)] = { + allowedConnectionIds: fn.allowedConnectionIds, + }; + } + return { backend: { functions } }; +} + +async function writeManifestFile(backendFunctions: BackendFunction[]): Promise { + const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); + const manifestPath = path.join(manifestDir, 'manifest.json'); + await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2)); + return manifestPath; +} + export type types = { // Add the types you'd like to expose here. AppsOptions: AppsOptions; + AppsManifest: AppsManifest; }; export const getPlugins: GetPlugins = ({ options, context, bundler }) => { @@ -109,6 +137,7 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => { const handleUpload = async (backendOutputs: Map) => { const handleTimer = log.time('handle assets'); let archiveDir: string | undefined; + let manifestDir: string | undefined; try { const identifierTimer = log.time('resolve identifier'); @@ -158,6 +187,16 @@ Either: }); } + // Emit the connection-ID manifest alongside the backend bundles so + // the server can allowlist the connections each function uses. + const backendFunctions = getBackendFunctions(); + const manifestPath = await writeManifestFile(backendFunctions); + manifestDir = path.dirname(manifestPath); + allAssets.push({ + absolutePath: manifestPath, + relativePath: 'manifest.json', + }); + const archiveTimer = log.time('archive assets'); const archive = await createArchive(allAssets); archiveTimer.end(); @@ -198,10 +237,13 @@ Either: log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`); } - // Clean temporary directory + // Clean temporary directories if (archiveDir) { await rm(archiveDir); } + if (manifestDir) { + await rm(manifestDir); + } handleTimer.end(); if (toThrow) { @@ -226,8 +268,9 @@ Either: // For each .backend.* file, parse its named exports, register // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. - handler(code, id) { - const exportNames = extractExportedFunctions(this.parse(code), id); + async handler(code, id) { + const ast = this.parse(code); + const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( `Backend file ${id} has no exported functions. ` + @@ -239,10 +282,18 @@ Either: return { code: '', map: null }; } + const connectionIdsByExport = await extractConnectionIds( + this as unknown as PluginContext, + ast, + id, + context.buildRoot, + ); + const { functions, proxyCode } = buildProxyModule( exportNames, id, context.buildRoot, + connectionIdsByExport, ); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`); diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index f62ffd83b..55ed247b5 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -12,5 +12,16 @@ export type AppsOptions = { name?: string; }; +export type AppsManifest = { + backend: { + functions: Record< + string, + { + allowedConnectionIds: string[]; + } + >; + }; +}; + // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = WithRequired; diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 2c7b203a0..ba408e58b 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -20,11 +20,13 @@ const mockFunctions: BackendFunction[] = [ relativePath: 'backend/greet', name: 'greet', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'backend/compute', name: 'compute', absolutePath: '/project/backend/compute.backend.ts', + allowedConnectionIds: [], }, ]; @@ -145,10 +147,25 @@ describe('Dev Server Middleware', () => { test('Should handle /__dd/executeAction POST', async () => { mockViteBuild.mockResolvedValue(mockBuildResult('// bundled code')); + let capturedPreviewBody: any; + const functionWithConnectionIds = { + ...mockFunctions[0], + allowedConnectionIds: ['77c14b8b-27e1-4901-985d-8817908b9706'], + }; + const middlewareWithConnectionIds = createDevServerMiddleware( + mockViteBuild, + () => [functionWithConnectionIds], + mockAuth, + '/project', + mockLog, + ); // Mock the Datadog API via nock. const apiScope = nock(`https://${DD_SITE}`) - .post('/api/v2/app-builder/queries/preview-async') + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedPreviewBody = body; + return true; + }) .reply(200, { data: { id: 'receipt-123' } }) .get('/api/v2/app-builder/queries/execution-long-polling/receipt-123') .reply(200, { @@ -161,13 +178,13 @@ describe('Dev Server Middleware', () => { }); const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), + functionName: encodeQueryName(functionWithConnectionIds), args: ['world'], }); const res = createMockResponse(); const next = jest.fn(); - middleware(req, res, next); + middlewareWithConnectionIds(req, res, next); expect(next).not.toHaveBeenCalled(); await res.done; @@ -176,6 +193,10 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.success).toBe(true); expect(body.result).toEqual({ data: { result: 'hello' } }); + expect( + capturedPreviewBody?.data.attributes.query.properties.spec.inputs + .allowedConnectionIds, + ).toEqual(['77c14b8b-27e1-4901-985d-8817908b9706']); expect(apiScope.isDone()).toBe(true); }); }); @@ -422,6 +443,7 @@ describe('Dev Server Middleware', () => { relativePath: 'backend/greet', name: 'greetV2', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, mockFunctions[1], ]; diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index f2ecbb43b..66dbd9224 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -115,6 +115,7 @@ async function bundleBackendFunction( async function executeScriptViaDatadog( scriptBody: string, displayName: string, + allowedConnectionIds: string[], auth: AuthConfig, log: Logger, ): Promise { @@ -133,7 +134,7 @@ async function executeScriptViaDatadog( properties: { spec: { fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', - inputs: { script: scriptBody }, + inputs: { script: scriptBody, allowedConnectionIds }, }, onlyTriggerManually: true, }, @@ -248,7 +249,7 @@ async function validateAndBundle( req: IncomingMessage, functionsByName: Map, bundle: BundleFn, -): Promise<{ displayName: string; code: string }> { +): Promise<{ displayName: string; code: string; func: BackendFunction }> { const { functionName, args = [] } = await parseRequestBody(req); if (!functionName || typeof functionName !== 'string') { @@ -261,7 +262,7 @@ async function validateAndBundle( } const code = await bundle(func, args); - return { displayName: formatRef(func), code }; + return { displayName: formatRef(func), code, func }; } /** @@ -298,11 +299,17 @@ async function handleExecuteAction( log: Logger, ): Promise { try { - const { displayName, code } = await validateAndBundle(req, functionsByName, bundle); + const { displayName, code, func } = await validateAndBundle(req, functionsByName, bundle); log.debug(`Executing action: ${displayName} with args`); - const result = await executeScriptViaDatadog(code, displayName, auth, log); + const result = await executeScriptViaDatadog( + code, + displayName, + func.allowedConnectionIds, + auth, + log, + ); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 5d923f8a7..b39465640 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -15,11 +15,13 @@ const functions: BackendFunction[] = [ relativePath: 'src/backend/myHandler', name: 'myHandler', absolutePath: '/src/backend/myHandler.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'src/backend/otherFunc', name: 'otherFunc', absolutePath: '/src/backend/otherFunc.backend.ts', + allowedConnectionIds: [], }, ]; diff --git a/yarn.lock b/yarn.lock index 329b10d2e..f82c9e596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1947,6 +1947,7 @@ __metadata: "@dd/core": "workspace:*" "@types/estree": "npm:1.0.8" chalk: "npm:2.3.1" + estree-walker: "npm:2.0.2" glob: "npm:11.1.0" jszip: "npm:3.10.1" pretty-bytes: "npm:5.6.0" @@ -6641,7 +6642,7 @@ __metadata: languageName: node linkType: hard -"estree-walker@npm:^2.0.2": +"estree-walker@npm:2.0.2, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2