/** * Flags webhook PARSE handlers that have no signature-verification import. * Webhook payloads are provider-controlled untrusted strings; the default * generated shape is "parse the body, return impactedItems" which silently * drops the verifier step. * * Scope (any of): * - filename matches /webhookParse\.ts$/i, /parseWebhooks?\.ts$/i, * /webhooksParse\.ts$/i * - any file that imports `ParseWebhooksHandler` from `@unito/integration-sdk` * or `@unito/integration-api` * * `AcknowledgeWebhooksHandler` exports are out of scope (handshake response, * not payload parser). * * Opt-out: JSDoc `/** @webhook-no-signature: *\/` above the export. * * Detection limitation (v1): only matches `ImportSpecifier` named imports for * SDK verifiers (`import { verifyWebhookSignature } from ...`). Default or * namespace imports are NOT detected; if the SDK exposes the verifier those * ways, use the JSDoc opt-out and document the reason. */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; const FILENAME_PATTERNS = [/\/webhooks?Parse\.ts$/i, /\/parseWebhooks?\.ts$/i, /\/webhookParse\.ts$/i]; const SDK_PACKAGES = new Set(['@unito/integration-sdk', '@unito/integration-api']); const PARSE_HANDLER_TYPE = 'ParseWebhooksHandler'; const ACK_HANDLER_TYPE = 'AcknowledgeWebhooksHandler'; const PARSE_EXPORT_NAME_RE = /^parseWebhooks?$/; const CRYPTO_MODULES = new Set(['crypto', 'node:crypto']); const SDK_VERIFIER_NAMES = new Set([ 'verifyWebhookSignature', 'verifySignature', 'verifyWebhook', 'WebhookVerifier', ]); const OPT_OUT_RE = /@webhook-no-signature:\s*\S+/; function filenameInScope(filename: string): boolean { return FILENAME_PATTERNS.some(re => re.test(filename)); } function isCryptoImport(node: ESTree.Node): boolean { if (node.type !== 'ImportDeclaration') { return false; } return typeof node.source.value === 'string' && CRYPTO_MODULES.has(node.source.value); } function isSdkVerifierImport(node: ESTree.Node): boolean { if (node.type !== 'ImportDeclaration') { return false; } if (typeof node.source.value !== 'string') { return false; } if (!SDK_PACKAGES.has(node.source.value)) { return false; } return node.specifiers.some(spec => { if (spec.type !== 'ImportSpecifier') { return false; } const importedName = spec.imported.type === 'Identifier' ? spec.imported.name : null; return importedName !== null && SDK_VERIFIER_NAMES.has(importedName); }); } function importsParseHandlerType(node: ESTree.Node): boolean { if (node.type !== 'ImportDeclaration') { return false; } if (typeof node.source.value !== 'string') { return false; } if (!SDK_PACKAGES.has(node.source.value)) { return false; } return node.specifiers.some(spec => { if (spec.type !== 'ImportSpecifier') { return false; } return spec.imported.type === 'Identifier' && spec.imported.name === PARSE_HANDLER_TYPE; }); } interface TypeAnnotationContainer { typeAnnotation?: { typeAnnotation?: { type: string; typeName?: { type: string; name: string }; }; }; } function getTypeAnnotationName(typeAnnotation: TypeAnnotationContainer['typeAnnotation'] | undefined): string | null { if (!typeAnnotation) { return null; } const inner = typeAnnotation.typeAnnotation; if (!inner) { return null; } if (inner.type === 'TSTypeReference' && inner.typeName && inner.typeName.type === 'Identifier') { return inner.typeName.name; } return null; } interface ParseExportInfo { name: string; } function findParseExportFromDeclaration(decl: ESTree.VariableDeclaration | null | undefined): ParseExportInfo | null { if (!decl || decl.type !== 'VariableDeclaration') { return null; } for (const declarator of decl.declarations) { if (!declarator.id || declarator.id.type !== 'Identifier') { continue; } const name = declarator.id.name; const idAsContainer = declarator.id as unknown as TypeAnnotationContainer; const typeName = getTypeAnnotationName(idAsContainer.typeAnnotation); if (typeName === ACK_HANDLER_TYPE) { continue; } if (typeName === PARSE_HANDLER_TYPE || PARSE_EXPORT_NAME_RE.test(name)) { return { name }; } } return null; } function findParseExport(node: ESTree.Node, programBody: readonly ESTree.Node[]): ParseExportInfo | null { if (node.type !== 'ExportNamedDeclaration') { return null; } if (node.declaration) { return findParseExportFromDeclaration(node.declaration as ESTree.VariableDeclaration); } if (!node.specifiers || node.specifiers.length === 0) { return null; } for (const spec of node.specifiers) { if (spec.type !== 'ExportSpecifier') { continue; } const exportedName = spec.exported.type === 'Identifier' ? spec.exported.name : null; const localName = spec.local.type === 'Identifier' ? spec.local.name : null; if (!exportedName || !localName) { continue; } let resolvedTypeName: string | null = null; for (const stmt of programBody) { if (stmt.type !== 'VariableDeclaration') { continue; } for (const d of stmt.declarations) { if (d.id && d.id.type === 'Identifier' && d.id.name === localName) { const idAsContainer = d.id as unknown as TypeAnnotationContainer; resolvedTypeName = getTypeAnnotationName(idAsContainer.typeAnnotation); } } } if (resolvedTypeName === ACK_HANDLER_TYPE) { continue; } if ( resolvedTypeName === PARSE_HANDLER_TYPE || PARSE_EXPORT_NAME_RE.test(exportedName) || PARSE_EXPORT_NAME_RE.test(localName) ) { return { name: exportedName }; } } return null; } function hasOptOutComment(sourceCode: Rule.RuleContext['sourceCode'], node: ESTree.Node): boolean { const comments = sourceCode.getCommentsBefore(node); return comments.some(c => OPT_OUT_RE.test(c.value)); } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Require webhook parse handlers to import a signature-verification helper (crypto.createHmac/timingSafeEqual or a known SDK verifier), or carry a `@webhook-no-signature: ` JSDoc opt-out.', recommended: true, }, schema: [], messages: { missingVerification: 'Webhook parse handler `{{name}}` has no signature-verification import. Webhook payloads are provider-controlled untrusted strings — verify the provider signature before parsing. Import `crypto.createHmac` + `timingSafeEqual` (or a known SDK verifier) and check the signature against `ctx.body.headers`. If the provider genuinely does not sign webhooks, document it with a JSDoc above the export: `/** @webhook-no-signature: */`.', }, }, create(context) { const filename = context.filename ?? ''; const sourceCode = context.sourceCode; let inScopeByImport = false; let hasVerifierImport = false; return { ImportDeclaration(node) { if (isCryptoImport(node) || isSdkVerifierImport(node)) { hasVerifierImport = true; } if (importsParseHandlerType(node)) { inScopeByImport = true; } }, 'Program:exit'(programNode: ESTree.Program) { const program = programNode; const filenameMatch = filenameInScope(filename); const inScope = filenameMatch || inScopeByImport; if (!inScope) { return; } if (hasVerifierImport) { return; } let flagged = false; for (const stmt of program.body) { const found = findParseExport(stmt as ESTree.Node, program.body as readonly ESTree.Node[]); if (!found) { continue; } if (hasOptOutComment(sourceCode, stmt as ESTree.Node)) { flagged = true; continue; } flagged = true; context.report({ node: stmt as ESTree.Node, messageId: 'missingVerification', data: { name: found.name }, }); } if (!flagged && filenameMatch) { const hasAnyExport = program.body.some( stmt => stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration', ); if (!hasAnyExport) { return; } const firstStmt = program.body[0]; if (!firstStmt) { return; } if (hasOptOutComment(sourceCode, firstStmt as ESTree.Node)) { return; } context.report({ node: firstStmt as ESTree.Node, messageId: 'missingVerification', data: { name: '' }, }); } }, }; }, }; export default rule;