From 902027ba011db53ca8a4a69a963c4d7aa7e66d37 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 7 May 2026 11:06:59 -0400 Subject: [PATCH] Extract inline backend connection IDs --- .../extract-connection-ids.test.ts | 337 ++++++++++++++++++ .../ast-parsing/extract-connection-ids.ts | 314 ++++++++++++++++ packages/plugins/apps/src/index.test.ts | 37 +- packages/plugins/apps/src/index.ts | 8 +- 4 files changed, 678 insertions(+), 18 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts new file mode 100644 index 000000000..323abecf9 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.test.ts @@ -0,0 +1,337 @@ +// 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 { parseAst } from 'rollup/parseAst'; +import type { AstNode } from 'rollup'; + +import { extractConnectionIds } from './extract-connection-ids'; + +const filePath = '/project/src/backend/actions.backend.js'; + +function parse(code: string): AstNode { + return parseAst(code) as AstNode; +} + +describe('Backend Functions - extractConnectionIds', () => { + test('Should extract inline string literal connection IDs from named action-catalog imports', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + return request({ connectionId: 'conn-b', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-b']); + }); + + test('Should dedupe and sort connection IDs', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'conn-b', inputs: {} }); + request({ connectionId: 'conn-a', inputs: {} }); + request({ connectionId: 'conn-b', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-a', 'conn-b']); + }); + + test('Should include same-file helper action calls', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + function helper() { + return request({ connectionId: 'conn-helper', inputs: {} }); + } + + export function run() { + return helper(); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-helper']); + }); + + test('Should detect default and namespace action-catalog imports', () => { + const ast = parse(` + import request from '@datadog/action-catalog/http/http'; + import * as slack from '@datadog/action-catalog/slack/messages'; + + export function run() { + request({ connectionId: 'conn-default', inputs: {} }); + slack.postMessage({ connectionId: 'conn-namespace', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual(['conn-default', 'conn-namespace']); + }); + + test('Should ignore non-action-catalog calls with connectionId properties', () => { + const ast = parse(` + import { request } from './local'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore action-catalog object arguments without connectionId', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ inputs: {} }); + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog imports', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + const importDeclaration = (ast as unknown as { body: Array<{ importKind?: string }> }) + .body[0]; + importDeclaration.importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test('Should ignore type-only action-catalog import specifiers', () => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + request({ connectionId: 'ignored', inputs: {} }); + } + `); + const importSpecifier = ( + ast as unknown as { + body: Array<{ specifiers: Array<{ importKind?: string }> }>; + } + ).body[0].specifiers[0]; + importSpecifier.importKind = 'type'; + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); + + test.each([ + { + description: 'function parameters that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + return request({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'function parameters that shadow namespace imports', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + + export function run(http) { + return http.request({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'catch parameters that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + try { + throw new Error('nope'); + } catch (request) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'local aliases of shadowed parameters', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(request) { + const action = request; + action({ connectionId: 'ignored', inputs: {} }); + } + `, + }, + { + description: 'for-of bindings that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(handlers) { + for (const request of handlers) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'for-statement bindings that shadow named imports', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + + export function run(handlers) { + for (const request = handlers.next; request;) { + request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + { + description: 'for-in bindings that shadow namespace imports', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + + export function run(clients) { + for (const http in clients) { + http.request({ connectionId: CONNECTIONS.HTTP, inputs: {} }); + } + } + `, + }, + ])( + 'Should not treat shadowed action-catalog import names as action calls: $description', + ({ code }) => { + expect(extractConnectionIds(parse(code), filePath)).toEqual([]); + }, + ); + + test.each([ + { + description: 'identifier value', + source: 'const ID = "conn"; request({ connectionId: ID, inputs: {} });', + expectedType: 'Identifier', + }, + { + description: 'template literal value', + source: 'request({ connectionId: `conn`, inputs: {} });', + expectedType: 'TemplateLiteral', + }, + { + description: 'member expression value', + source: 'request({ connectionId: CONNECTIONS.HTTP, inputs: {} });', + expectedType: 'MemberExpression', + }, + { + description: 'call expression value', + source: 'request({ connectionId: getConnectionId(), inputs: {} });', + expectedType: 'CallExpression', + }, + { + description: 'binary expression value', + source: "request({ connectionId: 'conn-' + suffix, inputs: {} });", + expectedType: 'BinaryExpression', + }, + ])('Should fail closed for unsupported $description', ({ source, expectedType }) => { + const ast = parse(` + import { request } from '@datadog/action-catalog/http/http'; + + export function run() { + ${source} + } + `); + + expect(() => extractConnectionIds(ast, filePath)).toThrow( + `expected an inline string literal, got ${expectedType}`, + ); + }); + + test.each([ + { + description: 'non-object first arguments', + source: 'request(opts);', + expectedMessage: 'non-object action-catalog call arguments', + }, + { + description: 'spread-composed object arguments', + source: 'request({ ...opts });', + expectedMessage: 'spread object arguments', + }, + { + description: 'computed connectionId keys', + source: "request({ ['connectionId']: 'conn' });", + expectedMessage: 'computed object property keys', + }, + { + description: 'optional action calls', + source: "request?.({ connectionId: 'conn' });", + expectedMessage: 'optional action-catalog calls', + }, + { + description: 'action-catalog import aliases', + source: "const action = request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + }, + { + description: 'action-catalog namespace member aliases', + source: "const action = http.request; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + { + description: 'action-catalog namespace destructuring aliases', + source: "const { request: action } = http; action({ connectionId: 'conn' });", + expectedMessage: 'action-catalog call aliases', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + { + description: 'multiple connectionId properties', + source: "request({ connectionId: 'conn-a', connectionId: 'conn-b' });", + expectedMessage: 'multiple connectionId properties', + }, + { + description: 'accessor connectionId properties', + source: 'request({ get connectionId() { return CONNECTIONS.HTTP; } });', + expectedMessage: 'accessor connectionId properties', + }, + { + description: 'computed namespace calls', + source: "http['request']({ connectionId: 'conn' });", + expectedMessage: 'optional or computed action-catalog namespace calls', + importStatement: "import * as http from '@datadog/action-catalog/http/http';", + }, + ])( + 'Should fail closed for unsupported $description', + ({ source, expectedMessage, importStatement }) => { + const ast = parse(` + ${importStatement ?? "import { request } from '@datadog/action-catalog/http/http';"} + + export function run() { + ${source} + } + `); + + expect(() => extractConnectionIds(ast, filePath)).toThrow(expectedMessage); + }, + ); + + test('Should return an empty allowlist when no connection IDs are present', () => { + const ast = parse(` + export function run() { + return 'ok'; + } + `); + + expect(extractConnectionIds(ast, filePath)).toEqual([]); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts new file mode 100644 index 000000000..c555eaf5a --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -0,0 +1,314 @@ +// 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 type { + BaseNode, + Identifier, + MemberExpression, + Node, + ObjectExpression, + Program, + Property, + SimpleCallExpression, + VariableDeclarator, +} from 'estree'; +import type { AstNode } from 'rollup'; + +import { isProgramNode } from './type-guards'; +import type { Scope } from './walk-with-scope'; +import { collectPatternNames, walkWithScope } from './walk-with-scope'; + +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +const CONNECTION_ID_PROPERTY = 'connectionId'; + +interface ActionCatalogImports { + functions: Set; + namespaces: Set; + unsupportedAliases: Set; +} + +type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; + +export function extractConnectionIds(ast: AstNode, filePath: string): string[] { + if (!isProgramNode(ast)) { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, + ); + } + + const imports = collectActionCatalogImports(ast); + const importedNames = getImportedNames(imports); + if (importedNames.size === 0) { + return []; + } + + collectUnsupportedActionCatalogAliases(ast, imports); + + const connectionIds = new Set(); + walkWithScope( + ast, + getTrackedNames(imports), + (node, scope) => { + if (node.type !== 'CallExpression') { + return; + } + + const actionCall = classifyActionCatalogCall(node, imports, scope, filePath); + if (!actionCall) { + return; + } + + extractConnectionIdFromActionCall(node, filePath, connectionIds); + }, + { + shouldIgnoreBinding: (_name, declaration, scope) => + declaration.kind === 'variable' && + isActionCatalogAliasDeclaration(declaration.node, imports, scope), + }, + ); + + return [...connectionIds].sort(); +} + +function collectActionCatalogImports(ast: Program): ActionCatalogImports { + const functions = new Set(); + const namespaces = new Set(); + const unsupportedAliases = new Set(); + + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration' || !isActionCatalogSource(node.source.value)) { + continue; + } + if (isTypeOnly(node)) { + continue; + } + + for (const specifier of node.specifiers) { + if (isTypeOnly(specifier)) { + continue; + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaces.add(specifier.local.name); + } else { + functions.add(specifier.local.name); + } + } + } + + return { functions, namespaces, unsupportedAliases }; +} + +function isActionCatalogSource(source: unknown): boolean { + return ( + typeof source === 'string' && + (source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`)) + ); +} + +function isTypeOnly(node: NodeWithOptionalImportKind): boolean { + return node.importKind === 'type'; +} + +function classifyActionCatalogCall( + node: SimpleCallExpression, + imports: ActionCatalogImports, + scope: Scope, + filePath: string, +): boolean { + const callee = node.callee; + + if (callee.type === 'Identifier') { + if (imports.unsupportedAliases.has(callee.name) && !scope.has(callee.name)) { + throw unsupportedActionCatalogCall(filePath, 'action-catalog call aliases'); + } + if (imports.functions.has(callee.name) && !scope.has(callee.name)) { + if (node.optional) { + throw unsupportedActionCatalogCall(filePath, 'optional action-catalog calls'); + } + return true; + } + return false; + } + + if (callee.type !== 'MemberExpression') { + return false; + } + + if (!isNamespaceMember(callee, imports.namespaces, scope)) { + return false; + } + + if (node.optional || hasUnsupportedMemberAccess(callee)) { + throw unsupportedActionCatalogCall( + filePath, + 'optional or computed action-catalog namespace calls', + ); + } + return true; +} + +function collectUnsupportedActionCatalogAliases(ast: Program, imports: ActionCatalogImports): void { + walkWithScope(ast, getImportedNames(imports), (node, scope) => { + if (node.type !== 'VariableDeclarator') { + return; + } + + for (const aliasName of getActionCatalogAliasNames(node, imports, scope)) { + imports.unsupportedAliases.add(aliasName); + } + }); +} + +function getActionCatalogAliasNames( + node: VariableDeclarator, + imports: ActionCatalogImports, + scope: Scope, +): string[] { + if ( + node.id.type === 'Identifier' && + node.init?.type === 'Identifier' && + imports.functions.has(node.init.name) && + !scope.has(node.init.name) + ) { + return [node.id.name]; + } + + if ( + node.id.type === 'Identifier' && + node.init?.type === 'MemberExpression' && + isNamespaceMember(node.init, imports.namespaces, scope) + ) { + return [node.id.name]; + } + + if ( + node.id.type !== 'ObjectPattern' || + node.init?.type !== 'Identifier' || + !imports.namespaces.has(node.init.name) || + scope.has(node.init.name) + ) { + return []; + } + + return node.id.properties.flatMap((property) => { + if (property.type === 'RestElement' || property.computed) { + return []; + } + return collectPatternNames(property.value); + }); +} + +function isActionCatalogAliasDeclaration( + node: Node, + imports: ActionCatalogImports, + scope: Scope, +): boolean { + return ( + node.type === 'VariableDeclarator' && + getActionCatalogAliasNames(node, imports, scope).length > 0 + ); +} + +function getImportedNames(imports: ActionCatalogImports): Set { + return new Set([...imports.functions, ...imports.namespaces]); +} + +function getTrackedNames(imports: ActionCatalogImports): Set { + return new Set([...imports.functions, ...imports.namespaces, ...imports.unsupportedAliases]); +} + +function isNamespaceMember( + node: MemberExpression, + namespaces: ReadonlySet, + scope: Scope, +): boolean { + const root = getMemberExpressionRoot(node); + return !!root && namespaces.has(root.name) && !scope.has(root.name); +} + +function getMemberExpressionRoot(node: MemberExpression): Identifier | undefined { + if (node.object.type === 'Identifier') { + return node.object; + } + if (node.object.type === 'MemberExpression') { + return getMemberExpressionRoot(node.object); + } + return undefined; +} + +function hasUnsupportedMemberAccess(node: MemberExpression): boolean { + if (node.optional || node.computed) { + return true; + } + return node.object.type === 'MemberExpression' && hasUnsupportedMemberAccess(node.object); +} + +function extractConnectionIdFromActionCall( + node: SimpleCallExpression, + filePath: string, + connectionIds: Set, +): void { + const [firstArg] = node.arguments; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + throw unsupportedActionCatalogCall(filePath, 'non-object action-catalog call arguments'); + } + + const connectionIdProperty = findConnectionIdProperty(firstArg, filePath); + if (!connectionIdProperty) { + return; + } + + const { value } = connectionIdProperty; + if (value.type === 'Literal' && typeof value.value === 'string') { + connectionIds.add(value.value); + return; + } + + throw unsupportedConnectionId(filePath, value.type); +} + +function findConnectionIdProperty( + objectExpression: ObjectExpression, + filePath: string, +): Property | undefined { + let connectionIdProperty: Property | undefined; + for (const property of objectExpression.properties) { + if (property.type === 'SpreadElement') { + throw unsupportedActionCatalogCall(filePath, 'spread object arguments'); + } + if (property.computed) { + throw unsupportedActionCatalogCall(filePath, 'computed object property keys'); + } + if (isConnectionIdKey(property)) { + if (connectionIdProperty) { + throw unsupportedActionCatalogCall(filePath, 'multiple connectionId properties'); + } + if (property.kind !== 'init') { + throw unsupportedActionCatalogCall(filePath, 'accessor connectionId properties'); + } + connectionIdProperty = property; + } + } + return connectionIdProperty; +} + +function isConnectionIdKey(property: Property): boolean { + if (property.key.type === 'Identifier') { + return property.key.name === CONNECTION_ID_PROPERTY; + } + return property.key.type === 'Literal' && property.key.value === CONNECTION_ID_PROPERTY; +} + +function unsupportedActionCatalogCall(filePath: string, unsupported: string): Error { + return new Error( + `Unsupported action-catalog call in ${filePath}: ${unsupported} could hide a connectionId.`, + ); +} + +function unsupportedConnectionId(filePath: string, type: string): Error { + return new Error( + `Unsupported action-catalog connectionId in ${filePath}: expected an inline string literal, got ${type}.`, + ); +} diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 5daa06bdc..4d126a46d 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -20,6 +20,7 @@ import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; +import { parseAst } from 'rollup/parseAst'; import { APPS_API_PATH } from './constants'; @@ -252,21 +253,19 @@ describe('Apps Plugin - getPlugins', () => { }; transform.handler.call( { - parse: () => ({ - type: 'Program', - body: [ - { - type: 'ExportNamedDeclaration', - declaration: { - type: 'FunctionDeclaration', - id: { type: 'Identifier', name: 'greet' }, - }, - specifiers: [], - }, - ], - }), + parse: parseAst, }, - 'export function greet() {}', + ` + import { request } from '@datadog/action-catalog/http/http'; + + export function greet() { + request({ connectionId: 'conn-b', inputs: {} }); + } + + export function salute() { + request({ connectionId: 'conn-a', inputs: {} }); + } + `, '/project/src/backend/greet.backend.js', ); @@ -282,7 +281,10 @@ describe('Apps Plugin - getPlugins', () => { ); expect( Object.keys((manifest as { backend: { functions: object } }).backend.functions), - ).toEqual([expect.stringMatching(/^[a-f0-9]{64}\.greet$/)]); + ).toEqual([ + expect.stringMatching(/^[a-f0-9]{64}\.greet$/), + expect.stringMatching(/^[a-f0-9]{64}\.salute$/), + ]); expect(manifest).toMatchObject({ backend: { functions: expect.any(Object) }, }); @@ -290,7 +292,10 @@ describe('Apps Plugin - getPlugins', () => { Object.values( (manifest as { backend: { functions: Record } }).backend.functions, ), - ).toEqual([{ allowedConnectionIds: [] }]); + ).toEqual([ + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + { allowedConnectionIds: ['conn-a', 'conn-b'] }, + ]); }); test('Should surface upload errors', async () => { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 1299947de..d6d3e1f60 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -14,6 +14,7 @@ import { createArchive } from './archive'; import type { Asset } from './assets'; import { collectAssets } from './assets'; import { extractExportedFunctions } from './backend/ast-parsing/extract-backend-functions'; +import { extractConnectionIds } from './backend/ast-parsing/extract-connection-ids'; import { encodeQueryName } from './backend/encodeQueryName'; import { generateProxyModule } from './backend/proxy-codegen'; import type { BackendFunction } from './backend/types'; @@ -34,6 +35,7 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, + allowedConnectionIds: string[], ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -46,7 +48,7 @@ function buildProxyModule( relativePath: refPath, name: exportName, absolutePath: id, - allowedConnectionIds: [], + allowedConnectionIds: [...allowedConnectionIds], }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); @@ -275,7 +277,8 @@ Either: // 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); + const ast = this.parse(code); + const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( `Backend file ${id} has no exported functions. ` + @@ -291,6 +294,7 @@ Either: exportNames, id, context.buildRoot, + extractConnectionIds(ast, id), ); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`);