@unito/integration-sdk
Version:
Integration SDK
286 lines (253 loc) • 8.92 kB
text/typescript
/**
* 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: <reason> *\/` 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<string>(['@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<string>(['crypto', 'node:crypto']);
const SDK_VERIFIER_NAMES = new Set<string>([
'verifyWebhookSignature',
'verifySignature',
'verifyWebhook',
'WebhookVerifier',
]);
const OPT_OUT_RE = /-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: <reason>` 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: <reason> */`.',
},
},
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: '<webhook-parse-route>' },
});
}
},
};
},
};
export default rule;