diff --git a/.changeset/ast-entrypoint-source-positions.md b/.changeset/ast-entrypoint-source-positions.md new file mode 100644 index 0000000000..67e377079e --- /dev/null +++ b/.changeset/ast-entrypoint-source-positions.md @@ -0,0 +1,7 @@ +--- +"react-doctor": patch +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +--- + +Classify source positions before reporting textual entrypoint matches in generated-source prose. Preserve existing findings when parsing or a source position is unsupported. diff --git a/packages/fuzz/corpus/regressions/webhook-signature-risk--actual-entrypoint.tsx b/packages/fuzz/corpus/regressions/webhook-signature-risk--actual-entrypoint.tsx new file mode 100644 index 0000000000..6cfcce3fbe --- /dev/null +++ b/packages/fuzz/corpus/regressions/webhook-signature-risk--actual-entrypoint.tsx @@ -0,0 +1,5 @@ +// rule: webhook-signature-risk +// weakness: entrypoint-source-position +// source: independent synthetic source position control +// verdict: fail +export const webhookHandler = (req: { body: unknown }) => req.body; diff --git a/packages/fuzz/corpus/regressions/webhook-signature-risk--entrypoint-prose.tsx b/packages/fuzz/corpus/regressions/webhook-signature-risk--entrypoint-prose.tsx new file mode 100644 index 0000000000..b144dbee37 --- /dev/null +++ b/packages/fuzz/corpus/regressions/webhook-signature-risk--entrypoint-prose.tsx @@ -0,0 +1,6 @@ +// rule: webhook-signature-risk +// weakness: entrypoint-source-position +// source: independent synthetic source position control +// verdict: pass +export const renderSource = () => "const payload = req.body;"; +console.error("Could not write webhookHandler output"); diff --git a/packages/fuzz/src/snippet-pools.ts b/packages/fuzz/src/snippet-pools.ts index accb163dd2..8f80af0ca2 100644 --- a/packages/fuzz/src/snippet-pools.ts +++ b/packages/fuzz/src/snippet-pools.ts @@ -314,6 +314,8 @@ export const LIBRARY_SNIPPET_POOL = [ // Module scope — SSR hazards, guard aliases, contexts, caches, styled. export const MODULE_SCOPE_SNIPPET_POOL = [ + 'export const renderSource = () => "const payload = req.body;";\nconsole.error("Could not write webhookHandler output");\n', + "export const webhookHandler = (req: { body: unknown }) => req.body;\n", `import { create as createFuzzZustandStore } from "zustand"; export const useFuzzZustandStore = createFuzzZustandStore(() => ({ items: [] })); export const fuzzZustandSelection = useFuzzZustandStore((state) => ({ items: state.items }));`, `import { create as createFuzzZustandTarget } from "zustand"; const useFuzzZustandTarget = createFuzzZustandTarget(() => ({ value: 0, other: 0 })); export const FuzzZustandWholeStoreTarget = () => { const { value: fuzzZustandValue } = useFuzzZustandTarget(); return {fuzzZustandValue}; };`, `import { create as createFuzzZustandGetStore } from "zustand"; const useFuzzZustandGetStore = createFuzzZustandGetStore((_set, get) => ({ count: get().count }));`, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-classified-webhook-entrypoint.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-classified-webhook-entrypoint.ts new file mode 100644 index 0000000000..5d0cba5fa8 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-classified-webhook-entrypoint.ts @@ -0,0 +1,237 @@ +import { CROSS_FILE_PARSE_MAX_BYTES } from "../../../constants/thresholds.js"; +import type { EsTreeNode } from "../../../utils/es-tree-node.js"; +import type { ScannedFile } from "../../../utils/file-scan.js"; +import { getNodeEndIndex } from "../../../utils/get-node-end-index.js"; +import { getNodeStartIndex } from "../../../utils/get-node-start-index.js"; +import { isNodeOfType } from "../../../utils/is-node-of-type.js"; +import { parseSourceText } from "../../../utils/parse-source-file.js"; +import { walkAst } from "../../../utils/walk-ast.js"; +import { getScannableContent } from "./scan-by-pattern.js"; +import { + WEBHOOK_FIRST_ITEM_INDEX, + WEBHOOK_REGION_STEP_COUNT, + WEBHOOK_MARKER_CHAR_COUNT, + WEBHOOK_TEMPLATE_EXPRESSION_MARKER_CHAR_COUNT, + WEBHOOK_NON_ASCII_PATTERN, + WEBHOOK_LITERAL_WHITESPACE_PATTERN, + WEBHOOK_IDENTIFIER_NAME_PATTERN, + WEBHOOK_FUNCTION_NAME_PATTERN, + WEBHOOK_VARIABLE_NAME_PATTERN, + WEBHOOK_SYNC_EXPORT_PATTERN, + WEBHOOK_ASYNC_EXPORT_PATTERN, + WEBHOOK_CONST_EXPORT_PATTERN, +} from "./webhook-entrypoint/constants.js"; + +interface EntrypointRegion { + readonly start: number; + readonly end: number; + readonly isRetained: boolean; +} + +export interface ClassifiedWebhookEntrypoint { + readonly index: number | null; +} + +const hasValidSourceSpan = (node: EsTreeNode, source: string): boolean => { + const start = getNodeStartIndex(node); + const end = getNodeEndIndex(node); + return ( + Number.isInteger(start) && + Number.isInteger(end) && + start >= WEBHOOK_FIRST_ITEM_INDEX && + end >= start && + end <= source.length + ); +}; + +const isDirectExportMatch = ( + node: EsTreeNode, + match: RegExpMatchArray, + source: string, +): boolean => { + const matchStart = match.index; + if ( + typeof matchStart !== "number" || + !isNodeOfType(node, "ExportNamedDeclaration") || + !node.declaration || + matchStart !== getNodeStartIndex(node) || + !source.startsWith("export", matchStart) + ) + return false; + const declaration = node.declaration; + if (isNodeOfType(declaration, "FunctionDeclaration")) { + return Boolean( + declaration.id && + isNodeOfType(declaration.id, "Identifier") && + WEBHOOK_FUNCTION_NAME_PATTERN.test(declaration.id.name) && + !declaration.generator && + (declaration.async ? WEBHOOK_ASYNC_EXPORT_PATTERN : WEBHOOK_SYNC_EXPORT_PATTERN).test( + match[WEBHOOK_FIRST_ITEM_INDEX], + ) && + getNodeEndIndex(declaration.id) === matchStart + match[WEBHOOK_FIRST_ITEM_INDEX].length && + source.slice(getNodeStartIndex(declaration.id), getNodeEndIndex(declaration.id)) === + declaration.id.name, + ); + } + if ( + !isNodeOfType(declaration, "VariableDeclaration") || + declaration.kind !== "const" || + !WEBHOOK_CONST_EXPORT_PATTERN.test(match[WEBHOOK_FIRST_ITEM_INDEX]) + ) + return false; + const firstDeclarator = declaration.declarations[WEBHOOK_FIRST_ITEM_INDEX]; + return Boolean( + firstDeclarator && + isNodeOfType(firstDeclarator, "VariableDeclarator") && + isNodeOfType(firstDeclarator.id, "Identifier") && + WEBHOOK_VARIABLE_NAME_PATTERN.test(firstDeclarator.id.name) && + getNodeEndIndex(firstDeclarator.id) === matchStart + match[WEBHOOK_FIRST_ITEM_INDEX].length && + source.slice(getNodeStartIndex(firstDeclarator.id), getNodeEndIndex(firstDeclarator.id)) === + firstDeclarator.id.name, + ); +}; + +export const getClassifiedWebhookEntrypoint = ( + file: ScannedFile, + entrypointPattern: RegExp, +): ClassifiedWebhookEntrypoint | null => { + if ( + file.content.length > CROSS_FILE_PARSE_MAX_BYTES || + WEBHOOK_NON_ASCII_PATTERN.test(file.content) + ) + return null; + try { + const program = parseSourceText({ + filename: file.absolutePath, + sourceText: file.content, + shouldAttachParentReferences: false, + }); + if (!program) return null; + const content = getScannableContent(file); + if (content.length !== file.content.length) return null; + const pattern = new RegExp( + entrypointPattern.source, + entrypointPattern.flags.includes("g") + ? entrypointPattern.flags + : `${entrypointPattern.flags}g`, + ); + const matches = [...content.matchAll(pattern)]; + if (matches.length === WEBHOOK_FIRST_ITEM_INDEX) return null; + const matchesByStart = new Map(); + for (const match of matches) { + if (typeof match.index !== "number" || !Number.isInteger(match.index)) return null; + matchesByStart.set(match.index, match); + } + const regions: EntrypointRegion[] = []; + let hasConsistentSpans = true; + walkAst(program, (node) => { + if (!hasConsistentSpans) return false; + if (!hasValidSourceSpan(node, file.content)) { + hasConsistentSpans = false; + return false; + } + const start = getNodeStartIndex(node); + const end = getNodeEndIndex(node); + if ( + isNodeOfType(node, "Identifier") && + WEBHOOK_IDENTIFIER_NAME_PATTERN.test(node.name) && + file.content.slice(start, end) === node.name + ) { + regions.push({ start, end, isRetained: true }); + } else if (isNodeOfType(node, "ExportNamedDeclaration")) { + const match = matchesByStart.get(start); + if (match && isDirectExportMatch(node, match, file.content)) + regions.push({ + start, + end: start + match[WEBHOOK_FIRST_ITEM_INDEX].length, + isRetained: true, + }); + } else if (isNodeOfType(node, "Literal")) { + const rawSource = file.content.slice(start, end); + if ("regex" in node && node.regex) { + if (rawSource !== `/${node.regex.pattern}/${node.regex.flags}`) { + hasConsistentSpans = false; + return false; + } + regions.push({ + start: start + WEBHOOK_MARKER_CHAR_COUNT, + end: end - node.regex.flags.length - WEBHOOK_MARKER_CHAR_COUNT, + isRetained: false, + }); + } else if (typeof node.value === "string") { + const marker = rawSource[WEBHOOK_FIRST_ITEM_INDEX]; + if ((marker !== '"' && marker !== "'") || !rawSource.endsWith(marker)) { + hasConsistentSpans = false; + return false; + } + const interior = rawSource.slice(WEBHOOK_MARKER_CHAR_COUNT, -WEBHOOK_MARKER_CHAR_COUNT); + if (interior.includes("\\")) return; + if (interior !== node.value) { + hasConsistentSpans = false; + return false; + } + regions.push({ + start: start + WEBHOOK_MARKER_CHAR_COUNT, + end: end - WEBHOOK_MARKER_CHAR_COUNT, + isRetained: !WEBHOOK_LITERAL_WHITESPACE_PATTERN.test(interior), + }); + } + } else if (isNodeOfType(node, "TemplateElement")) { + const rawSource = file.content.slice(start, end); + const marker = rawSource[WEBHOOK_FIRST_ITEM_INDEX]; + const ending = node.tail ? "`" : "${"; + const endingLength = node.tail + ? WEBHOOK_MARKER_CHAR_COUNT + : WEBHOOK_TEMPLATE_EXPRESSION_MARKER_CHAR_COUNT; + if ( + (marker !== "`" && marker !== "}") || + !rawSource.endsWith(ending) || + rawSource.slice(WEBHOOK_MARKER_CHAR_COUNT, -endingLength) !== node.value.raw + ) { + hasConsistentSpans = false; + return false; + } + regions.push({ + start: start + WEBHOOK_MARKER_CHAR_COUNT, + end: end - endingLength, + isRetained: false, + }); + } + }); + if ( + !hasConsistentSpans || + regions.some( + (region) => + region.start < WEBHOOK_FIRST_ITEM_INDEX || + region.end < region.start || + region.end > file.content.length, + ) + ) + return null; + regions.sort((left, right) => left.start - right.start || left.end - right.end); + let regionIndex = WEBHOOK_FIRST_ITEM_INDEX; + let firstRetainedIndex: number | null = null; + for (const match of matches) { + const index = match.index; + if ( + typeof index !== "number" || + !Number.isInteger(index) || + match[WEBHOOK_FIRST_ITEM_INDEX].length === WEBHOOK_FIRST_ITEM_INDEX + ) + return null; + while (regions[regionIndex] && regions[regionIndex].end <= index) + regionIndex += WEBHOOK_REGION_STEP_COUNT; + const region = regions[regionIndex]; + if ( + !region || + region.start > index || + region.end < index + match[WEBHOOK_FIRST_ITEM_INDEX].length + ) + return null; + if (region.isRetained && firstRetainedIndex === null) firstRetainedIndex = index; + } + return { index: firstRetainedIndex }; + } catch { + return null; + } +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/webhook-entrypoint/constants.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/webhook-entrypoint/constants.ts new file mode 100644 index 0000000000..aa9320201a --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/webhook-entrypoint/constants.ts @@ -0,0 +1,13 @@ +export const WEBHOOK_EMPTY_FINDING_COUNT = 0; +export const WEBHOOK_REGION_STEP_COUNT = 1; +export const WEBHOOK_FIRST_ITEM_INDEX = 0; +export const WEBHOOK_MARKER_CHAR_COUNT = 1; +export const WEBHOOK_TEMPLATE_EXPRESSION_MARKER_CHAR_COUNT = 2; +export const WEBHOOK_NON_ASCII_PATTERN = /\P{ASCII}/u; +export const WEBHOOK_LITERAL_WHITESPACE_PATTERN = /\s/u; +export const WEBHOOK_IDENTIFIER_NAME_PATTERN = /^(?:webhookHandler|webhookRoute)$/i; +export const WEBHOOK_FUNCTION_NAME_PATTERN = /^POST$/i; +export const WEBHOOK_VARIABLE_NAME_PATTERN = /^(?:POST|handler|webhook)$/i; +export const WEBHOOK_SYNC_EXPORT_PATTERN = /^export\s+function\s+POST$/i; +export const WEBHOOK_ASYNC_EXPORT_PATTERN = /^export\s+async\s+function\s+POST$/i; +export const WEBHOOK_CONST_EXPORT_PATTERN = /^export\s+const\s+(?:POST|handler|webhook)$/i; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.performance.test.ts new file mode 100644 index 0000000000..8cf2d5488b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.performance.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import * as sourceParser from "../../utils/parse-source-file.js"; +import { getLocationAtIndex } from "./utils/get-location-at-index.js"; +import { webhookSignatureRisk } from "./webhook-signature-risk.js"; +import { + WEBHOOK_POSITION_PROOF_COUNTS, + WEBHOOK_POSITION_PROOF_PARSE_CALL_COUNT, + WEBHOOK_POSITION_PROOF_QUOTED_SLICE_BASE_COUNT, + WEBHOOK_POSITION_PROOF_QUOTED_SLICES_PER_ENTRY, + WEBHOOK_POSITION_PROOF_TEMPLATE_SLICES_PER_ENTRY, + WEBHOOK_POSITION_PROOF_NESTED_SLICE_COUNT, +} from "../../../test-utils/webhook-position-proof/constants.js"; + +const shapes = [ + "quoted-prose-before-runtime", + "template-quasis-and-runtime", + "nested-call-source-spans", +]; + +describe("webhook entrypoint cold source views", () => { + for (const shape of shapes) { + for (const count of WEBHOOK_POSITION_PROOF_COUNTS) { + it(`${shape} with ${count} entries`, () => { + const declarations = Array.from({ length: count }, (_, entryIndex) => + shape === "quoted-prose-before-runtime" + ? `const text${entryIndex}="webhookHandler prose ${entryIndex}";` + : `const text${entryIndex}=\`webhookHandler prose \${webhookRoute(req)}\`;`, + ).join("\n"); + let code = `const req={};\n${declarations}${shape === "quoted-prose-before-runtime" ? "\nwebhookRoute(req);" : ""}\n`; + if (shape === "nested-call-source-spans") + code = `const req={};\nconst output=${"wrap(".repeat(count)}"webhookHandler prose"${")".repeat(count)};\nwebhookRoute(req);\n`; + const controlCode = + shape === "template-quasis-and-runtime" + ? code.replaceAll("${webhookRoute(req)}", "") + : code.replace("\nwebhookRoute(req);", ""); + const file = { + absolutePath: `/private/synthetic-entrypoints/server/webhooks/${shape}-${count}.js`, + relativePath: `server/webhooks/${shape}-${count}.js`, + content: code, + isGeneratedBundle: false, + }; + const expectedLocation = getLocationAtIndex(code, code.indexOf("webhookRoute")); + const sourceParserSpy = vi.spyOn(sourceParser, "parseSourceText"); + const sourceSliceSpy = vi.spyOn(String.prototype, "slice"); + try { + const findings = webhookSignatureRisk.scan?.(file); + const sourceSliceCalls = sourceSliceSpy.mock.contexts.filter( + (context) => String(context) === code, + ).length; + let sliceLimit = WEBHOOK_POSITION_PROOF_NESTED_SLICE_COUNT; + if (shape === "quoted-prose-before-runtime") + sliceLimit = + count * WEBHOOK_POSITION_PROOF_QUOTED_SLICES_PER_ENTRY + + WEBHOOK_POSITION_PROOF_QUOTED_SLICE_BASE_COUNT; + else if (shape === "template-quasis-and-runtime") + sliceLimit = count * WEBHOOK_POSITION_PROOF_TEMPLATE_SLICES_PER_ENTRY; + expect(sourceParserSpy).toHaveBeenCalledTimes(WEBHOOK_POSITION_PROOF_PARSE_CALL_COUNT); + expect( + sourceParserSpy.mock.results.every( + (result) => result.type === "return" && result.value !== null, + ), + ).toBe(true); + expect(sourceSliceCalls).toBeLessThanOrEqual(sliceLimit); + expect(findings).toEqual([ + { + message: "Webhook handler code does not show an obvious signature verification step.", + ...expectedLocation, + }, + ]); + } finally { + sourceSliceSpy.mockRestore(); + sourceParserSpy.mockRestore(); + } + expect(webhookSignatureRisk.scan?.({ ...file, content: controlCode })).toEqual([]); + }); + } + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.test.ts new file mode 100644 index 0000000000..2f32affdf4 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.entrypoint-position.test.ts @@ -0,0 +1,1243 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import * as sourceParser from "../../utils/parse-source-file.js"; +import { CROSS_FILE_PARSE_MAX_BYTES } from "../../constants/thresholds.js"; +import { webhookSignatureRisk } from "./webhook-signature-risk.js"; + +const cases = [ + { + name: "legacy-proposal-double-quoted-prose-only", + code: 'export const makeSource = () => "const payload = req.body;";\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-double-quoted-prose-only.js", + absolutePath: "/src/generation/webhook-double-quoted-prose-only.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-single-quoted-prose-only", + code: "export const makeSource = () => \"const payload = req.body;\";\nconsole.error('Could not write webhookRoute output');\n", + options: { + relativePath: "src/generation/webhook-single-quoted-prose-only.js", + absolutePath: "/src/generation/webhook-single-quoted-prose-only.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 32, + }, + ], + }, + { + name: "legacy-proposal-template-quasi-prose-only", + code: 'export const makeSource = () => "const payload = req.body;";\nconsole.error(`Could not write webhookHandler output`);\n', + options: { + relativePath: "src/generation/webhook-template-quasi-prose-only.js", + absolutePath: "/src/generation/webhook-template-quasi-prose-only.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 32, + }, + ], + }, + { + name: "legacy-proposal-generator-nested-template", + code: 'export const makeSource = (name) => `const req = input; await ${name}(req.body);`;\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-generator-nested-template.js", + absolutePath: "/src/generation/webhook-generator-nested-template.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-literal-mixed-with-actual-post", + code: 'console.error("Error writing to webhookHandler output:");\nexport async function POST(request) { return request.body; }\n', + options: { + relativePath: "src/generation/webhook-literal-mixed-with-actual-post.js", + absolutePath: "/src/generation/webhook-literal-mixed-with-actual-post.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 2, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-actual-before-prose", + code: 'export async function POST(request) { return request.body; }\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-actual-before-prose.js", + absolutePath: "/src/generation/webhook-actual-before-prose.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 1, + }, + ], + }, + { + name: "legacy-proposal-template-interpolation-actual-reference", + code: "const output = `rendered ${webhookHandler(req)}`;\n", + options: { + relativePath: "src/generation/webhook-template-interpolation-actual-reference.js", + absolutePath: "/src/generation/webhook-template-interpolation-actual-reference.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 28, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "legacy-proposal-nested-interpolation-actual-reference", + code: "const output = `outer ${`inner ${webhookHandler(req)}`}`;\n", + options: { + relativePath: "src/generation/webhook-nested-interpolation-actual-reference.js", + absolutePath: "/src/generation/webhook-nested-interpolation-actual-reference.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 34, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 34, + }, + ], + }, + { + name: "legacy-proposal-escaped-interpolation-is-text", + code: "const output = `generated \\${webhookHandler(req)} output`;\n", + options: { + relativePath: "src/generation/webhook-escaped-interpolation-is-text.js", + absolutePath: "/src/generation/webhook-escaped-interpolation-is-text.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 30, + }, + ], + }, + { + name: "legacy-proposal-escaped-quote-prose", + code: 'const output = "escaped \\" webhookHandler payload req";\n', + options: { + relativePath: "src/generation/webhook-escaped-quote-prose.js", + absolutePath: "/src/generation/webhook-escaped-quote-prose.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 28, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "legacy-proposal-quoted-header-suppression-preserved", + code: 'export const webhookHandler = (req) => req.headers["x-signature"];\n', + options: { + relativePath: "src/generation/webhook-quoted-header-suppression-preserved.js", + absolutePath: "/src/generation/webhook-quoted-header-suppression-preserved.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [], + }, + { + name: "legacy-proposal-helper-name-veto-preserved", + code: "export const webhookHandler = (req) => { verifySignature(req); return req.body; };\n", + options: { + relativePath: "src/generation/webhook-helper-name-veto-preserved.js", + absolutePath: "/src/generation/webhook-helper-name-veto-preserved.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [], + }, + { + name: "legacy-proposal-actual-with-request-only-string", + code: 'export const webhookHandler = () => "request data is a generated note";\n', + options: { + relativePath: "src/generation/webhook-actual-with-request-only-string.js", + absolutePath: "/src/generation/webhook-actual-with-request-only-string.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 14, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 14, + }, + ], + }, + { + name: "legacy-proposal-single-token-string-boundary", + code: 'const output = "webhookHandler"; const label = "req";\n', + options: { + relativePath: "src/generation/webhook-single-token-string-boundary.js", + absolutePath: "/src/generation/webhook-single-token-string-boundary.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 17, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 17, + }, + ], + }, + { + name: "legacy-proposal-type-name-boundary", + code: 'interface webhookHandler {}\nconst text = "req";\n', + options: { + relativePath: "src/generation/webhook-type-name.ts", + absolutePath: "/src/generation/webhook-type-name.ts", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 11, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 11, + }, + ], + }, + { + name: "legacy-proposal-literal-reference-no-prose", + code: 'const entrypoint = "webhookRoute"; const label = "req";\n', + options: { + relativePath: "src/generation/webhook-literal-reference-no-prose.js", + absolutePath: "/src/generation/webhook-literal-reference-no-prose.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 1, + column: 21, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 21, + }, + ], + }, + { + name: "legacy-proposal-line-comment-fake-actual", + code: '// export async function POST(request) { return request.body; }\nexport const makeSource = () => "const payload = req.body;";\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-line-comment-fake-actual.js", + absolutePath: "/src/generation/webhook-line-comment-fake-actual.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 3, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-block-comment-fake-actual", + code: '/* webhookHandler(req) */\nexport const makeSource = () => "const payload = req.body;";\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-block-comment-fake-actual.js", + absolutePath: "/src/generation/webhook-block-comment-fake-actual.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 3, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-division-next-to-real-entrypoint", + code: "const \u5408\u8a08 = 8 / 2;\nexport const webhookHandler = (req) => req.body / \u5408\u8a08;\n", + options: { + relativePath: "src/generation/webhook-division-next-to-real-entrypoint.js", + absolutePath: "/src/generation/webhook-division-next-to-real-entrypoint.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 2, + column: 14, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 14, + }, + ], + }, + { + name: "legacy-proposal-regexp-body-then-real-entrypoint", + code: "const words = /webhookHandler req/;\nexport const webhookHandler = (req) => req.body;\n", + options: { + relativePath: "src/generation/webhook-regexp-body-then-real-entrypoint.js", + absolutePath: "/src/generation/webhook-regexp-body-then-real-entrypoint.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [ + { + line: 2, + column: 14, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 16, + }, + ], + }, + { + name: "legacy-proposal-tagged-template-boundary", + code: "const code = compile`webhookHandler req`;\n", + options: { + relativePath: "src/generation/webhook-tagged-template-boundary.js", + absolutePath: "/src/generation/webhook-tagged-template-boundary.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 22, + }, + ], + }, + { + name: "legacy-proposal-opaque-generated-text-transport", + code: 'const code = "export const webhookHandler = req => req.body";\nconsumeGeneratedSource(code);\n', + options: { + relativePath: "src/generation/webhook-opaque-generated-text-transport.js", + absolutePath: "/src/generation/webhook-opaque-generated-text-transport.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "legacy-proposal-same-code-different-production-path", + code: 'export const makeSource = () => "const payload = req.body;";\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/runtime/webhook-routes.js", + absolutePath: "/src/runtime/webhook-routes.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 33, + }, + ], + }, + { + name: "legacy-proposal-generated-file-metadata-boundary", + code: 'export const makeSource = () => "const payload = req.body;";\nconsole.error("Error writing to webhookHandler output:");\n', + options: { + relativePath: "src/generation/webhook-generated-file-metadata-boundary.js", + absolutePath: "/src/generation/webhook-generated-file-metadata-boundary.js", + isGeneratedBundle: false, + execution: "scan", + reactProfileNotConsultedByDirectScan: true, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 33, + }, + ], + }, + { + name: "ast-position-contextual-of-division", + code: "const req={};const of=8;const output=of / webhookHandler(req) /2;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-00.js", + relativePath: "src/webhooks/case-00.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 43, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 43, + }, + ], + }, + { + name: "ast-position-spaced-property-keyword", + code: "const req={};const object={return:8};const output=object. return / webhookHandler(req) /2;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-01.js", + relativePath: "src/webhooks/case-01.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 68, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 68, + }, + ], + }, + { + name: "ast-position-repeated-ts-nonnull", + code: "const req={};declare const value:number;const output=value!! / webhookHandler(req) /2;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-02.ts", + relativePath: "src/webhooks/case-02.ts", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 64, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 64, + }, + ], + }, + { + name: "ast-position-astral-division-unchanged", + code: "const req={};const \ud801\udc00=8;const output=\ud801\udc00 / webhookHandler(req) /2;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-03.js", + relativePath: "src/webhooks/case-03.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 43, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 43, + }, + ], + }, + { + name: "ast-position-escaped-identifier-keyword-suffix", + code: "const req={};const \\u{10400}return=8;const output=\\u{10400}return / webhookHandler(req) /2;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-04.js", + relativePath: "src/webhooks/case-04.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 69, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 69, + }, + ], + }, + { + name: "ast-position-prose-only", + code: 'const req={};console.error("Error writing webhookHandler file",req);\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-05.js", + relativePath: "src/webhooks/case-05.js", + isGeneratedBundle: false, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 43, + }, + ], + }, + { + name: "ast-position-prose-before-real", + code: 'const req={};console.error("webhookHandler prose");webhookHandler(req);\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-06.js", + relativePath: "src/webhooks/case-06.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 52, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 29, + }, + ], + }, + { + name: "ast-position-regex-before-real", + code: "const req={};const pattern=/webhookHandler(req)/;webhookRoute(req);\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-07.js", + relativePath: "src/webhooks/case-07.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 50, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 29, + }, + ], + }, + { + name: "ast-position-comment-before-real", + code: "const req={};/* webhookHandler prose */webhookRoute(req);\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-08.js", + relativePath: "src/webhooks/case-08.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 40, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 40, + }, + ], + }, + { + name: "ast-position-template-expression", + code: "const req={};const output=`webhookHandler prose ${webhookRoute(req)}`;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-09.js", + relativePath: "src/webhooks/case-09.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 51, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "ast-position-tagged-template-quasi", + code: "const req={};const output=tag`webhookHandler prose`;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-10.js", + relativePath: "src/webhooks/case-10.js", + isGeneratedBundle: false, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 31, + }, + ], + }, + { + name: "ast-position-single-token-literal", + code: 'const req={};const kind="webhookHandler";\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-11.js", + relativePath: "src/webhooks/case-11.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 26, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 26, + }, + ], + }, + { + name: "ast-position-single-token-specifier", + code: 'import "webhookHandler";const req={};\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-12.js", + relativePath: "src/webhooks/case-12.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 9, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 9, + }, + ], + }, + { + name: "ast-position-escaped-space-literal", + code: 'const req={};const text="prefix\\ webhookHandler";\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-13.js", + relativePath: "src/webhooks/case-13.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 34, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 34, + }, + ], + }, + { + name: "ast-position-escaped-quote-literal", + code: 'const req={};const text="prefix\\" webhookHandler prose";\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-14.js", + relativePath: "src/webhooks/case-14.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 35, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 35, + }, + ], + }, + { + name: "ast-position-type-alias-retention", + code: "const req={};type webhookHandler = string;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-15.ts", + relativePath: "src/webhooks/case-15.ts", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 19, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 19, + }, + ], + }, + { + name: "ast-position-pruned-annotation-fallback", + code: "const req={};declare const value:webhookHandler;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-16.ts", + relativePath: "src/webhooks/case-16.ts", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 34, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 34, + }, + ], + }, + { + name: "ast-position-pruned-return-fallback", + code: "const req={};const read=():webhookHandler=>req;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-17.ts", + relativePath: "src/webhooks/case-17.ts", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 28, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "ast-position-unknown-after-prose", + code: 'const req={};console.error("webhookHandler prose");declare const value:webhookRoute;\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-18.ts", + relativePath: "src/webhooks/case-18.ts", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 29, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 29, + }, + ], + }, + { + name: "ast-position-export-post", + code: "export async function POST(req){return req;}\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-19.js", + relativePath: "src/webhooks/case-19.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 1, + }, + ], + }, + { + name: "ast-position-export-const-handler", + code: "export const handler=(req)=>req;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-20.js", + relativePath: "src/webhooks/case-20.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 1, + }, + ], + }, + { + name: "ast-position-export-comments", + code: "export /* note */ const POST=(req)=>req;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-21.js", + relativePath: "src/webhooks/case-21.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 1, + }, + ], + }, + { + name: "ast-position-fake-export-prose", + code: 'const req={};const text="export const POST prose";\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-22.js", + relativePath: "src/webhooks/case-22.js", + isGeneratedBundle: false, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 26, + }, + ], + }, + { + name: "ast-position-parse-failure-preserve", + code: 'const req={};const broken=;console.error("webhookHandler prose");\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-23.js", + relativePath: "src/webhooks/case-23.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 43, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 43, + }, + ], + }, + { + name: "ast-position-private-name-unknown", + code: "const req={};class Store {#webhookHandler=1;read(){return this.#webhookHandler;}}\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-24.js", + relativePath: "src/webhooks/case-24.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 28, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 28, + }, + ], + }, + { + name: "ast-position-crlf-code-location", + code: 'const req={};\r\nconsole.error("webhookHandler prose");\r\nwebhookRoute(req);\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-25.js", + relativePath: "src/webhooks/case-25.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 3, + column: 1, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 2, + column: 16, + }, + ], + }, + { + name: "ast-position-unescaped-unicode-data-fallback", + code: 'const req={};console.error("caf\u00e9 webhookHandler prose");\n', + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-26.js", + relativePath: "src/webhooks/case-26.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 34, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 34, + }, + ], + }, + { + name: "ast-position-single-token-template", + code: "const req={};const text=`webhookHandler`;\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-27.js", + relativePath: "src/webhooks/case-27.js", + isGeneratedBundle: false, + }, + expectedLocations: [], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 26, + }, + ], + }, + { + name: "ast-position-regex-nested-character-class", + code: "const req={};const pattern=/[webhookHandler/]/;webhookRoute(req);\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-28.js", + relativePath: "src/webhooks/case-28.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 48, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 30, + }, + ], + }, + { + name: "ast-position-member-property-identifier", + code: "const req={};object.webhookHandler(req);\n", + options: { + absolutePath: "/private/react-doctor-synthetic/ast-entrypoint/src/webhooks/case-29.js", + relativePath: "src/webhooks/case-29.js", + isGeneratedBundle: false, + }, + expectedLocations: [ + { + line: 1, + column: 21, + }, + ], + legacyFindings: [ + { + message: "Webhook handler code does not show an obvious signature verification step.", + line: 1, + column: 21, + }, + ], + }, +]; + +describe("AST classified entrypoint positions", () => { + it.each(cases)("$name", ({ code, options, expectedLocations }) => { + const findings = webhookSignatureRisk.scan?.({ ...options, content: code }); + expect(findings?.map(({ line, column }) => ({ line, column }))).toEqual(expectedLocations); + }); + + it("retains the complete legacy finding when parsing returns null", () => { + const [scenario] = cases; + if (!scenario) throw new Error("Missing synthetic scenario"); + const parser = vi.spyOn(sourceParser, "parseSourceText").mockReturnValueOnce(null); + try { + expect(webhookSignatureRisk.scan?.({ ...scenario.options, content: scenario.code })).toEqual( + scenario.legacyFindings, + ); + expect(parser).toHaveBeenCalledWith({ + filename: scenario.options.absolutePath, + sourceText: scenario.code, + shouldAttachParentReferences: false, + }); + } finally { + parser.mockRestore(); + } + }); + + it("retains the complete legacy finding when parsing throws", () => { + const [scenario] = cases; + if (!scenario) throw new Error("Missing synthetic scenario"); + const parser = vi.spyOn(sourceParser, "parseSourceText").mockImplementationOnce(() => { + throw new Error("Parser unavailable"); + }); + try { + expect(webhookSignatureRisk.scan?.({ ...scenario.options, content: scenario.code })).toEqual( + scenario.legacyFindings, + ); + } finally { + parser.mockRestore(); + } + }); + + it("does not parse beyond the existing source byte budget", () => { + const [scenario] = cases; + if (!scenario) throw new Error("Missing synthetic scenario"); + const parser = vi.spyOn(sourceParser, "parseSourceText"); + try { + expect( + webhookSignatureRisk.scan?.({ + ...scenario.options, + content: scenario.code + " ".repeat(CROSS_FILE_PARSE_MAX_BYTES), + }), + ).toEqual(scenario.legacyFindings); + expect(parser).not.toHaveBeenCalled(); + } finally { + parser.mockRestore(); + } + }); + + it("does not parse non ASCII source", () => { + const scenario = cases.find( + (entry) => entry.name === "legacy-proposal-division-next-to-real-entrypoint", + ); + if (!scenario) throw new Error("Missing synthetic scenario"); + const parser = vi.spyOn(sourceParser, "parseSourceText"); + try { + expect(webhookSignatureRisk.scan?.({ ...scenario.options, content: scenario.code })).toEqual( + scenario.legacyFindings, + ); + expect(parser).not.toHaveBeenCalled(); + } finally { + parser.mockRestore(); + } + }); + + it("returns an old quiet gate without parsing", () => { + const scenario = cases.find( + (entry) => entry.name === "legacy-proposal-quoted-header-suppression-preserved", + ); + if (!scenario) throw new Error("Missing synthetic scenario"); + const parser = vi.spyOn(sourceParser, "parseSourceText"); + try { + expect(webhookSignatureRisk.scan?.({ ...scenario.options, content: scenario.code })).toEqual( + scenario.legacyFindings, + ); + expect(parser).not.toHaveBeenCalled(); + } finally { + parser.mockRestore(); + } + }); + + it("retains the original whole array when a later match is unclassified", () => { + const scenario = cases.find((entry) => entry.name === "ast-position-unknown-after-prose"); + if (!scenario) throw new Error("Missing synthetic scenario"); + expect(webhookSignatureRisk.scan?.({ ...scenario.options, content: scenario.code })).toEqual( + scenario.legacyFindings, + ); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.ts index f482b9bb66..1eefbd6825 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/webhook-signature-risk.ts @@ -1,3 +1,6 @@ +import { getClassifiedWebhookEntrypoint } from "./utils/get-classified-webhook-entrypoint.js"; +import { getLocationAtIndex } from "./utils/get-location-at-index.js"; +import { WEBHOOK_EMPTY_FINDING_COUNT } from "./utils/webhook-entrypoint/constants.js"; import { defineRule } from "../../utils/define-rule.js"; import { isProductionSourcePath } from "./utils/is-production-source-path.js"; import { scanByPattern } from "./utils/scan-by-pattern.js"; @@ -49,27 +52,37 @@ const REQUEST_READ_PATTERN = /\b(?:req|request)\b/; const COMMENT_OR_STRING_PATTERN = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g; +const scanWebhookEntrypoints = scanByPattern({ + shouldScan: (file) => { + if (!isProductionSourcePath(file.relativePath)) return false; + if (OUTBOUND_WEBHOOK_CONFIG_PATTERN.test(file.content)) return false; + const judgeableContent = file.content + .replace(COMMENT_OR_STRING_PATTERN, "") + .replace(OUTBOUND_WEBHOOK_URL_MENTION_PATTERN, ""); + return ( + WEBHOOK_HANDLER_PATTERN.test(file.relativePath) || + WEBHOOK_HANDLER_PATTERN.test(judgeableContent) + ); + }, + pattern: WEBHOOK_ENTRYPOINT_PATTERN, + requireAll: [REQUEST_READ_PATTERN], + suppressWhen: WEBHOOK_SIGNATURE_VERIFICATION_PATTERN, + message: "Webhook handler code does not show an obvious signature verification step.", +}); + export const webhookSignatureRisk = defineRule({ id: "webhook-signature-risk", title: "Webhook handler lacks signature verification", severity: "warn", recommendation: "Verify provider signatures before parsing or acting on webhook bodies. Use provider SDK helpers or HMAC verification with timing-safe comparison.", - scan: scanByPattern({ - shouldScan: (file) => { - if (!isProductionSourcePath(file.relativePath)) return false; - if (OUTBOUND_WEBHOOK_CONFIG_PATTERN.test(file.content)) return false; - const judgeableContent = file.content - .replace(COMMENT_OR_STRING_PATTERN, "") - .replace(OUTBOUND_WEBHOOK_URL_MENTION_PATTERN, ""); - return ( - WEBHOOK_HANDLER_PATTERN.test(file.relativePath) || - WEBHOOK_HANDLER_PATTERN.test(judgeableContent) - ); - }, - pattern: WEBHOOK_ENTRYPOINT_PATTERN, - requireAll: [REQUEST_READ_PATTERN], - suppressWhen: WEBHOOK_SIGNATURE_VERIFICATION_PATTERN, - message: "Webhook handler code does not show an obvious signature verification step.", - }), + scan: (file) => { + const findings = scanWebhookEntrypoints(file); + if (findings.length === WEBHOOK_EMPTY_FINDING_COUNT) return findings; + const entrypoint = getClassifiedWebhookEntrypoint(file, WEBHOOK_ENTRYPOINT_PATTERN); + if (!entrypoint) return findings; + if (entrypoint.index === null) return []; + const location = getLocationAtIndex(file.content, entrypoint.index); + return findings.map((finding) => ({ ...finding, ...location })); + }, }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts index c328a49d12..93e63c999c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts @@ -1,8 +1,37 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noMutateThenSetOrReturnSameReference } from "./no-mutate-then-set-or-return-same-reference.js"; -const MEASUREMENT_SAMPLE_COUNT = 3; +interface PairingStressFixture { + readonly source: string; + readonly pairingSiteBudget: number; +} + +interface PairingWorkMeasurement { + readonly coexistenceCalls: number; + readonly reachabilityCalls: number; +} + +const coexistenceCounter = vi.hoisted(() => vi.fn()); +const reachabilityCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/nodes-can-co-execute.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + nodesCanCoExecute: coexistenceCounter.mockImplementation(originalModule.nodesCanCoExecute), + }; +}); + +vi.mock("../../utils/can-node-reach-node.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + canNodeReachNode: reachabilityCounter.mockImplementation(originalModule.canNodeReachNode), + }; +}); describe("no-mutate-then-set-or-return-same-reference", () => { it("flags setX(state.add(index)) on a state Set", () => { @@ -561,7 +590,7 @@ describe("no-mutate-then-set-or-return-same-reference", () => { }); it("amortizes mutation and same-reference pairing by control-flow block", () => { - const buildSource = (pairCount: number): string => { + const buildSource = (pairCount: number): PairingStressFixture => { const mutations = "items.push(1);".repeat(pairCount); const conditions = Array.from( { length: pairCount }, @@ -579,21 +608,48 @@ describe("no-mutate-then-set-or-return-same-reference", () => { return `(${buildBalancedResultExpression(startIndex, middleIndex)}||${buildBalancedResultExpression(middleIndex, endIndex)})`; }; const sameReferenceResults = buildBalancedResultExpression(0, resultExpressions.length); - return `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{if(flag){${mutations}}if(!flag){return ${sameReferenceResults}}return [...items]})}`; + const mutationRegions = [`if(flag){${mutations}}`]; + const returnSites = [`if(!flag){return ${sameReferenceResults}}`, "return [...items]"]; + return { + source: `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{${mutationRegions.join("")}${returnSites.join("")}})}`, + pairingSiteBudget: mutationRegions.length * returnSites.length, + }; }; - runRule(noMutateThenSetOrReturnSameReference, buildSource(200)); - const measureFastestDuration = (pairCount: number): number => { - let fastestDuration = Number.POSITIVE_INFINITY; - for (let repetition = 0; repetition < MEASUREMENT_SAMPLE_COUNT; repetition += 1) { - const startedAt = performance.now(); - const result = runRule(noMutateThenSetOrReturnSameReference, buildSource(pairCount)); - fastestDuration = Math.min(fastestDuration, performance.now() - startedAt); - expect(result.diagnostics).toHaveLength(0); - } - return fastestDuration; + const measurePairingWork = ( + source: string, + expectedDiagnosticCount: number, + ): PairingWorkMeasurement => { + coexistenceCounter.mockClear(); + reachabilityCounter.mockClear(); + const result = runRule(noMutateThenSetOrReturnSameReference, source); + expect(result.parseErrors).toHaveLength(0); + expect(result.diagnostics).toHaveLength(expectedDiagnosticCount); + return { + coexistenceCalls: coexistenceCounter.mock.calls.length, + reachabilityCalls: reachabilityCounter.mock.calls.length, + }; }; - const smallDuration = measureFastestDuration(800); - const largeDuration = measureFastestDuration(4_000); - expect(largeDuration).toBeLessThan(smallDuration * 18); + const positive = measurePairingWork( + "const C=()=>{const[,setItems]=useState([]);setItems(items=>{items.push(1);return items})}", + 1, + ); + expect(positive.coexistenceCalls).toBeGreaterThan(0); + expect(positive.reachabilityCalls).toBeGreaterThan(0); + + const smallFixture = buildSource(800); + const small = measurePairingWork(smallFixture.source, 0); + expect(small.coexistenceCalls).toBeGreaterThan(0); + expect(small.coexistenceCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + expect(small.reachabilityCalls).toBeGreaterThan(0); + expect(small.reachabilityCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + + const largeFixture = buildSource(4_000); + const large = measurePairingWork(largeFixture.source, 0); + expect(large.coexistenceCalls).toBeGreaterThan(0); + expect(large.coexistenceCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.reachabilityCalls).toBeGreaterThan(0); + expect(large.reachabilityCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.coexistenceCalls).toBe(small.coexistenceCalls); + expect(large.reachabilityCalls).toBe(small.reachabilityCalls); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/webhook-position-proof/constants.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/webhook-position-proof/constants.ts new file mode 100644 index 0000000000..d385c42e54 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/webhook-position-proof/constants.ts @@ -0,0 +1,6 @@ +export const WEBHOOK_POSITION_PROOF_COUNTS = [20, 100]; +export const WEBHOOK_POSITION_PROOF_PARSE_CALL_COUNT = 1; +export const WEBHOOK_POSITION_PROOF_QUOTED_SLICE_BASE_COUNT = 1; +export const WEBHOOK_POSITION_PROOF_QUOTED_SLICES_PER_ENTRY = 2; +export const WEBHOOK_POSITION_PROOF_TEMPLATE_SLICES_PER_ENTRY = 3; +export const WEBHOOK_POSITION_PROOF_NESTED_SLICE_COUNT = 3;