Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ast-entrypoint-source-positions.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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");
2 changes: 2 additions & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <output>{fuzzZustandValue}</output>; };`,
`import { create as createFuzzZustandGetStore } from "zustand"; const useFuzzZustandGetStore = createFuzzZustandGetStore((_set, get) => ({ count: get().count }));`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number, RegExpMatchArray>();
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;
}
};
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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([]);
});
}
}
});
Loading
Loading