@api-doctor/cli 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +102 -6
- package/dist/cli.cjs +6686 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.mjs +6667 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/plugin.d.ts +1316 -0
- package/dist/plugin.js +4812 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +64 -15
- package/skills/api-doctor/SKILL.md +113 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,4812 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var PLUGIN_NAME = "@api-doctor/cli";
|
|
3
|
+
|
|
4
|
+
// src/providers/resend/rules/webhook-signature.ts
|
|
5
|
+
var rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Resend webhook handlers must verify signatures before processing payloads",
|
|
10
|
+
category: "security",
|
|
11
|
+
cwe: "CWE-345",
|
|
12
|
+
owasp: "API2:2023 Broken Authentication",
|
|
13
|
+
rationale: "Webhook endpoints are public URLs, so anyone who learns the path can POST a forged payload. Without verifying the Svix signature first, an attacker can fake delivery, bounce, or complaint events and drive your application into the wrong state. Validating the signature against your webhook secret before reading the body ensures the event genuinely came from Resend.",
|
|
14
|
+
docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction#verify-webhook-signatures",
|
|
15
|
+
recommended: true
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
missingVerification: "This webhook handler processes Resend events without verifying the signature first."
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
// eslint-style rule API: `create(context)` returns visitor map.
|
|
22
|
+
create(context) {
|
|
23
|
+
let importsResend = false;
|
|
24
|
+
const svixImports = /* @__PURE__ */ new Set();
|
|
25
|
+
const postHandlers = [];
|
|
26
|
+
function nodeStartPos(n) {
|
|
27
|
+
const rangeStart = typeof n?.range?.[0] === "number" ? n.range[0] : null;
|
|
28
|
+
const line = n?.loc?.start?.line ?? 1;
|
|
29
|
+
const column = n?.loc?.start?.column ?? 0;
|
|
30
|
+
const offset = rangeStart ?? line * 1e6 + column;
|
|
31
|
+
return { offset, line, column };
|
|
32
|
+
}
|
|
33
|
+
function nodeEndPos(n) {
|
|
34
|
+
const rangeEnd = typeof n?.range?.[1] === "number" ? n.range[1] : null;
|
|
35
|
+
const line = n?.loc?.end?.line ?? n?.loc?.start?.line ?? 1;
|
|
36
|
+
const column = n?.loc?.end?.column ?? n?.loc?.start?.column ?? 0;
|
|
37
|
+
const offset = rangeEnd ?? line * 1e6 + column;
|
|
38
|
+
return { offset, line, column };
|
|
39
|
+
}
|
|
40
|
+
function within(handler, n) {
|
|
41
|
+
const p = nodeStartPos(n);
|
|
42
|
+
return p.offset >= handler.start.offset && p.offset <= handler.end.offset;
|
|
43
|
+
}
|
|
44
|
+
function isExportedPostHandler(fnNode) {
|
|
45
|
+
if (fnNode?.type === "FunctionDeclaration") return fnNode?.id?.name === "POST";
|
|
46
|
+
return fnNode?.type === "ArrowFunctionExpression" || fnNode?.type === "FunctionExpression";
|
|
47
|
+
}
|
|
48
|
+
function getHandlerFromExportNamedDeclaration(node) {
|
|
49
|
+
const handlers = [];
|
|
50
|
+
const decl = node?.declaration;
|
|
51
|
+
if (!decl) return handlers;
|
|
52
|
+
if (decl.type === "FunctionDeclaration") {
|
|
53
|
+
if (decl.id?.name === "POST") {
|
|
54
|
+
handlers.push({
|
|
55
|
+
node: decl,
|
|
56
|
+
start: nodeStartPos(decl),
|
|
57
|
+
end: nodeEndPos(decl),
|
|
58
|
+
firstBodyPos: void 0,
|
|
59
|
+
firstVerifyPos: void 0
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return handlers;
|
|
63
|
+
}
|
|
64
|
+
if (decl.type === "VariableDeclaration") {
|
|
65
|
+
for (const d of decl.declarations ?? []) {
|
|
66
|
+
const idName = d?.id?.type === "Identifier" ? d.id.name : null;
|
|
67
|
+
if (idName !== "POST") continue;
|
|
68
|
+
const init = d?.init;
|
|
69
|
+
if (!init) continue;
|
|
70
|
+
if (!isExportedPostHandler(init)) continue;
|
|
71
|
+
handlers.push({
|
|
72
|
+
node: init,
|
|
73
|
+
start: nodeStartPos(init),
|
|
74
|
+
end: nodeEndPos(init),
|
|
75
|
+
firstBodyPos: void 0,
|
|
76
|
+
firstVerifyPos: void 0
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return handlers;
|
|
81
|
+
}
|
|
82
|
+
function isReqJsonCall(n) {
|
|
83
|
+
if (n?.type !== "CallExpression") return false;
|
|
84
|
+
const callee = n.callee;
|
|
85
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
86
|
+
const prop = callee.property;
|
|
87
|
+
if (prop?.type !== "Identifier" || prop.name !== "json") return false;
|
|
88
|
+
const obj = callee.object;
|
|
89
|
+
return obj?.type === "Identifier" && (obj.name === "req" || obj.name === "request");
|
|
90
|
+
}
|
|
91
|
+
function isBodyMember(n) {
|
|
92
|
+
if (n?.type !== "MemberExpression") return false;
|
|
93
|
+
const prop = n.property;
|
|
94
|
+
if (prop?.type !== "Identifier" || prop.name !== "body") return false;
|
|
95
|
+
const obj = n.object;
|
|
96
|
+
return obj?.type === "Identifier" && (obj.name === "req" || obj.name === "request");
|
|
97
|
+
}
|
|
98
|
+
function isCryptoCreateHmacCall(n) {
|
|
99
|
+
if (n?.type !== "CallExpression") return false;
|
|
100
|
+
const callee = n.callee;
|
|
101
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
102
|
+
const prop = callee.property;
|
|
103
|
+
if (prop?.type !== "Identifier" || prop.name !== "createHmac") return false;
|
|
104
|
+
return callee.object?.type === "Identifier" || callee.object?.type === "MemberExpression";
|
|
105
|
+
}
|
|
106
|
+
function isSvixVerifyCall(n) {
|
|
107
|
+
if (n?.type !== "CallExpression") return false;
|
|
108
|
+
const callee = n.callee;
|
|
109
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
110
|
+
const prop = callee.property;
|
|
111
|
+
if (prop?.type !== "Identifier" || prop.name !== "verify") return false;
|
|
112
|
+
const obj = callee.object;
|
|
113
|
+
return obj?.type === "Identifier" && svixImports.has(obj.name);
|
|
114
|
+
}
|
|
115
|
+
function recordFirst(posKey, handler, pos) {
|
|
116
|
+
const existing = handler[posKey];
|
|
117
|
+
if (!existing) {
|
|
118
|
+
handler[posKey] = pos;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (pos.offset < existing.offset) {
|
|
122
|
+
handler[posKey] = pos;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
ImportDeclaration(node) {
|
|
127
|
+
const importSource = node?.source?.value;
|
|
128
|
+
if (importSource === "resend") importsResend = true;
|
|
129
|
+
if (importSource === "svix") {
|
|
130
|
+
for (const s of node.specifiers ?? []) {
|
|
131
|
+
if (s?.type === "ImportSpecifier" && s.local?.type === "Identifier") {
|
|
132
|
+
svixImports.add(s.local.name);
|
|
133
|
+
}
|
|
134
|
+
if ((s?.type === "ImportDefaultSpecifier" || s?.type === "ImportNamespaceSpecifier") && s.local?.type === "Identifier") {
|
|
135
|
+
svixImports.add(s.local.name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
ExportNamedDeclaration(node) {
|
|
141
|
+
const handlers = getHandlerFromExportNamedDeclaration(node);
|
|
142
|
+
for (const h of handlers) postHandlers.push(h);
|
|
143
|
+
},
|
|
144
|
+
CallExpression(node) {
|
|
145
|
+
if (postHandlers.length === 0) return;
|
|
146
|
+
const pos = nodeStartPos(node);
|
|
147
|
+
for (const handler of postHandlers) {
|
|
148
|
+
if (!within(handler, node)) continue;
|
|
149
|
+
if (isReqJsonCall(node)) recordFirst("firstBodyPos", handler, pos);
|
|
150
|
+
if (isSvixVerifyCall(node)) recordFirst("firstVerifyPos", handler, pos);
|
|
151
|
+
if (isCryptoCreateHmacCall(node)) recordFirst("firstVerifyPos", handler, pos);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
MemberExpression(node) {
|
|
155
|
+
if (postHandlers.length === 0) return;
|
|
156
|
+
if (!isBodyMember(node)) return;
|
|
157
|
+
const pos = nodeStartPos(node);
|
|
158
|
+
for (const handler of postHandlers) {
|
|
159
|
+
if (!within(handler, node)) continue;
|
|
160
|
+
recordFirst("firstBodyPos", handler, pos);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"Program:exit"() {
|
|
164
|
+
if (!importsResend) return;
|
|
165
|
+
for (const handler of postHandlers) {
|
|
166
|
+
if (!handler.firstVerifyPos) {
|
|
167
|
+
context.report({ node: handler.node, messageId: "missingVerification" });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (handler.firstBodyPos && handler.firstVerifyPos.offset > handler.firstBodyPos.offset) {
|
|
171
|
+
context.report({ node: handler.node, messageId: "missingVerification" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
var resendWebhookSignatureRule = rule;
|
|
179
|
+
|
|
180
|
+
// src/providers/resend/rules/api-key-hardcoded.ts
|
|
181
|
+
var RESEND_KEY_PATTERN = /\bre_[A-Za-z0-9_]+/;
|
|
182
|
+
var rule2 = {
|
|
183
|
+
meta: {
|
|
184
|
+
type: "problem",
|
|
185
|
+
docs: {
|
|
186
|
+
description: "Resend API keys must not be hardcoded; load them from environment variables",
|
|
187
|
+
category: "security",
|
|
188
|
+
cwe: "CWE-798",
|
|
189
|
+
owasp: "API8:2023 Security Misconfiguration",
|
|
190
|
+
rationale: "A hardcoded API key gets committed to version control, where it lives in git history forever and is exposed to anyone with repository access. Leaked Resend keys let attackers send mail from your domain, damaging sender reputation and deliverability. Reading the key from process.env.RESEND_API_KEY keeps the secret out of source code and lets you rotate it without a redeploy.",
|
|
191
|
+
docsUrl: "https://resend.com/docs/send-with-nextjs#prerequisites",
|
|
192
|
+
recommended: true
|
|
193
|
+
},
|
|
194
|
+
messages: {
|
|
195
|
+
hardcodedApiKey: "Hardcoded Resend API key detected. Load the key from process.env.RESEND_API_KEY instead."
|
|
196
|
+
},
|
|
197
|
+
schema: []
|
|
198
|
+
},
|
|
199
|
+
create(context) {
|
|
200
|
+
return {
|
|
201
|
+
Literal(node) {
|
|
202
|
+
if (typeof node.value !== "string") return;
|
|
203
|
+
if (RESEND_KEY_PATTERN.test(node.value)) {
|
|
204
|
+
context.report({ node, messageId: "hardcodedApiKey" });
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
TemplateElement(node) {
|
|
208
|
+
const cooked = node?.value?.cooked ?? node?.value?.raw;
|
|
209
|
+
if (typeof cooked !== "string") return;
|
|
210
|
+
if (RESEND_KEY_PATTERN.test(cooked)) {
|
|
211
|
+
context.report({ node, messageId: "hardcodedApiKey" });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
var resendApiKeyHardcodedRule = rule2;
|
|
218
|
+
|
|
219
|
+
// src/providers/resend/rules/api-key-in-client-bundle.ts
|
|
220
|
+
function isComponentsPath(filename) {
|
|
221
|
+
return /[\\/]components[\\/]/.test(filename);
|
|
222
|
+
}
|
|
223
|
+
var rule3 = {
|
|
224
|
+
meta: {
|
|
225
|
+
type: "problem",
|
|
226
|
+
docs: {
|
|
227
|
+
description: "The Resend SDK must not be imported into client-bundled code",
|
|
228
|
+
category: "security",
|
|
229
|
+
cwe: "CWE-200",
|
|
230
|
+
owasp: "API8:2023 Security Misconfiguration",
|
|
231
|
+
rationale: 'The Resend SDK is server-only and is initialized with your secret API key. Importing it into a "use client" component or other browser-bundled code ships that key to every visitor, where it can be read straight from the page source. Keeping Resend imports in server code (route handlers, server actions, server components) ensures the key never reaches the client.',
|
|
232
|
+
docsUrl: "https://resend.com/docs/send-with-nextjs",
|
|
233
|
+
recommended: true
|
|
234
|
+
},
|
|
235
|
+
messages: {
|
|
236
|
+
clientBundleImport: "Resend is imported into client-bundled code. Keep Resend (and its API key) on the server."
|
|
237
|
+
},
|
|
238
|
+
schema: []
|
|
239
|
+
},
|
|
240
|
+
create(context) {
|
|
241
|
+
let resendImportNode = null;
|
|
242
|
+
let hasUseClient = false;
|
|
243
|
+
let hasJsx = false;
|
|
244
|
+
return {
|
|
245
|
+
Program(node) {
|
|
246
|
+
for (const stmt of node.body ?? []) {
|
|
247
|
+
if (stmt?.type !== "ExpressionStatement") continue;
|
|
248
|
+
const directive = stmt.directive ?? (stmt.expression?.type === "Literal" ? stmt.expression.value : void 0);
|
|
249
|
+
if (directive === "use client") {
|
|
250
|
+
hasUseClient = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
ImportDeclaration(node) {
|
|
256
|
+
if (node?.source?.value !== "resend") return;
|
|
257
|
+
if (node.importKind === "type") return;
|
|
258
|
+
const allSpecifiersAreType = Array.isArray(node.specifiers) && node.specifiers.length > 0 && node.specifiers.every((s) => s.importKind === "type");
|
|
259
|
+
if (allSpecifiersAreType) return;
|
|
260
|
+
resendImportNode = node;
|
|
261
|
+
},
|
|
262
|
+
JSXElement() {
|
|
263
|
+
hasJsx = true;
|
|
264
|
+
},
|
|
265
|
+
JSXFragment() {
|
|
266
|
+
hasJsx = true;
|
|
267
|
+
},
|
|
268
|
+
"Program:exit"() {
|
|
269
|
+
if (!resendImportNode) return;
|
|
270
|
+
const inClientBundle = hasUseClient || isComponentsPath(String(context.filename ?? "")) && hasJsx;
|
|
271
|
+
if (inClientBundle) {
|
|
272
|
+
context.report({ node: resendImportNode, messageId: "clientBundleImport" });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
var resendApiKeyInClientBundleRule = rule3;
|
|
279
|
+
|
|
280
|
+
// src/providers/resend/utils.ts
|
|
281
|
+
function isResendEmailsSendCall(node) {
|
|
282
|
+
if (node?.type !== "CallExpression") return false;
|
|
283
|
+
const callee = node.callee;
|
|
284
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
285
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "send") return false;
|
|
286
|
+
const obj = callee.object;
|
|
287
|
+
return obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "emails";
|
|
288
|
+
}
|
|
289
|
+
function isResendBatchSendCall(node) {
|
|
290
|
+
if (node?.type !== "CallExpression") return false;
|
|
291
|
+
const callee = node.callee;
|
|
292
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
293
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "send") return false;
|
|
294
|
+
const obj = callee.object;
|
|
295
|
+
return obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "batch";
|
|
296
|
+
}
|
|
297
|
+
function isResendSendCall(node) {
|
|
298
|
+
return isResendEmailsSendCall(node) || isResendBatchSendCall(node);
|
|
299
|
+
}
|
|
300
|
+
function getObjectArg(node, index) {
|
|
301
|
+
const arg = node?.arguments?.[index];
|
|
302
|
+
return arg?.type === "ObjectExpression" ? arg : null;
|
|
303
|
+
}
|
|
304
|
+
function getSendOptionObjects(node) {
|
|
305
|
+
if (isResendEmailsSendCall(node)) {
|
|
306
|
+
const opts = getObjectArg(node, 0);
|
|
307
|
+
return opts ? [opts] : [];
|
|
308
|
+
}
|
|
309
|
+
if (isResendBatchSendCall(node)) {
|
|
310
|
+
const arr = node?.arguments?.[0];
|
|
311
|
+
if (arr?.type !== "ArrayExpression") return [];
|
|
312
|
+
return (arr.elements ?? []).filter((el) => el?.type === "ObjectExpression");
|
|
313
|
+
}
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
function findProperty(objectExpression, name) {
|
|
317
|
+
if (objectExpression?.type !== "ObjectExpression") return void 0;
|
|
318
|
+
return objectExpression.properties?.find(
|
|
319
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
function isInsideTestFile(filename) {
|
|
323
|
+
return /(^|[\\/])__tests__[\\/]|\.(test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
324
|
+
}
|
|
325
|
+
function startOffset(n) {
|
|
326
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
327
|
+
if (typeof n?.start === "number") return n.start;
|
|
328
|
+
return (n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.start?.column ?? 0);
|
|
329
|
+
}
|
|
330
|
+
function endOffset(n) {
|
|
331
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
332
|
+
if (typeof n?.end === "number") return n.end;
|
|
333
|
+
return (n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.end?.column ?? 0);
|
|
334
|
+
}
|
|
335
|
+
function contains(outer, inner) {
|
|
336
|
+
const s = startOffset(inner);
|
|
337
|
+
return s >= startOffset(outer) && s <= endOffset(outer);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/providers/resend/rules/marketing-via-batch-send.ts
|
|
341
|
+
var MARKETING_PATH = /marketing|campaign|newsletter|promotion|broadcast/i;
|
|
342
|
+
var rule4 = {
|
|
343
|
+
meta: {
|
|
344
|
+
type: "problem",
|
|
345
|
+
docs: {
|
|
346
|
+
description: "Marketing emails should use the Broadcasts API, not resend.batch.send",
|
|
347
|
+
category: "correctness",
|
|
348
|
+
rationale: "resend.batch.send is the transactional batch API; Resend documents Broadcasts as the correct feature for marketing and campaign sends. Using batch send for promotional mail skips audience management, consent tracking, and the automatic unsubscribe handling that Broadcasts provide, which puts you out of step with CAN-SPAM/CASL. Sending campaigns through Broadcasts (or the Dashboard) keeps deliverability and compliance intact.",
|
|
349
|
+
docsUrl: "https://resend.com/docs/dashboard/emails/batch-sending",
|
|
350
|
+
recommended: true
|
|
351
|
+
},
|
|
352
|
+
messages: {
|
|
353
|
+
marketingViaBatch: "Marketing/campaign email sent via resend.batch.send. Use the Broadcasts API for marketing sends."
|
|
354
|
+
},
|
|
355
|
+
schema: []
|
|
356
|
+
},
|
|
357
|
+
create(context) {
|
|
358
|
+
const isMarketingFile = MARKETING_PATH.test(String(context.filename ?? ""));
|
|
359
|
+
return {
|
|
360
|
+
CallExpression(node) {
|
|
361
|
+
if (!isMarketingFile) return;
|
|
362
|
+
if (isResendBatchSendCall(node)) {
|
|
363
|
+
context.report({ node, messageId: "marketingViaBatch" });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
var resendMarketingViaBatchSendRule = rule4;
|
|
370
|
+
|
|
371
|
+
// src/providers/resend/rules/marketing-missing-unsubscribe.ts
|
|
372
|
+
var MARKETING_TAG = /marketing|campaign|newsletter|promotion/i;
|
|
373
|
+
var UNSUBSCRIBE_PLACEHOLDER = "{{{RESEND_UNSUBSCRIBE_URL}}}";
|
|
374
|
+
function literalString(node) {
|
|
375
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value;
|
|
376
|
+
if (node?.type === "TemplateLiteral") {
|
|
377
|
+
return (node.quasis ?? []).map((q) => q?.value?.cooked ?? q?.value?.raw ?? "").join(" ");
|
|
378
|
+
}
|
|
379
|
+
return void 0;
|
|
380
|
+
}
|
|
381
|
+
function hasMarketingTag(opts) {
|
|
382
|
+
const tagsProp = findProperty(opts, "tags");
|
|
383
|
+
const arr = tagsProp?.value;
|
|
384
|
+
if (arr?.type !== "ArrayExpression") return false;
|
|
385
|
+
for (const el of arr.elements ?? []) {
|
|
386
|
+
if (el?.type !== "ObjectExpression") continue;
|
|
387
|
+
const valueProp = findProperty(el, "value");
|
|
388
|
+
const v = literalString(valueProp?.value);
|
|
389
|
+
if (typeof v === "string" && MARKETING_TAG.test(v)) return true;
|
|
390
|
+
}
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
function hasListUnsubscribeHeader(opts) {
|
|
394
|
+
const headersProp = findProperty(opts, "headers");
|
|
395
|
+
const obj = headersProp?.value;
|
|
396
|
+
if (obj?.type !== "ObjectExpression") return false;
|
|
397
|
+
return (obj.properties ?? []).some((p) => {
|
|
398
|
+
if (p?.type !== "Property") return false;
|
|
399
|
+
const key = p.key?.type === "Literal" ? String(p.key.value) : p.key?.type === "Identifier" ? p.key.name : "";
|
|
400
|
+
return key.toLowerCase() === "list-unsubscribe";
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function htmlHasUnsubscribePlaceholder(opts) {
|
|
404
|
+
const htmlProp = findProperty(opts, "html");
|
|
405
|
+
const text = literalString(htmlProp?.value);
|
|
406
|
+
return typeof text === "string" && text.includes(UNSUBSCRIBE_PLACEHOLDER);
|
|
407
|
+
}
|
|
408
|
+
function hasUnsubscribeMechanism(opts) {
|
|
409
|
+
return hasListUnsubscribeHeader(opts) || htmlHasUnsubscribePlaceholder(opts);
|
|
410
|
+
}
|
|
411
|
+
var rule5 = {
|
|
412
|
+
meta: {
|
|
413
|
+
type: "problem",
|
|
414
|
+
docs: {
|
|
415
|
+
description: "Marketing emails must include an unsubscribe mechanism",
|
|
416
|
+
category: "correctness",
|
|
417
|
+
rationale: 'Marketing email is regulated by laws like CAN-SPAM (US) and CASL (Canada), which require recipients to be able to opt out. A campaign with only static "you opted in" text and no working unsubscribe path exposes you to legal penalties and gets flagged as spam, hurting deliverability for all your mail. Adding a List-Unsubscribe header (RFC 8058) or the {{{RESEND_UNSUBSCRIBE_URL}}} placeholder gives recipients a real way to opt out.',
|
|
418
|
+
docsUrl: "https://resend.com/docs/dashboard/broadcasts/introduction",
|
|
419
|
+
recommended: true
|
|
420
|
+
},
|
|
421
|
+
messages: {
|
|
422
|
+
missingUnsubscribe: "Marketing email has no unsubscribe mechanism (List-Unsubscribe header or {{{RESEND_UNSUBSCRIBE_URL}}})."
|
|
423
|
+
},
|
|
424
|
+
schema: []
|
|
425
|
+
},
|
|
426
|
+
create(context) {
|
|
427
|
+
return {
|
|
428
|
+
CallExpression(node) {
|
|
429
|
+
for (const opts of getSendOptionObjects(node)) {
|
|
430
|
+
if (hasMarketingTag(opts) && !hasUnsubscribeMechanism(opts)) {
|
|
431
|
+
context.report({ node, messageId: "missingUnsubscribe" });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
var resendMarketingMissingUnsubscribeRule = rule5;
|
|
440
|
+
|
|
441
|
+
// src/providers/resend/rules/test-domain-in-production-path.ts
|
|
442
|
+
var TEST_DOMAIN = "onboarding@resend.dev";
|
|
443
|
+
var rule6 = {
|
|
444
|
+
meta: {
|
|
445
|
+
type: "suggestion",
|
|
446
|
+
docs: {
|
|
447
|
+
description: "Do not use the onboarding@resend.dev test domain in production code",
|
|
448
|
+
category: "correctness",
|
|
449
|
+
rationale: 'onboarding@resend.dev is a shared test sender that only delivers to the account owner; sending from it to any other recipient returns a 403 error. Shipping it to production \u2014 including as a `?? "onboarding@resend.dev"` fallback \u2014 means real users silently never receive their email. Sending from a verified domain configured via process.env.RESEND_FROM_EMAIL is the documented production requirement.',
|
|
450
|
+
docsUrl: "https://resend.com/docs/send-with-nextjs",
|
|
451
|
+
recommended: true
|
|
452
|
+
},
|
|
453
|
+
messages: {
|
|
454
|
+
testDomain: "onboarding@resend.dev is a test-only sender. Use a verified domain (via process.env) in production."
|
|
455
|
+
},
|
|
456
|
+
schema: []
|
|
457
|
+
},
|
|
458
|
+
create(context) {
|
|
459
|
+
if (isInsideTestFile(String(context.filename ?? ""))) return {};
|
|
460
|
+
return {
|
|
461
|
+
Literal(node) {
|
|
462
|
+
if (typeof node.value === "string" && node.value.includes(TEST_DOMAIN)) {
|
|
463
|
+
context.report({ node, messageId: "testDomain" });
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
TemplateElement(node) {
|
|
467
|
+
const cooked = node?.value?.cooked ?? node?.value?.raw;
|
|
468
|
+
if (typeof cooked === "string" && cooked.includes(TEST_DOMAIN)) {
|
|
469
|
+
context.report({ node, messageId: "testDomain" });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
var resendTestDomainInProductionPathRule = rule6;
|
|
476
|
+
|
|
477
|
+
// src/providers/resend/rules/from-address-not-friendly-format.ts
|
|
478
|
+
var BARE_EMAIL = /^[^<>]+@[^<>]+$/;
|
|
479
|
+
var rule7 = {
|
|
480
|
+
meta: {
|
|
481
|
+
type: "suggestion",
|
|
482
|
+
docs: {
|
|
483
|
+
description: 'Resend from addresses should use the friendly-name format "Name <email>"',
|
|
484
|
+
category: "integration",
|
|
485
|
+
rationale: 'Every Resend doc example uses the friendly-name form "Acme <onboarding@acme.com>" rather than a bare email. A bare from address shows up in inboxes as a raw email string, which looks less trustworthy and can hurt open rates and brand recognition. Wrapping the address with a display name is a one-line change that matches the documented convention.',
|
|
486
|
+
docsUrl: "https://resend.com/docs/api-reference/emails/send-email",
|
|
487
|
+
recommended: true
|
|
488
|
+
},
|
|
489
|
+
messages: {
|
|
490
|
+
bareFromAddress: 'From address is a bare email. Use the friendly format "Name <email@domain>" as shown in the docs.'
|
|
491
|
+
},
|
|
492
|
+
schema: []
|
|
493
|
+
},
|
|
494
|
+
create(context) {
|
|
495
|
+
return {
|
|
496
|
+
CallExpression(node) {
|
|
497
|
+
for (const opts of getSendOptionObjects(node)) {
|
|
498
|
+
const fromProp = findProperty(opts, "from");
|
|
499
|
+
const value = fromProp?.value;
|
|
500
|
+
if (value?.type === "Literal" && typeof value.value === "string" && BARE_EMAIL.test(value.value.trim())) {
|
|
501
|
+
context.report({ node: value, messageId: "bareFromAddress" });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
var resendFromAddressNotFriendlyFormatRule = rule7;
|
|
509
|
+
|
|
510
|
+
// src/providers/resend/rules/batch-size-not-enforced.ts
|
|
511
|
+
var COMPARISON_OPERATORS = /* @__PURE__ */ new Set([">", ">=", "<", "<=", "===", "!==", "==", "!="]);
|
|
512
|
+
var rule8 = {
|
|
513
|
+
meta: {
|
|
514
|
+
type: "suggestion",
|
|
515
|
+
docs: {
|
|
516
|
+
description: "Enforce the 100-email batch limit before calling resend.batch.send",
|
|
517
|
+
category: "reliability",
|
|
518
|
+
rationale: "resend.batch.send accepts at most 100 emails per call. Passing a user- or data-driven array without a length guard means the request fails outright once the list grows past 100, so an entire batch of notifications silently never sends. Guarding the array length (or chunking it into <=100-sized slices) keeps the send reliable as volume scales.",
|
|
519
|
+
docsUrl: "https://resend.com/docs/api-reference/emails/send-batch-emails",
|
|
520
|
+
recommended: true
|
|
521
|
+
},
|
|
522
|
+
messages: {
|
|
523
|
+
batchSizeNotEnforced: "resend.batch.send has a 100-email limit. Guard the array length (e.g. if (items.length > 100)) before sending."
|
|
524
|
+
},
|
|
525
|
+
schema: []
|
|
526
|
+
},
|
|
527
|
+
create(context) {
|
|
528
|
+
const functions = [];
|
|
529
|
+
const loops = [];
|
|
530
|
+
const lengthChecks = /* @__PURE__ */ new Map();
|
|
531
|
+
const batchCalls = [];
|
|
532
|
+
function isLengthOfName(member) {
|
|
533
|
+
if (member?.type !== "MemberExpression") return null;
|
|
534
|
+
if (member.property?.type !== "Identifier" || member.property.name !== "length") return null;
|
|
535
|
+
if (member.object?.type !== "Identifier") return null;
|
|
536
|
+
return member.object.name;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
FunctionDeclaration(node) {
|
|
540
|
+
functions.push(node);
|
|
541
|
+
},
|
|
542
|
+
FunctionExpression(node) {
|
|
543
|
+
functions.push(node);
|
|
544
|
+
},
|
|
545
|
+
ArrowFunctionExpression(node) {
|
|
546
|
+
functions.push(node);
|
|
547
|
+
},
|
|
548
|
+
ForStatement(node) {
|
|
549
|
+
loops.push(node);
|
|
550
|
+
},
|
|
551
|
+
ForOfStatement(node) {
|
|
552
|
+
loops.push(node);
|
|
553
|
+
},
|
|
554
|
+
ForInStatement(node) {
|
|
555
|
+
loops.push(node);
|
|
556
|
+
},
|
|
557
|
+
WhileStatement(node) {
|
|
558
|
+
loops.push(node);
|
|
559
|
+
},
|
|
560
|
+
DoWhileStatement(node) {
|
|
561
|
+
loops.push(node);
|
|
562
|
+
},
|
|
563
|
+
BinaryExpression(node) {
|
|
564
|
+
if (!COMPARISON_OPERATORS.has(node.operator)) return;
|
|
565
|
+
for (const side of [node.left, node.right]) {
|
|
566
|
+
const name = isLengthOfName(side);
|
|
567
|
+
if (name) {
|
|
568
|
+
const list = lengthChecks.get(name) ?? [];
|
|
569
|
+
list.push(node);
|
|
570
|
+
lengthChecks.set(name, list);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
CallExpression(node) {
|
|
575
|
+
if (!isResendBatchSendCall(node)) return;
|
|
576
|
+
const arg = node.arguments?.[0];
|
|
577
|
+
if (arg?.type !== "Identifier") return;
|
|
578
|
+
batchCalls.push({ node, argName: arg.name });
|
|
579
|
+
},
|
|
580
|
+
"Program:exit"() {
|
|
581
|
+
for (const { node, argName } of batchCalls) {
|
|
582
|
+
if (loops.some((loop) => contains(loop, node))) continue;
|
|
583
|
+
const enclosing = functions.filter((fn) => contains(fn, node)).sort((a, b) => startOffset(b) - startOffset(a))[0];
|
|
584
|
+
const checks = lengthChecks.get(argName) ?? [];
|
|
585
|
+
const guarded = enclosing ? checks.some((chk) => contains(enclosing, chk)) : checks.length > 0;
|
|
586
|
+
if (!guarded) {
|
|
587
|
+
context.report({ node, messageId: "batchSizeNotEnforced" });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var resendBatchSizeNotEnforcedRule = rule8;
|
|
595
|
+
|
|
596
|
+
// src/providers/resend/rules/missing-idempotency-key.ts
|
|
597
|
+
var rule9 = {
|
|
598
|
+
meta: {
|
|
599
|
+
type: "suggestion",
|
|
600
|
+
docs: {
|
|
601
|
+
description: "Resend send/batch calls should include an idempotencyKey",
|
|
602
|
+
category: "reliability",
|
|
603
|
+
rationale: "Without an idempotency key, if a network retry or webhook redelivery occurs, Resend will send the email multiple times. This causes duplicate charges, duplicate user notifications, and damaged sender reputation. Adding an idempotency key (a unique string per logical operation, like `welcome/${userId}`) makes the send safely retryable.",
|
|
604
|
+
docsUrl: "https://resend.com/docs/send-with-nextjs",
|
|
605
|
+
recommended: true
|
|
606
|
+
},
|
|
607
|
+
messages: {
|
|
608
|
+
missingIdempotencyKey: "Resend send call has no idempotencyKey. Add one to prevent duplicate sends on retry."
|
|
609
|
+
},
|
|
610
|
+
schema: []
|
|
611
|
+
},
|
|
612
|
+
create(context) {
|
|
613
|
+
return {
|
|
614
|
+
CallExpression(node) {
|
|
615
|
+
if (!isResendSendCall(node)) return;
|
|
616
|
+
const hasKey = (node.arguments ?? []).some(
|
|
617
|
+
(arg) => arg?.type === "ObjectExpression" && findProperty(arg, "idempotencyKey")
|
|
618
|
+
);
|
|
619
|
+
if (!hasKey) {
|
|
620
|
+
context.report({ node, messageId: "missingIdempotencyKey" });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
var resendMissingIdempotencyKeyRule = rule9;
|
|
627
|
+
|
|
628
|
+
// src/providers/resend/rules/no-error-code-mapping.ts
|
|
629
|
+
var rule10 = {
|
|
630
|
+
meta: {
|
|
631
|
+
type: "suggestion",
|
|
632
|
+
docs: {
|
|
633
|
+
description: "Resend errors should map to appropriate HTTP status codes, not a blanket 500",
|
|
634
|
+
category: "reliability",
|
|
635
|
+
rationale: "Resend returns different error classes that callers must treat differently: 400/422 mean fix the params and do not retry, 401/403 mean fix the key or domain, and 429/500 mean retry with backoff. Collapsing all of them into a blanket HTTP 500 tells the client to retry errors that will never succeed and hides the real cause from logs and monitoring. Mapping the SDK error code to the right status makes the API honest and lets clients react correctly.",
|
|
636
|
+
docsUrl: "https://resend.com/docs/ai-onboarding",
|
|
637
|
+
recommended: true
|
|
638
|
+
},
|
|
639
|
+
messages: {
|
|
640
|
+
noErrorCodeMapping: "Resend errors are returned as a blanket HTTP 500. Map 400/401/403/422 to non-500 statuses."
|
|
641
|
+
},
|
|
642
|
+
schema: []
|
|
643
|
+
},
|
|
644
|
+
create(context) {
|
|
645
|
+
const functions = [];
|
|
646
|
+
const resendErrorBindings = [];
|
|
647
|
+
const ifErrorStatements = [];
|
|
648
|
+
const fiveHundredNodes = [];
|
|
649
|
+
function initIsResendSend(init) {
|
|
650
|
+
if (!init) return false;
|
|
651
|
+
const expr = init.type === "AwaitExpression" ? init.argument : init;
|
|
652
|
+
return isResendSendCall(expr);
|
|
653
|
+
}
|
|
654
|
+
function isNextResponse500(node) {
|
|
655
|
+
const callee = node.callee;
|
|
656
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
657
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "json") return false;
|
|
658
|
+
const opts = node.arguments?.[1];
|
|
659
|
+
const statusProp = findProperty(opts, "status");
|
|
660
|
+
return statusProp?.value?.type === "Literal" && statusProp.value.value === 500;
|
|
661
|
+
}
|
|
662
|
+
function isResStatus500(node) {
|
|
663
|
+
const callee = node.callee;
|
|
664
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
665
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "status") return false;
|
|
666
|
+
const arg = node.arguments?.[0];
|
|
667
|
+
return arg?.type === "Literal" && arg.value === 500;
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
FunctionDeclaration(node) {
|
|
671
|
+
functions.push(node);
|
|
672
|
+
},
|
|
673
|
+
FunctionExpression(node) {
|
|
674
|
+
functions.push(node);
|
|
675
|
+
},
|
|
676
|
+
ArrowFunctionExpression(node) {
|
|
677
|
+
functions.push(node);
|
|
678
|
+
},
|
|
679
|
+
VariableDeclarator(node) {
|
|
680
|
+
if (!initIsResendSend(node.init)) return;
|
|
681
|
+
if (node.id?.type !== "ObjectPattern") return;
|
|
682
|
+
const errorProp = (node.id.properties ?? []).find(
|
|
683
|
+
(p) => p?.type === "Property" && p.key?.type === "Identifier" && p.key.name === "error"
|
|
684
|
+
);
|
|
685
|
+
if (!errorProp) return;
|
|
686
|
+
const localName = errorProp.value?.type === "Identifier" ? errorProp.value.name : "error";
|
|
687
|
+
resendErrorBindings.push({ name: localName, pos: startOffset(node) });
|
|
688
|
+
},
|
|
689
|
+
IfStatement(node) {
|
|
690
|
+
if (node.test?.type === "Identifier") {
|
|
691
|
+
ifErrorStatements.push({ node, name: node.test.name, consequent: node.consequent });
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
CallExpression(node) {
|
|
695
|
+
if (isNextResponse500(node) || isResStatus500(node)) {
|
|
696
|
+
fiveHundredNodes.push(node);
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
"Program:exit"() {
|
|
700
|
+
for (const { node, name, consequent } of ifErrorStatements) {
|
|
701
|
+
const has500 = fiveHundredNodes.some((five) => contains(consequent, five));
|
|
702
|
+
if (!has500) continue;
|
|
703
|
+
const enclosing = functions.filter((fn) => contains(fn, node)).sort((a, b) => startOffset(b) - startOffset(a))[0];
|
|
704
|
+
const boundFromResend = resendErrorBindings.some(
|
|
705
|
+
(b) => b.name === name && (enclosing ? contains(enclosing, { range: [b.pos, b.pos] }) : true)
|
|
706
|
+
);
|
|
707
|
+
if (boundFromResend) {
|
|
708
|
+
context.report({ node, messageId: "noErrorCodeMapping" });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
var resendNoErrorCodeMappingRule = rule10;
|
|
716
|
+
|
|
717
|
+
// src/providers/resend/rules/webhook-no-idempotency.ts
|
|
718
|
+
var DEDUP_OBJECTS = /* @__PURE__ */ new Set(["redis", "kv", "db", "prisma", "supabase", "cache", "store"]);
|
|
719
|
+
var DEDUP_METHODS = /* @__PURE__ */ new Set([
|
|
720
|
+
"has",
|
|
721
|
+
"add",
|
|
722
|
+
"sadd",
|
|
723
|
+
"sismember",
|
|
724
|
+
"exists",
|
|
725
|
+
"findUnique",
|
|
726
|
+
"findFirst",
|
|
727
|
+
"upsert"
|
|
728
|
+
]);
|
|
729
|
+
var EVENT_ID_PROPS = /* @__PURE__ */ new Set(["email_id", "eventId", "event_id"]);
|
|
730
|
+
var rule11 = {
|
|
731
|
+
meta: {
|
|
732
|
+
type: "suggestion",
|
|
733
|
+
docs: {
|
|
734
|
+
description: "Resend webhook handlers should deduplicate retried events",
|
|
735
|
+
category: "reliability",
|
|
736
|
+
rationale: "Resend retries failed webhook deliveries for up to 24 hours, so the same event can legitimately arrive more than once. A handler that acts on every delivery without deduplication will double-process events \u2014 sending duplicate downstream notifications, double-counting metrics, or corrupting state. Tracking processed event ids (e.g. event.data.email_id) in a store or set and skipping ones already seen makes the handler safely idempotent.",
|
|
737
|
+
docsUrl: "https://resend.com/docs/dashboard/webhooks/introduction",
|
|
738
|
+
recommended: true
|
|
739
|
+
},
|
|
740
|
+
messages: {
|
|
741
|
+
noIdempotency: "Resend webhook handler has no deduplication. Resend retries for 24h; track processed event ids."
|
|
742
|
+
},
|
|
743
|
+
schema: []
|
|
744
|
+
},
|
|
745
|
+
create(context) {
|
|
746
|
+
let importsSvix = false;
|
|
747
|
+
const postHandlers = [];
|
|
748
|
+
const dedupSignals = [];
|
|
749
|
+
function collectPostHandler(decl) {
|
|
750
|
+
if (!decl) return;
|
|
751
|
+
if (decl.type === "FunctionDeclaration" && decl.id?.name === "POST") {
|
|
752
|
+
postHandlers.push(decl);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (decl.type === "VariableDeclaration") {
|
|
756
|
+
for (const d of decl.declarations ?? []) {
|
|
757
|
+
if (d?.id?.type === "Identifier" && d.id.name === "POST" && (d.init?.type === "ArrowFunctionExpression" || d.init?.type === "FunctionExpression")) {
|
|
758
|
+
postHandlers.push(d.init);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
ImportDeclaration(node) {
|
|
765
|
+
if (node?.source?.value === "svix") importsSvix = true;
|
|
766
|
+
},
|
|
767
|
+
ExportNamedDeclaration(node) {
|
|
768
|
+
collectPostHandler(node.declaration);
|
|
769
|
+
},
|
|
770
|
+
NewExpression(node) {
|
|
771
|
+
if (node.callee?.type === "Identifier" && (node.callee.name === "Map" || node.callee.name === "Set")) {
|
|
772
|
+
dedupSignals.push(node);
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
CallExpression(node) {
|
|
776
|
+
const callee = node.callee;
|
|
777
|
+
if (callee?.type !== "MemberExpression") return;
|
|
778
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
779
|
+
const methodName = callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
780
|
+
if (objName && DEDUP_OBJECTS.has(objName) || methodName && DEDUP_METHODS.has(methodName)) {
|
|
781
|
+
dedupSignals.push(node);
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
MemberExpression(node) {
|
|
785
|
+
if (node.property?.type === "Identifier" && EVENT_ID_PROPS.has(node.property.name)) {
|
|
786
|
+
dedupSignals.push(node);
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
"Program:exit"() {
|
|
790
|
+
if (!importsSvix) return;
|
|
791
|
+
for (const handler of postHandlers) {
|
|
792
|
+
const hasDedup = dedupSignals.some(
|
|
793
|
+
(sig) => startOffset(sig) >= startOffset(handler) && endOffset(sig) <= endOffset(handler)
|
|
794
|
+
);
|
|
795
|
+
if (!hasDedup) {
|
|
796
|
+
context.report({ node: handler, messageId: "noIdempotency" });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
var resendWebhookNoIdempotencyRule = rule11;
|
|
804
|
+
|
|
805
|
+
// src/providers/resend/rules/missing-tags.ts
|
|
806
|
+
var rule12 = {
|
|
807
|
+
meta: {
|
|
808
|
+
type: "suggestion",
|
|
809
|
+
docs: {
|
|
810
|
+
description: "Resend sends should include tags for deliverability segmentation",
|
|
811
|
+
category: "integration",
|
|
812
|
+
rationale: 'Tags are how Resend segments and filters email in the dashboard and analytics, so sends without them collapse into one undifferentiated stream. When deliverability dips or you need to trace a specific campaign, untagged mail gives you nothing to slice on. Adding tags such as [{ name: "category", value: "welcome" }] makes monitoring and debugging across email types possible.',
|
|
813
|
+
docsUrl: "https://resend.com/docs/dashboard/emails/tags",
|
|
814
|
+
recommended: true
|
|
815
|
+
},
|
|
816
|
+
messages: {
|
|
817
|
+
missingTags: 'Resend send has no tags. Add tags (e.g. [{ name: "category", value: "welcome" }]) for segmentation.'
|
|
818
|
+
},
|
|
819
|
+
schema: []
|
|
820
|
+
},
|
|
821
|
+
create(context) {
|
|
822
|
+
return {
|
|
823
|
+
CallExpression(node) {
|
|
824
|
+
if (!isResendSendCall(node)) return;
|
|
825
|
+
const optionObjects = getSendOptionObjects(node);
|
|
826
|
+
if (optionObjects.length === 0) return;
|
|
827
|
+
const someMissingTags = optionObjects.some((opts) => !findProperty(opts, "tags"));
|
|
828
|
+
if (someMissingTags) {
|
|
829
|
+
context.report({ node, messageId: "missingTags" });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
var resendMissingTagsRule = rule12;
|
|
836
|
+
|
|
837
|
+
// src/providers/resend/rules/request-id-not-logged.ts
|
|
838
|
+
var REQUEST_ID_HEADERS = /* @__PURE__ */ new Set(["x-request-id", "x-resend-request-id"]);
|
|
839
|
+
var rule13 = {
|
|
840
|
+
meta: {
|
|
841
|
+
type: "suggestion",
|
|
842
|
+
docs: {
|
|
843
|
+
description: "Log the Resend request id when handling errors",
|
|
844
|
+
category: "integration",
|
|
845
|
+
rationale: 'Every Resend API response carries a request id (x-request-id / x-resend-request-id) that uniquely identifies the call on their side. When something goes wrong, logging only error.message leaves you and Resend support with no way to find the exact failed request. Logging the request id alongside the message turns a vague "send failed" into a traceable incident that support can look up directly.',
|
|
846
|
+
docsUrl: "https://resend.com/docs/api-reference/errors",
|
|
847
|
+
recommended: true
|
|
848
|
+
},
|
|
849
|
+
messages: {
|
|
850
|
+
requestIdNotLogged: "Error handler logs error.message but not the Resend request id (x-request-id / x-resend-request-id)."
|
|
851
|
+
},
|
|
852
|
+
schema: []
|
|
853
|
+
},
|
|
854
|
+
create(context) {
|
|
855
|
+
let importsResend = false;
|
|
856
|
+
const scopes = [];
|
|
857
|
+
const messageAccesses = [];
|
|
858
|
+
const requestIdPositions = [];
|
|
859
|
+
function within(range, pos) {
|
|
860
|
+
return pos >= range[0] && pos <= range[1];
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
ImportDeclaration(node) {
|
|
864
|
+
if (node?.source?.value === "resend") importsResend = true;
|
|
865
|
+
},
|
|
866
|
+
CatchClause(node) {
|
|
867
|
+
const param = node.param;
|
|
868
|
+
if (param?.type === "Identifier" && node.body) {
|
|
869
|
+
scopes.push({
|
|
870
|
+
node,
|
|
871
|
+
name: param.name,
|
|
872
|
+
range: [startOffset(node.body), endOffset(node.body)]
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
IfStatement(node) {
|
|
877
|
+
if (node.test?.type === "Identifier" && node.consequent) {
|
|
878
|
+
scopes.push({
|
|
879
|
+
node,
|
|
880
|
+
name: node.test.name,
|
|
881
|
+
range: [startOffset(node.consequent), endOffset(node.consequent)]
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
MemberExpression(node) {
|
|
886
|
+
if (node.property?.type === "Identifier" && node.property.name === "message" && node.object?.type === "Identifier") {
|
|
887
|
+
messageAccesses.push({ name: node.object.name, pos: startOffset(node) });
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
Literal(node) {
|
|
891
|
+
if (typeof node.value === "string" && REQUEST_ID_HEADERS.has(node.value.toLowerCase())) {
|
|
892
|
+
requestIdPositions.push(startOffset(node));
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
"Program:exit"() {
|
|
896
|
+
if (!importsResend) return;
|
|
897
|
+
for (const scope of scopes) {
|
|
898
|
+
const logsMessage = messageAccesses.some(
|
|
899
|
+
(m) => m.name === scope.name && within(scope.range, m.pos)
|
|
900
|
+
);
|
|
901
|
+
if (!logsMessage) continue;
|
|
902
|
+
const logsRequestId = requestIdPositions.some((pos) => within(scope.range, pos));
|
|
903
|
+
if (!logsRequestId) {
|
|
904
|
+
context.report({ node: scope.node, messageId: "requestIdNotLogged" });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
var resendRequestIdNotLoggedRule = rule13;
|
|
912
|
+
|
|
913
|
+
// src/providers/supabase/utils.ts
|
|
914
|
+
function memberPropName(node) {
|
|
915
|
+
if (node?.type !== "CallExpression") return void 0;
|
|
916
|
+
const callee = node.callee;
|
|
917
|
+
if (callee?.type !== "MemberExpression") return void 0;
|
|
918
|
+
const prop = callee.property;
|
|
919
|
+
if (!callee.computed && prop?.type === "Identifier") return prop.name;
|
|
920
|
+
if (callee.computed && prop?.type === "Literal" && typeof prop.value === "string") {
|
|
921
|
+
return prop.value;
|
|
922
|
+
}
|
|
923
|
+
return void 0;
|
|
924
|
+
}
|
|
925
|
+
function chainObjectCall(node) {
|
|
926
|
+
const obj = node?.callee?.object;
|
|
927
|
+
return obj?.type === "CallExpression" ? obj : null;
|
|
928
|
+
}
|
|
929
|
+
function parseSelectColumns(arg) {
|
|
930
|
+
if (arg?.type !== "Literal" || typeof arg.value !== "string") return [];
|
|
931
|
+
return arg.value.split(",").map((c) => c.trim()).filter(Boolean);
|
|
932
|
+
}
|
|
933
|
+
function isTenantColumnName(name) {
|
|
934
|
+
return /^[a-z][a-z0-9]*_id$/i.test(name) && name.toLowerCase() !== "id";
|
|
935
|
+
}
|
|
936
|
+
function isTimestampColumnName(name) {
|
|
937
|
+
return /^[a-z][a-z0-9]*_at$/i.test(name);
|
|
938
|
+
}
|
|
939
|
+
function fromTableName(node) {
|
|
940
|
+
let current = node;
|
|
941
|
+
while (current?.type === "CallExpression") {
|
|
942
|
+
if (memberPropName(current) === "from") {
|
|
943
|
+
const arg = current.arguments?.[0];
|
|
944
|
+
return arg?.type === "Literal" && typeof arg.value === "string" ? arg.value : void 0;
|
|
945
|
+
}
|
|
946
|
+
current = chainObjectCall(current);
|
|
947
|
+
}
|
|
948
|
+
return void 0;
|
|
949
|
+
}
|
|
950
|
+
function chainHasMethod(node, method) {
|
|
951
|
+
let current = node;
|
|
952
|
+
while (current?.type === "CallExpression") {
|
|
953
|
+
if (memberPropName(current) === method) return true;
|
|
954
|
+
current = chainObjectCall(current);
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
function isSupabaseMutationKind(node, kind) {
|
|
959
|
+
if (!chainHasMethod(node, kind)) return false;
|
|
960
|
+
let current = node;
|
|
961
|
+
while (current?.type === "CallExpression") {
|
|
962
|
+
if (memberPropName(current) === "from") return true;
|
|
963
|
+
current = chainObjectCall(current);
|
|
964
|
+
}
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
var USER_METADATA_AUTHZ_KEYS = /* @__PURE__ */ new Set([
|
|
968
|
+
"role",
|
|
969
|
+
"roles",
|
|
970
|
+
"admin",
|
|
971
|
+
"is_admin",
|
|
972
|
+
"permission",
|
|
973
|
+
"permissions"
|
|
974
|
+
]);
|
|
975
|
+
function isUserMetadataAuthzRead(node) {
|
|
976
|
+
if (node?.type !== "MemberExpression") return false;
|
|
977
|
+
const parts = [];
|
|
978
|
+
let current = node;
|
|
979
|
+
while (current?.type === "MemberExpression") {
|
|
980
|
+
const prop = current.property;
|
|
981
|
+
const name = !current.computed && prop?.type === "Identifier" ? prop.name : prop?.type === "Literal" && typeof prop.value === "string" ? prop.value : void 0;
|
|
982
|
+
if (name) parts.unshift(name);
|
|
983
|
+
current = current.object;
|
|
984
|
+
}
|
|
985
|
+
const metaIdx = parts.indexOf("user_metadata");
|
|
986
|
+
if (metaIdx === -1) return false;
|
|
987
|
+
const field = parts[metaIdx + 1];
|
|
988
|
+
return typeof field === "string" && USER_METADATA_AUTHZ_KEYS.has(field);
|
|
989
|
+
}
|
|
990
|
+
function destructuredNames(pattern) {
|
|
991
|
+
const names = /* @__PURE__ */ new Set();
|
|
992
|
+
if (!pattern) return names;
|
|
993
|
+
if (pattern.type === "Identifier") {
|
|
994
|
+
names.add(pattern.name);
|
|
995
|
+
return names;
|
|
996
|
+
}
|
|
997
|
+
if (pattern.type !== "ObjectPattern") return names;
|
|
998
|
+
for (const prop of pattern.properties ?? []) {
|
|
999
|
+
if (prop?.type === "Property") {
|
|
1000
|
+
if (prop.value?.type === "Identifier") names.add(prop.value.name);
|
|
1001
|
+
else if (prop.key?.type === "Identifier" && prop.shorthand) names.add(prop.key.name);
|
|
1002
|
+
else if (prop.key?.type === "Identifier" && prop.value?.type === "Identifier") {
|
|
1003
|
+
names.add(prop.value.name);
|
|
1004
|
+
}
|
|
1005
|
+
} else if (prop?.type === "RestElement" && prop.argument?.type === "Identifier") {
|
|
1006
|
+
names.add(prop.argument.name);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return names;
|
|
1010
|
+
}
|
|
1011
|
+
function resolvePropertyValueName(prop) {
|
|
1012
|
+
if (prop?.shorthand && prop.key?.type === "Identifier") return prop.key.name;
|
|
1013
|
+
const value = prop?.value;
|
|
1014
|
+
if (value?.type === "Identifier") return value.name;
|
|
1015
|
+
if (value?.type === "LogicalExpression" && (value.operator === "??" || value.operator === "||")) {
|
|
1016
|
+
if (value.left?.type === "Identifier") return value.left.name;
|
|
1017
|
+
}
|
|
1018
|
+
return void 0;
|
|
1019
|
+
}
|
|
1020
|
+
function typeofStringCheckTarget(node) {
|
|
1021
|
+
if (node?.type !== "BinaryExpression") return void 0;
|
|
1022
|
+
if (node.operator !== "===" && node.operator !== "!==") return void 0;
|
|
1023
|
+
const sides = [node.left, node.right];
|
|
1024
|
+
const typeofSide = sides.find(
|
|
1025
|
+
(s) => s?.type === "UnaryExpression" && s.operator === "typeof" && s.argument?.type === "Identifier"
|
|
1026
|
+
);
|
|
1027
|
+
const litSide = sides.find((s) => s?.type === "Literal" && s.value === "string");
|
|
1028
|
+
if (!typeofSide || !litSide) return void 0;
|
|
1029
|
+
return typeofSide.argument.name;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/providers/supabase/rules/scope-queries-by-tenant-column.ts
|
|
1033
|
+
var rule14 = {
|
|
1034
|
+
meta: {
|
|
1035
|
+
type: "suggestion",
|
|
1036
|
+
docs: {
|
|
1037
|
+
description: "Supabase queries that select a tenant column must filter by it",
|
|
1038
|
+
category: "correctness",
|
|
1039
|
+
rationale: "A column like session_id or user_id existing in the schema (and being selected) signals intent to scope rows to one caller, but selecting it is not the same as filtering by it. Without an .eq()/.match()/.filter() on that column, the query returns every row for every tenant, turning a per-user feed into a single shared, cross-user one.",
|
|
1040
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/eq",
|
|
1041
|
+
recommended: true
|
|
1042
|
+
},
|
|
1043
|
+
messages: {
|
|
1044
|
+
missingTenantFilter: 'This query selects "{{column}}" but never filters by it. Add .eq("{{column}}", ...) (or .match()/.filter()) to scope results to the caller.'
|
|
1045
|
+
},
|
|
1046
|
+
schema: []
|
|
1047
|
+
},
|
|
1048
|
+
create(context) {
|
|
1049
|
+
const chainStates = /* @__PURE__ */ new Map();
|
|
1050
|
+
const selectStates = [];
|
|
1051
|
+
function recordFilteredColumn(state, name) {
|
|
1052
|
+
if (typeof name === "string") state.filteredColumns.add(name);
|
|
1053
|
+
else state.filteredColumns.add("*");
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
"CallExpression:exit"(node) {
|
|
1057
|
+
const prop = memberPropName(node);
|
|
1058
|
+
if (!prop) return;
|
|
1059
|
+
const objCall = chainObjectCall(node);
|
|
1060
|
+
if (prop === "select" && objCall && memberPropName(objCall) === "from") {
|
|
1061
|
+
const columns = parseSelectColumns(node.arguments?.[0]);
|
|
1062
|
+
const tenantColumns = columns.filter(isTenantColumnName);
|
|
1063
|
+
if (tenantColumns.length === 0) return;
|
|
1064
|
+
const state2 = { selectNode: node, tenantColumns, filteredColumns: /* @__PURE__ */ new Set() };
|
|
1065
|
+
chainStates.set(node, state2);
|
|
1066
|
+
selectStates.push(state2);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const state = objCall ? chainStates.get(objCall) : void 0;
|
|
1070
|
+
if (!state) return;
|
|
1071
|
+
if (prop === "eq" || prop === "filter") {
|
|
1072
|
+
const colArg = node.arguments?.[0];
|
|
1073
|
+
recordFilteredColumn(state, colArg?.type === "Literal" ? colArg.value : void 0);
|
|
1074
|
+
} else if (prop === "match") {
|
|
1075
|
+
const objArg = node.arguments?.[0];
|
|
1076
|
+
if (objArg?.type === "ObjectExpression") {
|
|
1077
|
+
for (const p of objArg.properties ?? []) {
|
|
1078
|
+
if (p?.type !== "Property") continue;
|
|
1079
|
+
const name = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1080
|
+
recordFilteredColumn(state, name);
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
recordFilteredColumn(state, void 0);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
chainStates.set(node, state);
|
|
1087
|
+
},
|
|
1088
|
+
"Program:exit"() {
|
|
1089
|
+
for (const state of selectStates) {
|
|
1090
|
+
if (state.filteredColumns.has("*")) continue;
|
|
1091
|
+
const missing = state.tenantColumns.find((c) => !state.filteredColumns.has(c));
|
|
1092
|
+
if (missing) {
|
|
1093
|
+
context.report({
|
|
1094
|
+
node: state.selectNode,
|
|
1095
|
+
messageId: "missingTenantFilter",
|
|
1096
|
+
data: { column: missing }
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
var supabaseScopeQueriesByTenantColumnRule = rule14;
|
|
1105
|
+
|
|
1106
|
+
// src/providers/supabase/rules/validate-uuid-columns.ts
|
|
1107
|
+
function regexSourceLooksUuidShaped(pattern) {
|
|
1108
|
+
return /[0-9a-f]{2,}/i.test(pattern) && pattern.includes("-");
|
|
1109
|
+
}
|
|
1110
|
+
var rule15 = {
|
|
1111
|
+
meta: {
|
|
1112
|
+
type: "suggestion",
|
|
1113
|
+
docs: {
|
|
1114
|
+
description: "Columns typed uuid must be validated for UUID shape, not just typeof string",
|
|
1115
|
+
category: "correctness",
|
|
1116
|
+
rationale: 'typeof === "string" accepts any string, including malformed UUIDs like "abc". When the underlying column is typed uuid, the database rejects it with a generic type-cast error that the app then has to collapse into an opaque 500 \u2014 masking a client-correctable 400 as a server failure. Validating the UUID shape (e.g. with a regex) before the insert lets the app return a precise 400 instead.',
|
|
1117
|
+
docsUrl: "https://supabase.com/docs/guides/database/tables#data-types",
|
|
1118
|
+
recommended: true
|
|
1119
|
+
},
|
|
1120
|
+
messages: {
|
|
1121
|
+
missingUuidValidation: 'Column "{{column}}" looks UUID-typed but the value is only checked with typeof === "string", not a UUID-shape regex. Validate the format before insert/upsert.'
|
|
1122
|
+
},
|
|
1123
|
+
schema: []
|
|
1124
|
+
},
|
|
1125
|
+
create(context) {
|
|
1126
|
+
const validations = /* @__PURE__ */ new Map();
|
|
1127
|
+
const regexVarPatterns = /* @__PURE__ */ new Map();
|
|
1128
|
+
function markValidation(name, key) {
|
|
1129
|
+
let v = validations.get(name);
|
|
1130
|
+
if (!v) {
|
|
1131
|
+
v = { typeofOnly: false, uuidChecked: false };
|
|
1132
|
+
validations.set(name, v);
|
|
1133
|
+
}
|
|
1134
|
+
v[key] = true;
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
VariableDeclarator(node) {
|
|
1138
|
+
if (node.id?.type === "Identifier" && node.init?.type === "Literal" && node.init.regex) {
|
|
1139
|
+
regexVarPatterns.set(node.id.name, node.init.regex.pattern);
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
BinaryExpression(node) {
|
|
1143
|
+
const varName = typeofStringCheckTarget(node);
|
|
1144
|
+
if (varName) markValidation(varName, "typeofOnly");
|
|
1145
|
+
},
|
|
1146
|
+
CallExpression(node) {
|
|
1147
|
+
const prop = memberPropName(node);
|
|
1148
|
+
if (prop === "test") {
|
|
1149
|
+
const objNode = node.callee.object;
|
|
1150
|
+
let pattern;
|
|
1151
|
+
if (objNode?.type === "Literal" && objNode.regex) {
|
|
1152
|
+
pattern = objNode.regex.pattern;
|
|
1153
|
+
} else if (objNode?.type === "Identifier" && regexVarPatterns.has(objNode.name)) {
|
|
1154
|
+
pattern = regexVarPatterns.get(objNode.name);
|
|
1155
|
+
}
|
|
1156
|
+
if (pattern && regexSourceLooksUuidShaped(pattern)) {
|
|
1157
|
+
const arg2 = node.arguments?.[0];
|
|
1158
|
+
if (arg2?.type === "Identifier") markValidation(arg2.name, "uuidChecked");
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (prop !== "insert" && prop !== "upsert") return;
|
|
1163
|
+
const objCall = chainObjectCall(node);
|
|
1164
|
+
if (!objCall || memberPropName(objCall) !== "from") return;
|
|
1165
|
+
const arg = node.arguments?.[0];
|
|
1166
|
+
if (arg?.type !== "ObjectExpression") return;
|
|
1167
|
+
for (const p of arg.properties ?? []) {
|
|
1168
|
+
if (p?.type !== "Property") continue;
|
|
1169
|
+
const keyName = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1170
|
+
if (typeof keyName !== "string" || !isTenantColumnName(keyName)) continue;
|
|
1171
|
+
const valueName = resolvePropertyValueName(p);
|
|
1172
|
+
if (!valueName) continue;
|
|
1173
|
+
const v = validations.get(valueName);
|
|
1174
|
+
if (v?.typeofOnly && !v.uuidChecked) {
|
|
1175
|
+
context.report({ node: p, messageId: "missingUuidValidation", data: { column: keyName } });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
var supabaseValidateUuidColumnsRule = rule15;
|
|
1183
|
+
|
|
1184
|
+
// src/providers/supabase/rules/order-by-timestamp-not-identity.ts
|
|
1185
|
+
var rule16 = {
|
|
1186
|
+
meta: {
|
|
1187
|
+
type: "suggestion",
|
|
1188
|
+
docs: {
|
|
1189
|
+
description: "Order by a selected timestamp column instead of the identity column",
|
|
1190
|
+
category: "correctness",
|
|
1191
|
+
rationale: "Ordering by a bigint identity PK only produces correct chronological order because the PK happens to be monotonic with insert order today. That assumption breaks under bulk inserts, backfills, or replication where PK order and time order can diverge. When the query already selects a timestamp column built for this purpose, order by it instead of the surrogate key.",
|
|
1192
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/order",
|
|
1193
|
+
recommended: true
|
|
1194
|
+
},
|
|
1195
|
+
messages: {
|
|
1196
|
+
orderByIdentity: 'This query selects "{{column}}" but orders by "id" instead. Order by "{{column}}" so result order does not depend on PK/insert-order coincidence.'
|
|
1197
|
+
},
|
|
1198
|
+
schema: []
|
|
1199
|
+
},
|
|
1200
|
+
create(context) {
|
|
1201
|
+
const chainStates = /* @__PURE__ */ new Map();
|
|
1202
|
+
return {
|
|
1203
|
+
"CallExpression:exit"(node) {
|
|
1204
|
+
const prop = memberPropName(node);
|
|
1205
|
+
if (!prop) return;
|
|
1206
|
+
const objCall = chainObjectCall(node);
|
|
1207
|
+
if (prop === "select" && objCall && memberPropName(objCall) === "from") {
|
|
1208
|
+
const columns = parseSelectColumns(node.arguments?.[0]);
|
|
1209
|
+
const timestampColumns = columns.filter(isTimestampColumnName);
|
|
1210
|
+
chainStates.set(node, { timestampColumns });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const state = objCall ? chainStates.get(objCall) : void 0;
|
|
1214
|
+
if (!state) return;
|
|
1215
|
+
if (prop === "order") {
|
|
1216
|
+
const colArg = node.arguments?.[0];
|
|
1217
|
+
const orderColumn = colArg?.type === "Literal" ? colArg.value : void 0;
|
|
1218
|
+
if (typeof orderColumn === "string" && orderColumn.toLowerCase() === "id" && state.timestampColumns.length > 0) {
|
|
1219
|
+
context.report({
|
|
1220
|
+
node,
|
|
1221
|
+
messageId: "orderByIdentity",
|
|
1222
|
+
data: { column: state.timestampColumns[0] }
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
chainStates.set(node, state);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
var supabaseOrderByTimestampNotIdentityRule = rule16;
|
|
1232
|
+
|
|
1233
|
+
// src/providers/supabase/rules/consistent-input-length-limits.ts
|
|
1234
|
+
var rule17 = {
|
|
1235
|
+
meta: {
|
|
1236
|
+
type: "suggestion",
|
|
1237
|
+
docs: {
|
|
1238
|
+
description: "Sibling string fields inserted together should share the same length cap discipline",
|
|
1239
|
+
category: "correctness",
|
|
1240
|
+
rationale: "A field with no length cap on an otherwise-validated, unauthenticated insert path lets a client persist arbitrarily large payloads repeatedly, growing storage with no bound. When sibling fields in the same insert already have explicit length caps, an uncapped field next to them is usually an oversight rather than an intentional choice.",
|
|
1241
|
+
docsUrl: "https://supabase.com/docs/guides/database/tables",
|
|
1242
|
+
recommended: true
|
|
1243
|
+
},
|
|
1244
|
+
messages: {
|
|
1245
|
+
inconsistentLengthLimit: 'Field "{{field}}" has no length cap, but sibling field "{{cappedField}}" in the same insert does. Add a .length check for "{{field}}" too.'
|
|
1246
|
+
},
|
|
1247
|
+
schema: []
|
|
1248
|
+
},
|
|
1249
|
+
create(context) {
|
|
1250
|
+
const validations = /* @__PURE__ */ new Map();
|
|
1251
|
+
function markValidation(name, key) {
|
|
1252
|
+
let v = validations.get(name);
|
|
1253
|
+
if (!v) {
|
|
1254
|
+
v = { typeofStringChecked: false, hasLengthCap: false };
|
|
1255
|
+
validations.set(name, v);
|
|
1256
|
+
}
|
|
1257
|
+
v[key] = true;
|
|
1258
|
+
}
|
|
1259
|
+
function lengthCapTarget(node) {
|
|
1260
|
+
if (node?.type !== "BinaryExpression") return void 0;
|
|
1261
|
+
if (node.operator !== ">" && node.operator !== ">=") return void 0;
|
|
1262
|
+
const left = node.left;
|
|
1263
|
+
const right = node.right;
|
|
1264
|
+
if (left?.type !== "MemberExpression") return void 0;
|
|
1265
|
+
if (left.property?.type !== "Identifier" || left.property.name !== "length") return void 0;
|
|
1266
|
+
if (left.object?.type !== "Identifier") return void 0;
|
|
1267
|
+
if (right?.type !== "Literal" || typeof right.value !== "number") return void 0;
|
|
1268
|
+
return left.object.name;
|
|
1269
|
+
}
|
|
1270
|
+
return {
|
|
1271
|
+
BinaryExpression(node) {
|
|
1272
|
+
const typeofVar = typeofStringCheckTarget(node);
|
|
1273
|
+
if (typeofVar) markValidation(typeofVar, "typeofStringChecked");
|
|
1274
|
+
const lengthVar = lengthCapTarget(node);
|
|
1275
|
+
if (lengthVar) markValidation(lengthVar, "hasLengthCap");
|
|
1276
|
+
},
|
|
1277
|
+
CallExpression(node) {
|
|
1278
|
+
const prop = memberPropName(node);
|
|
1279
|
+
if (prop !== "insert" && prop !== "upsert") return;
|
|
1280
|
+
const objCall = chainObjectCall(node);
|
|
1281
|
+
if (!objCall || memberPropName(objCall) !== "from") return;
|
|
1282
|
+
const arg = node.arguments?.[0];
|
|
1283
|
+
if (arg?.type !== "ObjectExpression") return;
|
|
1284
|
+
const stringFields = [];
|
|
1285
|
+
for (const p of arg.properties ?? []) {
|
|
1286
|
+
if (p?.type !== "Property") continue;
|
|
1287
|
+
const field = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1288
|
+
if (typeof field !== "string") continue;
|
|
1289
|
+
const varName = resolvePropertyValueName(p);
|
|
1290
|
+
if (!varName) continue;
|
|
1291
|
+
const v = validations.get(varName);
|
|
1292
|
+
if (v?.typeofStringChecked) stringFields.push({ propNode: p, field, varName });
|
|
1293
|
+
}
|
|
1294
|
+
const capped = stringFields.filter((f) => validations.get(f.varName)?.hasLengthCap);
|
|
1295
|
+
const uncapped = stringFields.filter((f) => !validations.get(f.varName)?.hasLengthCap);
|
|
1296
|
+
if (capped.length === 0 || uncapped.length === 0) return;
|
|
1297
|
+
for (const f of uncapped) {
|
|
1298
|
+
context.report({
|
|
1299
|
+
node: f.propNode,
|
|
1300
|
+
messageId: "inconsistentLengthLimit",
|
|
1301
|
+
data: { field: f.field, cappedField: capped[0].field }
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
var supabaseConsistentInputLengthLimitsRule = rule17;
|
|
1309
|
+
|
|
1310
|
+
// src/providers/supabase/rules/idempotent-mutations.ts
|
|
1311
|
+
function objectHasIdempotencyKey(objectExpression) {
|
|
1312
|
+
if (objectExpression?.type !== "ObjectExpression") return false;
|
|
1313
|
+
return (objectExpression.properties ?? []).some((p) => {
|
|
1314
|
+
if (p?.type !== "Property") return false;
|
|
1315
|
+
const name = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1316
|
+
return typeof name === "string" && /idempot|dedupe/i.test(name);
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
function insertPayloadHasIdempotencyKey(arg) {
|
|
1320
|
+
if (arg?.type === "ObjectExpression") return objectHasIdempotencyKey(arg);
|
|
1321
|
+
if (arg?.type === "ArrayExpression") {
|
|
1322
|
+
return (arg.elements ?? []).some((el) => objectHasIdempotencyKey(el));
|
|
1323
|
+
}
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
var rule18 = {
|
|
1327
|
+
meta: {
|
|
1328
|
+
type: "suggestion",
|
|
1329
|
+
docs: {
|
|
1330
|
+
description: "Supabase insert calls should be retry-safe via an idempotency key",
|
|
1331
|
+
category: "reliability",
|
|
1332
|
+
rationale: 'Nothing prevents a duplicate row if the client fetch behind an insert is retried (flaky network, double-click, browser replay) \u2014 there is no unique constraint or dedupe key visible in the payload, and no upsert semantics. Generate a client-side idempotency key per logical action and either include it as a field guarded by a unique constraint, or use .upsert(..., { onConflict: "<key column>" }).',
|
|
1333
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/upsert",
|
|
1334
|
+
recommended: true
|
|
1335
|
+
},
|
|
1336
|
+
messages: {
|
|
1337
|
+
missingIdempotencyKey: 'This insert has no idempotency/dedupe key field, so a retried request can create a duplicate row. Add one, or use .upsert(..., { onConflict: "<key column>" }).'
|
|
1338
|
+
},
|
|
1339
|
+
schema: []
|
|
1340
|
+
},
|
|
1341
|
+
create(context) {
|
|
1342
|
+
return {
|
|
1343
|
+
CallExpression(node) {
|
|
1344
|
+
const prop = memberPropName(node);
|
|
1345
|
+
if (prop !== "insert") return;
|
|
1346
|
+
const objCall = chainObjectCall(node);
|
|
1347
|
+
if (!objCall || memberPropName(objCall) !== "from") return;
|
|
1348
|
+
const arg = node.arguments?.[0];
|
|
1349
|
+
if (!arg) return;
|
|
1350
|
+
if (insertPayloadHasIdempotencyKey(arg)) return;
|
|
1351
|
+
context.report({ node, messageId: "missingIdempotencyKey" });
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
var supabaseIdempotentMutationsRule = rule18;
|
|
1357
|
+
|
|
1358
|
+
// src/providers/supabase/rules/fail-fast-env-validation.ts
|
|
1359
|
+
function unwrapNonNull(node) {
|
|
1360
|
+
return node?.type === "TSNonNullExpression" ? node.expression : node;
|
|
1361
|
+
}
|
|
1362
|
+
function processEnvMemberName(node) {
|
|
1363
|
+
const n = unwrapNonNull(node);
|
|
1364
|
+
if (n?.type !== "MemberExpression" || n.computed) return void 0;
|
|
1365
|
+
const obj = n.object;
|
|
1366
|
+
if (obj?.type !== "MemberExpression" || obj.computed) return void 0;
|
|
1367
|
+
if (obj.object?.type !== "Identifier" || obj.object.name !== "process") return void 0;
|
|
1368
|
+
if (obj.property?.type !== "Identifier" || obj.property.name !== "env") return void 0;
|
|
1369
|
+
if (n.property?.type !== "Identifier") return void 0;
|
|
1370
|
+
return n.property.name;
|
|
1371
|
+
}
|
|
1372
|
+
function hasThrowOrReturn(node) {
|
|
1373
|
+
if (!node) return false;
|
|
1374
|
+
if (node.type === "ThrowStatement" || node.type === "ReturnStatement") return true;
|
|
1375
|
+
if (node.type === "BlockStatement") {
|
|
1376
|
+
return (node.body ?? []).some((s) => s.type === "ThrowStatement" || s.type === "ReturnStatement");
|
|
1377
|
+
}
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
var rule19 = {
|
|
1381
|
+
meta: {
|
|
1382
|
+
type: "suggestion",
|
|
1383
|
+
docs: {
|
|
1384
|
+
description: "createClient must fail fast when required env vars are missing",
|
|
1385
|
+
category: "reliability",
|
|
1386
|
+
rationale: "createClient does not throw on undefined arguments \u2014 a missing env var surfaces later as an opaque error deep in a fetch call rather than a clear message at startup. Checking presence before calling createClient turns a confusing runtime failure (e.g. on a misconfigured second service) into an immediate, actionable one.",
|
|
1387
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/initializing",
|
|
1388
|
+
recommended: true
|
|
1389
|
+
},
|
|
1390
|
+
messages: {
|
|
1391
|
+
missingEnvValidation: "createClient is called with {{vars}} with no presence check beforehand. Throw if it/they are unset before calling createClient."
|
|
1392
|
+
},
|
|
1393
|
+
schema: []
|
|
1394
|
+
},
|
|
1395
|
+
create(context) {
|
|
1396
|
+
let createClientLocalName;
|
|
1397
|
+
const envVarOfVariable = /* @__PURE__ */ new Map();
|
|
1398
|
+
const validatedVarNames = /* @__PURE__ */ new Set();
|
|
1399
|
+
const validatedEnvNames = /* @__PURE__ */ new Set();
|
|
1400
|
+
function addTarget(node) {
|
|
1401
|
+
const n = unwrapNonNull(node);
|
|
1402
|
+
if (n?.type === "Identifier") {
|
|
1403
|
+
validatedVarNames.add(n.name);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const envName = processEnvMemberName(n);
|
|
1407
|
+
if (envName) validatedEnvNames.add(envName);
|
|
1408
|
+
}
|
|
1409
|
+
function collectGuardTargets(node) {
|
|
1410
|
+
if (!node) return;
|
|
1411
|
+
if (node.type === "LogicalExpression" && node.operator === "||") {
|
|
1412
|
+
collectGuardTargets(node.left);
|
|
1413
|
+
collectGuardTargets(node.right);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (node.type === "UnaryExpression" && node.operator === "!") {
|
|
1417
|
+
addTarget(node.argument);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (node.type === "BinaryExpression" && (node.operator === "==" || node.operator === "===")) {
|
|
1421
|
+
const sides = [node.left, node.right];
|
|
1422
|
+
const isNullish = (n) => n?.type === "Literal" && n.value === null || n?.type === "Identifier" && n.name === "undefined";
|
|
1423
|
+
const target = sides.find((s) => !isNullish(s));
|
|
1424
|
+
const nullSide = sides.find(isNullish);
|
|
1425
|
+
if (target && nullSide) addTarget(target);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
ImportDeclaration(node) {
|
|
1430
|
+
if (node.source?.value !== "@supabase/supabase-js") return;
|
|
1431
|
+
for (const s of node.specifiers ?? []) {
|
|
1432
|
+
if (s?.type === "ImportSpecifier" && s.imported?.type === "Identifier" && s.imported.name === "createClient" && s.local?.type === "Identifier") {
|
|
1433
|
+
createClientLocalName = s.local.name;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
},
|
|
1437
|
+
VariableDeclarator(node) {
|
|
1438
|
+
if (node.id?.type !== "Identifier") return;
|
|
1439
|
+
const envName = processEnvMemberName(node.init);
|
|
1440
|
+
if (envName) envVarOfVariable.set(node.id.name, envName);
|
|
1441
|
+
},
|
|
1442
|
+
IfStatement(node) {
|
|
1443
|
+
if (!hasThrowOrReturn(node.consequent)) return;
|
|
1444
|
+
collectGuardTargets(node.test);
|
|
1445
|
+
},
|
|
1446
|
+
CallExpression(node) {
|
|
1447
|
+
if (!createClientLocalName) return;
|
|
1448
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== createClientLocalName) return;
|
|
1449
|
+
const missing = [];
|
|
1450
|
+
for (const rawArg of node.arguments ?? []) {
|
|
1451
|
+
const arg = unwrapNonNull(rawArg);
|
|
1452
|
+
let envName;
|
|
1453
|
+
let isValidated;
|
|
1454
|
+
if (arg?.type === "Identifier") {
|
|
1455
|
+
envName = envVarOfVariable.get(arg.name);
|
|
1456
|
+
if (!envName) continue;
|
|
1457
|
+
isValidated = validatedVarNames.has(arg.name);
|
|
1458
|
+
} else {
|
|
1459
|
+
envName = processEnvMemberName(arg);
|
|
1460
|
+
if (!envName) continue;
|
|
1461
|
+
isValidated = validatedEnvNames.has(envName);
|
|
1462
|
+
}
|
|
1463
|
+
if (!isValidated) missing.push(envName);
|
|
1464
|
+
}
|
|
1465
|
+
if (missing.length > 0) {
|
|
1466
|
+
context.report({ node, messageId: "missingEnvValidation", data: { vars: missing.join(", ") } });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
var supabaseFailFastEnvValidationRule = rule19;
|
|
1473
|
+
|
|
1474
|
+
// src/providers/supabase/rules/no-user-metadata-authz.ts
|
|
1475
|
+
var AUTHZ_DATA_KEYS = /* @__PURE__ */ new Set(["role", "roles", "admin", "is_admin", "permission", "permissions"]);
|
|
1476
|
+
function objectHasAuthzDataKey(objectExpression) {
|
|
1477
|
+
if (objectExpression?.type !== "ObjectExpression") return false;
|
|
1478
|
+
return (objectExpression.properties ?? []).some((p) => {
|
|
1479
|
+
if (p?.type !== "Property") return false;
|
|
1480
|
+
const name = p.shorthand && p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1481
|
+
return typeof name === "string" && AUTHZ_DATA_KEYS.has(name);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
function findAuthDataPayload(args) {
|
|
1485
|
+
for (const arg of args) {
|
|
1486
|
+
if (arg?.type !== "ObjectExpression") continue;
|
|
1487
|
+
for (const p of arg.properties ?? []) {
|
|
1488
|
+
if (p?.type !== "Property") continue;
|
|
1489
|
+
const key = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1490
|
+
if (key === "data" && p.value?.type === "ObjectExpression") return p.value;
|
|
1491
|
+
if (key === "options" && p.value?.type === "ObjectExpression") {
|
|
1492
|
+
for (const opt of p.value.properties ?? []) {
|
|
1493
|
+
if (opt?.type !== "Property") continue;
|
|
1494
|
+
const optKey = opt.key?.type === "Identifier" ? opt.key.name : opt.key?.type === "Literal" ? opt.key.value : void 0;
|
|
1495
|
+
if (optKey === "data" && opt.value?.type === "ObjectExpression") return opt.value;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
function isAuthUserMetadataWrite(node) {
|
|
1503
|
+
const prop = memberPropName(node);
|
|
1504
|
+
if (prop !== "signUp" && prop !== "updateUser") return false;
|
|
1505
|
+
const dataPayload = findAuthDataPayload(node.arguments ?? []);
|
|
1506
|
+
return dataPayload ? objectHasAuthzDataKey(dataPayload) : false;
|
|
1507
|
+
}
|
|
1508
|
+
var rule20 = {
|
|
1509
|
+
meta: {
|
|
1510
|
+
type: "problem",
|
|
1511
|
+
docs: {
|
|
1512
|
+
description: "Do not store or read authorization data from user_metadata",
|
|
1513
|
+
category: "security",
|
|
1514
|
+
cwe: "CWE-285",
|
|
1515
|
+
owasp: "A01:2021 Broken Access Control",
|
|
1516
|
+
rationale: "Supabase documents raw_user_meta_data as client-writable and unsuitable for authorization. Reading user_metadata.role (or writing role into signUp/updateUser data) lets any signed-in user self-assign privileged roles from the browser. Store roles in app_metadata via a trusted server path, or in an RLS-protected profiles table.",
|
|
1517
|
+
docsUrl: "https://supabase.com/docs/guides/database/postgres/row-level-security",
|
|
1518
|
+
recommended: true
|
|
1519
|
+
},
|
|
1520
|
+
messages: {
|
|
1521
|
+
userMetadataAuthzRead: "Authorization is read from user_metadata, which the client can modify. Use app_metadata or a server-side role table instead.",
|
|
1522
|
+
userMetadataAuthzWrite: "Authorization data is written to user_metadata via signUp/updateUser, which the client can change later. Use app_metadata or a server-side role table instead."
|
|
1523
|
+
},
|
|
1524
|
+
schema: []
|
|
1525
|
+
},
|
|
1526
|
+
create(context) {
|
|
1527
|
+
return {
|
|
1528
|
+
MemberExpression(node) {
|
|
1529
|
+
if (isUserMetadataAuthzRead(node)) {
|
|
1530
|
+
context.report({ node, messageId: "userMetadataAuthzRead" });
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
CallExpression(node) {
|
|
1534
|
+
if (isAuthUserMetadataWrite(node)) {
|
|
1535
|
+
context.report({ node, messageId: "userMetadataAuthzWrite" });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
var supabaseNoUserMetadataAuthzRule = rule20;
|
|
1542
|
+
|
|
1543
|
+
// src/providers/supabase/rules/single-without-error-check.ts
|
|
1544
|
+
function isSingleSupabaseQuery(awaitArg) {
|
|
1545
|
+
return awaitArg?.type === "CallExpression" && chainHasMethod(awaitArg, "single");
|
|
1546
|
+
}
|
|
1547
|
+
var rule21 = {
|
|
1548
|
+
meta: {
|
|
1549
|
+
type: "suggestion",
|
|
1550
|
+
docs: {
|
|
1551
|
+
description: "Supabase .single() calls must inspect the returned error field",
|
|
1552
|
+
category: "correctness",
|
|
1553
|
+
rationale: ".single() signals zero-or-one-row intent via the error field (PGRST116), not by leaving data undefined silently. Destructuring only data and never reading error produces infinite spinners on deleted rows, bad IDs, or RLS-denied reads. Prefer .maybeSingle() or branch on error before rendering.",
|
|
1554
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/single",
|
|
1555
|
+
recommended: true
|
|
1556
|
+
},
|
|
1557
|
+
messages: {
|
|
1558
|
+
missingErrorCheck: "This .single() result ignores error \u2014 a missing or denied row will look like a perpetual load. Destructure error or use .maybeSingle()."
|
|
1559
|
+
},
|
|
1560
|
+
schema: []
|
|
1561
|
+
},
|
|
1562
|
+
create(context) {
|
|
1563
|
+
function checkAwaitBinding(node, pattern, awaitExpr) {
|
|
1564
|
+
if (!isSingleSupabaseQuery(awaitExpr.argument)) return;
|
|
1565
|
+
const names = destructuredNames(pattern);
|
|
1566
|
+
if (names.has("error")) return;
|
|
1567
|
+
context.report({ node, messageId: "missingErrorCheck" });
|
|
1568
|
+
}
|
|
1569
|
+
return {
|
|
1570
|
+
VariableDeclarator(node) {
|
|
1571
|
+
if (node.init?.type !== "AwaitExpression") return;
|
|
1572
|
+
checkAwaitBinding(node, node.id, node.init);
|
|
1573
|
+
},
|
|
1574
|
+
AssignmentExpression(node) {
|
|
1575
|
+
if (node.right?.type !== "AwaitExpression") return;
|
|
1576
|
+
checkAwaitBinding(node, node.left, node.right);
|
|
1577
|
+
},
|
|
1578
|
+
ExpressionStatement(node) {
|
|
1579
|
+
const expr = node.expression;
|
|
1580
|
+
if (expr?.type !== "AwaitExpression") return;
|
|
1581
|
+
if (!isSingleSupabaseQuery(expr.argument)) return;
|
|
1582
|
+
if (memberPropName(expr.argument) === "single") {
|
|
1583
|
+
context.report({ node, messageId: "missingErrorCheck" });
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
var supabaseSingleWithoutErrorCheckRule = rule21;
|
|
1590
|
+
|
|
1591
|
+
// src/providers/supabase/rules/non-atomic-replace-pattern.ts
|
|
1592
|
+
function isSupabaseTableMutation(node, kind) {
|
|
1593
|
+
return isSupabaseMutationKind(node, kind);
|
|
1594
|
+
}
|
|
1595
|
+
var rule22 = {
|
|
1596
|
+
meta: {
|
|
1597
|
+
type: "suggestion",
|
|
1598
|
+
docs: {
|
|
1599
|
+
description: "Supabase delete-then-insert replace patterns should check errors or use RPC",
|
|
1600
|
+
category: "correctness",
|
|
1601
|
+
rationale: "Replacing child rows by deleting all rows for a user then re-inserting is a common Supabase pattern, but sequential client calls are not transactional. If delete succeeds and a later insert fails, existing rows are gone with no error shown. Wrap both steps in a Postgres RPC function or check error after each call.",
|
|
1602
|
+
docsUrl: "https://supabase.com/docs/guides/database/functions",
|
|
1603
|
+
recommended: true
|
|
1604
|
+
},
|
|
1605
|
+
messages: {
|
|
1606
|
+
nonAtomicReplace: "This function deletes then re-inserts rows without checking errors \u2014 a failed insert after a successful delete loses data silently."
|
|
1607
|
+
},
|
|
1608
|
+
schema: []
|
|
1609
|
+
},
|
|
1610
|
+
create(context) {
|
|
1611
|
+
const fnStack = [];
|
|
1612
|
+
const mutationsByFunction = /* @__PURE__ */ new Map();
|
|
1613
|
+
function currentFunction() {
|
|
1614
|
+
return fnStack[fnStack.length - 1];
|
|
1615
|
+
}
|
|
1616
|
+
function recordMutation(node, awaitExpr, pattern, kind) {
|
|
1617
|
+
const fn = currentFunction();
|
|
1618
|
+
if (!fn) return;
|
|
1619
|
+
if (!isSupabaseTableMutation(awaitExpr.argument, kind)) return;
|
|
1620
|
+
const checksError = pattern ? destructuredNames(pattern).has("error") : false;
|
|
1621
|
+
const list = mutationsByFunction.get(fn) ?? [];
|
|
1622
|
+
list.push({ node, table: fromTableName(awaitExpr.argument), kind, checksError });
|
|
1623
|
+
mutationsByFunction.set(fn, list);
|
|
1624
|
+
}
|
|
1625
|
+
function enterFunction(node) {
|
|
1626
|
+
fnStack.push(node);
|
|
1627
|
+
}
|
|
1628
|
+
function exitFunction(node) {
|
|
1629
|
+
const sites = mutationsByFunction.get(node);
|
|
1630
|
+
if (sites) {
|
|
1631
|
+
const deletes = sites.filter((s) => s.kind === "delete");
|
|
1632
|
+
const inserts = sites.filter((s) => s.kind === "insert");
|
|
1633
|
+
if (deletes.length > 0 && inserts.length > 0) {
|
|
1634
|
+
const uncheckedDelete = deletes.some((d) => !d.checksError);
|
|
1635
|
+
const uncheckedInsert = inserts.some((i) => !i.checksError);
|
|
1636
|
+
const sameTable = uncheckedDelete && uncheckedInsert && deletes.some((d) => inserts.some((i) => d.table && i.table && d.table === i.table));
|
|
1637
|
+
if (sameTable) {
|
|
1638
|
+
context.report({ node: inserts[0].node, messageId: "nonAtomicReplace" });
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
mutationsByFunction.delete(node);
|
|
1642
|
+
}
|
|
1643
|
+
fnStack.pop();
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
FunctionDeclaration(node) {
|
|
1647
|
+
enterFunction(node);
|
|
1648
|
+
},
|
|
1649
|
+
"FunctionDeclaration:exit"(node) {
|
|
1650
|
+
exitFunction(node);
|
|
1651
|
+
},
|
|
1652
|
+
FunctionExpression(node) {
|
|
1653
|
+
enterFunction(node);
|
|
1654
|
+
},
|
|
1655
|
+
"FunctionExpression:exit"(node) {
|
|
1656
|
+
exitFunction(node);
|
|
1657
|
+
},
|
|
1658
|
+
ArrowFunctionExpression(node) {
|
|
1659
|
+
enterFunction(node);
|
|
1660
|
+
},
|
|
1661
|
+
"ArrowFunctionExpression:exit"(node) {
|
|
1662
|
+
exitFunction(node);
|
|
1663
|
+
},
|
|
1664
|
+
VariableDeclarator(node) {
|
|
1665
|
+
if (node.init?.type !== "AwaitExpression") return;
|
|
1666
|
+
const arg = node.init.argument;
|
|
1667
|
+
if (isSupabaseTableMutation(arg, "delete")) recordMutation(node, node.init, node.id, "delete");
|
|
1668
|
+
if (isSupabaseTableMutation(arg, "insert")) recordMutation(node, node.init, node.id, "insert");
|
|
1669
|
+
},
|
|
1670
|
+
ExpressionStatement(node) {
|
|
1671
|
+
const expr = node.expression;
|
|
1672
|
+
if (expr?.type !== "AwaitExpression") return;
|
|
1673
|
+
const arg = expr.argument;
|
|
1674
|
+
if (isSupabaseTableMutation(arg, "delete")) recordMutation(node, expr, void 0, "delete");
|
|
1675
|
+
if (isSupabaseTableMutation(arg, "insert")) recordMutation(node, expr, void 0, "insert");
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
var supabaseNonAtomicReplacePatternRule = rule22;
|
|
1681
|
+
|
|
1682
|
+
// src/providers/supabase/rules/unchecked-mutation-error.ts
|
|
1683
|
+
var MUTATIONS = ["insert", "update", "delete", "upsert"];
|
|
1684
|
+
function isSupabaseMutationCall(node) {
|
|
1685
|
+
return MUTATIONS.some((kind) => isSupabaseMutationKind(node, kind));
|
|
1686
|
+
}
|
|
1687
|
+
var rule23 = {
|
|
1688
|
+
meta: {
|
|
1689
|
+
type: "suggestion",
|
|
1690
|
+
docs: {
|
|
1691
|
+
description: "Supabase mutations must check the returned error field",
|
|
1692
|
+
category: "correctness",
|
|
1693
|
+
rationale: "Unlike fetch(), Supabase client mutations return { data, error } and resolve even when RLS denies the write or a constraint fails. Fire-and-forget awaits or destructuring only data lets optimistic UI state diverge from the database with no toast or rollback.",
|
|
1694
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/insert",
|
|
1695
|
+
recommended: true
|
|
1696
|
+
},
|
|
1697
|
+
messages: {
|
|
1698
|
+
uncheckedMutation: "This Supabase mutation never checks error \u2014 RLS denials and constraint failures will be silent."
|
|
1699
|
+
},
|
|
1700
|
+
schema: []
|
|
1701
|
+
},
|
|
1702
|
+
create(context) {
|
|
1703
|
+
function checkMutationAwait(node, pattern, awaitExpr) {
|
|
1704
|
+
if (!isSupabaseMutationCall(awaitExpr.argument)) return;
|
|
1705
|
+
if (!pattern) {
|
|
1706
|
+
context.report({ node, messageId: "uncheckedMutation" });
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const names = destructuredNames(pattern);
|
|
1710
|
+
if (!names.has("error")) {
|
|
1711
|
+
context.report({ node, messageId: "uncheckedMutation" });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return {
|
|
1715
|
+
ExpressionStatement(node) {
|
|
1716
|
+
const expr = node.expression;
|
|
1717
|
+
if (expr?.type !== "AwaitExpression") return;
|
|
1718
|
+
checkMutationAwait(node, void 0, expr);
|
|
1719
|
+
},
|
|
1720
|
+
VariableDeclarator(node) {
|
|
1721
|
+
if (node.init?.type !== "AwaitExpression") return;
|
|
1722
|
+
checkMutationAwait(node, node.id, node.init);
|
|
1723
|
+
},
|
|
1724
|
+
AssignmentExpression(node) {
|
|
1725
|
+
if (node.right?.type !== "AwaitExpression") return;
|
|
1726
|
+
checkMutationAwait(node, node.left, node.right);
|
|
1727
|
+
}
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
var supabaseUncheckedMutationErrorRule = rule23;
|
|
1732
|
+
|
|
1733
|
+
// src/providers/supabase/rules/realtime-missing-filter.ts
|
|
1734
|
+
var rule24 = {
|
|
1735
|
+
meta: {
|
|
1736
|
+
type: "suggestion",
|
|
1737
|
+
docs: {
|
|
1738
|
+
description: "Supabase Realtime postgres_changes subscriptions should use a filter",
|
|
1739
|
+
category: "reliability",
|
|
1740
|
+
rationale: "Listening to postgres_changes on an entire table without a filter means every insert/update/delete by any user triggers the callback on every connected client. RLS still scopes the data, but the refetch storm scales with concurrent users and will degrade under real load. Scope subscriptions with the documented filter option (e.g. receiver_id=eq.{id}).",
|
|
1741
|
+
docsUrl: "https://supabase.com/docs/guides/realtime/postgres-changes#filtering",
|
|
1742
|
+
recommended: true
|
|
1743
|
+
},
|
|
1744
|
+
messages: {
|
|
1745
|
+
missingFilter: "This Realtime postgres_changes subscription has no filter and will fire on every row change in the table."
|
|
1746
|
+
},
|
|
1747
|
+
schema: []
|
|
1748
|
+
},
|
|
1749
|
+
create(context) {
|
|
1750
|
+
return {
|
|
1751
|
+
CallExpression(node) {
|
|
1752
|
+
if (memberPropName(node) !== "on") return;
|
|
1753
|
+
const eventArg = node.arguments?.[0];
|
|
1754
|
+
if (eventArg?.type !== "Literal" || eventArg.value !== "postgres_changes") return;
|
|
1755
|
+
const options = node.arguments?.[1];
|
|
1756
|
+
if (options?.type !== "ObjectExpression") return;
|
|
1757
|
+
const hasFilter = (options.properties ?? []).some((p) => {
|
|
1758
|
+
if (p?.type !== "Property") return false;
|
|
1759
|
+
const key = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1760
|
+
return key === "filter";
|
|
1761
|
+
});
|
|
1762
|
+
if (!hasFilter) {
|
|
1763
|
+
context.report({ node, messageId: "missingFilter" });
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
var supabaseRealtimeMissingFilterRule = rule24;
|
|
1770
|
+
|
|
1771
|
+
// src/providers/supabase/rules/storage-error-not-surfaced.ts
|
|
1772
|
+
function isStorageUploadCall(node) {
|
|
1773
|
+
let current = node;
|
|
1774
|
+
let sawStorage = false;
|
|
1775
|
+
let sawUpload = false;
|
|
1776
|
+
while (current?.type === "CallExpression") {
|
|
1777
|
+
const prop = memberPropName(current);
|
|
1778
|
+
if (prop === "storage") sawStorage = true;
|
|
1779
|
+
if (prop === "upload") sawUpload = true;
|
|
1780
|
+
current = chainObjectCall(current);
|
|
1781
|
+
}
|
|
1782
|
+
return sawStorage && sawUpload;
|
|
1783
|
+
}
|
|
1784
|
+
var rule25 = {
|
|
1785
|
+
meta: {
|
|
1786
|
+
type: "suggestion",
|
|
1787
|
+
docs: {
|
|
1788
|
+
description: "Supabase storage upload errors must be surfaced to the user",
|
|
1789
|
+
category: "reliability",
|
|
1790
|
+
rationale: "Storage uploads return { error } without throwing. An if (!uploadError) block with no else lets the caller fall through to a profile save and success toast even when the file never uploaded \u2014 the user only notices later that their avatar or resume did not change.",
|
|
1791
|
+
docsUrl: "https://supabase.com/docs/reference/javascript/storage-from-upload",
|
|
1792
|
+
recommended: true
|
|
1793
|
+
},
|
|
1794
|
+
messages: {
|
|
1795
|
+
uploadErrorNotSurfaced: "Storage upload failure is ignored when uploadError is set \u2014 add an else branch to stop and show an error."
|
|
1796
|
+
},
|
|
1797
|
+
schema: []
|
|
1798
|
+
},
|
|
1799
|
+
create(context) {
|
|
1800
|
+
const uploadAwaitVars = /* @__PURE__ */ new Set();
|
|
1801
|
+
return {
|
|
1802
|
+
VariableDeclarator(node) {
|
|
1803
|
+
if (node.init?.type !== "AwaitExpression") return;
|
|
1804
|
+
if (!isStorageUploadCall(node.init.argument)) return;
|
|
1805
|
+
if (node.id?.type === "ObjectPattern") {
|
|
1806
|
+
for (const p of node.id.properties ?? []) {
|
|
1807
|
+
if (p?.type !== "Property") continue;
|
|
1808
|
+
if (p.key?.type === "Identifier" && p.key.name === "error" && p.value?.type === "Identifier") {
|
|
1809
|
+
uploadAwaitVars.add(p.value.name);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
IfStatement(node) {
|
|
1815
|
+
const test = node.test;
|
|
1816
|
+
if (test?.type !== "UnaryExpression" || test.operator !== "!") return;
|
|
1817
|
+
const arg = test.argument;
|
|
1818
|
+
if (arg?.type !== "Identifier") return;
|
|
1819
|
+
if (!uploadAwaitVars.has(arg.name) && !/uploadError|uploadErr/i.test(arg.name)) return;
|
|
1820
|
+
if (node.alternate) return;
|
|
1821
|
+
context.report({ node, messageId: "uploadErrorNotSurfaced" });
|
|
1822
|
+
},
|
|
1823
|
+
"Program:exit"() {
|
|
1824
|
+
uploadAwaitVars.clear();
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
var supabaseStorageErrorNotSurfacedRule = rule25;
|
|
1830
|
+
|
|
1831
|
+
// src/providers/auth0/rules/required-audience-validation.ts
|
|
1832
|
+
var rule26 = {
|
|
1833
|
+
meta: {
|
|
1834
|
+
type: "problem",
|
|
1835
|
+
docs: {
|
|
1836
|
+
description: "Auth0 JWT verification must require audience validation, not skip it when unconfigured",
|
|
1837
|
+
category: "security",
|
|
1838
|
+
cwe: "CWE-345",
|
|
1839
|
+
owasp: "API2:2023 Broken Authentication",
|
|
1840
|
+
rationale: "Signature and issuer checks alone only prove a token was signed by the Auth0 tenant \u2014 they do not prove the token was issued for this API. Without a mandatory audience check, any validly signed token from the same tenant (including one issued for a completely different application) will authenticate successfully. If the audience check is only applied when a config value happens to be set, a misconfiguration silently disables it instead of failing closed.",
|
|
1841
|
+
docsUrl: "https://auth0.com/docs/secure/tokens/json-web-tokens/validate-json-web-tokens",
|
|
1842
|
+
recommended: true
|
|
1843
|
+
},
|
|
1844
|
+
messages: {
|
|
1845
|
+
missingAudience: "Auth0 JWT verification is configured without an audience check \u2014 any token signed by this tenant, regardless of which API it was issued for, will be accepted.",
|
|
1846
|
+
conditionalAudience: "Audience validation is only applied conditionally here \u2014 if the underlying config value is ever unset, the check silently disappears instead of failing closed."
|
|
1847
|
+
}
|
|
1848
|
+
},
|
|
1849
|
+
create(context) {
|
|
1850
|
+
const jwtImportNames = /* @__PURE__ */ new Set();
|
|
1851
|
+
const expressJwtImportNames = /* @__PURE__ */ new Set();
|
|
1852
|
+
function isRS256Algorithms(node) {
|
|
1853
|
+
if (node?.type !== "ArrayExpression") return false;
|
|
1854
|
+
return (node.elements ?? []).some(
|
|
1855
|
+
(el) => el?.type === "Literal" && el.value === "RS256"
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
function isAuth0StyleOptions(objectExpr) {
|
|
1859
|
+
if (objectExpr?.type !== "ObjectExpression") return false;
|
|
1860
|
+
let hasRS256 = false;
|
|
1861
|
+
let hasIssuer = false;
|
|
1862
|
+
for (const prop of objectExpr.properties ?? []) {
|
|
1863
|
+
if (prop?.type !== "Property") continue;
|
|
1864
|
+
const keyName = prop.key?.type === "Identifier" ? prop.key.name : prop.key?.value;
|
|
1865
|
+
if (keyName === "algorithms" && isRS256Algorithms(prop.value)) hasRS256 = true;
|
|
1866
|
+
if (keyName === "issuer") hasIssuer = true;
|
|
1867
|
+
}
|
|
1868
|
+
return hasRS256 && hasIssuer;
|
|
1869
|
+
}
|
|
1870
|
+
function checkAudience(objectExpr) {
|
|
1871
|
+
let sawAudienceProperty = false;
|
|
1872
|
+
let sawConditionalSpread = false;
|
|
1873
|
+
for (const prop of objectExpr.properties ?? []) {
|
|
1874
|
+
if (prop?.type === "Property") {
|
|
1875
|
+
const keyName = prop.key?.type === "Identifier" ? prop.key.name : prop.key?.value;
|
|
1876
|
+
if (keyName === "audience") sawAudienceProperty = true;
|
|
1877
|
+
}
|
|
1878
|
+
if (prop?.type === "SpreadElement" && prop.argument?.type === "ConditionalExpression") {
|
|
1879
|
+
sawConditionalSpread = true;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
if (sawAudienceProperty) return "ok";
|
|
1883
|
+
if (sawConditionalSpread) return "conditional";
|
|
1884
|
+
return "missing";
|
|
1885
|
+
}
|
|
1886
|
+
function reportIfBad(objectExpr, reportNode) {
|
|
1887
|
+
if (!isAuth0StyleOptions(objectExpr)) return;
|
|
1888
|
+
const result = checkAudience(objectExpr);
|
|
1889
|
+
if (result === "missing") {
|
|
1890
|
+
context.report({ node: reportNode, messageId: "missingAudience" });
|
|
1891
|
+
} else if (result === "conditional") {
|
|
1892
|
+
context.report({ node: reportNode, messageId: "conditionalAudience" });
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
ImportDeclaration(node) {
|
|
1897
|
+
const importSource = node?.source?.value;
|
|
1898
|
+
if (importSource === "jsonwebtoken") {
|
|
1899
|
+
for (const s of node.specifiers ?? []) {
|
|
1900
|
+
if ((s?.type === "ImportDefaultSpecifier" || s?.type === "ImportNamespaceSpecifier") && s.local?.type === "Identifier") {
|
|
1901
|
+
jwtImportNames.add(s.local.name);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (importSource === "express-jwt") {
|
|
1906
|
+
for (const s of node.specifiers ?? []) {
|
|
1907
|
+
if (s?.local?.type === "Identifier") expressJwtImportNames.add(s.local.name);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
CallExpression(node) {
|
|
1912
|
+
const callee = node?.callee;
|
|
1913
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.name === "verify" && callee.object?.type === "Identifier" && jwtImportNames.has(callee.object.name)) {
|
|
1914
|
+
const optionsArg = node.arguments?.[2];
|
|
1915
|
+
if (optionsArg) reportIfBad(optionsArg, node);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
if (callee?.type === "Identifier" && expressJwtImportNames.has(callee.name)) {
|
|
1919
|
+
const optionsArg = node.arguments?.[0];
|
|
1920
|
+
if (optionsArg) reportIfBad(optionsArg, node);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
var auth0RequiredAudienceValidationRule = rule26;
|
|
1927
|
+
|
|
1928
|
+
// src/providers/auth0/rules/no-account-link-without-verified-email.ts
|
|
1929
|
+
var CLAIMS_LIKE_NAMES = /* @__PURE__ */ new Set([
|
|
1930
|
+
"claims",
|
|
1931
|
+
"decoded",
|
|
1932
|
+
"payload",
|
|
1933
|
+
"profile",
|
|
1934
|
+
"userinfo",
|
|
1935
|
+
"userInfo",
|
|
1936
|
+
"idTokenClaims",
|
|
1937
|
+
"tokenClaims"
|
|
1938
|
+
]);
|
|
1939
|
+
var LOOKUP_METHODS = /* @__PURE__ */ new Set(["findOne", "findUnique", "findFirst"]);
|
|
1940
|
+
var rule27 = {
|
|
1941
|
+
meta: {
|
|
1942
|
+
type: "problem",
|
|
1943
|
+
docs: {
|
|
1944
|
+
description: "Account-linking lookups keyed on an email claim must verify email_verified first",
|
|
1945
|
+
category: "security",
|
|
1946
|
+
cwe: "CWE-290",
|
|
1947
|
+
owasp: "API2:2023 Broken Authentication",
|
|
1948
|
+
rationale: "By default, Auth0 access tokens carry no email claim at all \u2014 the email most apps trust arrives via a custom namespaced claim or the /userinfo fallback, neither of which is cryptographically tied to proof of ownership the way `sub` is. Using that email to look up and re-link an existing account, without first checking `email_verified`, lets an attacker who controls (or can register) an Auth0 identity with an unverified or spoofed email silently take over any existing account that happens to share it.",
|
|
1949
|
+
docsUrl: "https://auth0.com/docs/manage-users/user-accounts/user-account-linking",
|
|
1950
|
+
recommended: true
|
|
1951
|
+
},
|
|
1952
|
+
messages: {
|
|
1953
|
+
unverifiedEmailLink: "This account lookup is keyed on an email claim, but nothing in this function checks email_verified first \u2014 an unverified or attacker-supplied email can silently take over an existing account."
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
create(context) {
|
|
1957
|
+
const stack = [];
|
|
1958
|
+
const states = /* @__PURE__ */ new Map();
|
|
1959
|
+
function ensureState(fn) {
|
|
1960
|
+
let s = states.get(fn);
|
|
1961
|
+
if (!s) {
|
|
1962
|
+
s = { sawLookup: false, lookupNode: null, sawVerified: false };
|
|
1963
|
+
states.set(fn, s);
|
|
1964
|
+
}
|
|
1965
|
+
return s;
|
|
1966
|
+
}
|
|
1967
|
+
function top() {
|
|
1968
|
+
return stack[stack.length - 1];
|
|
1969
|
+
}
|
|
1970
|
+
function pushScope(node) {
|
|
1971
|
+
stack.push(node);
|
|
1972
|
+
ensureState(node);
|
|
1973
|
+
}
|
|
1974
|
+
function popScope() {
|
|
1975
|
+
stack.pop();
|
|
1976
|
+
}
|
|
1977
|
+
function propName2(node) {
|
|
1978
|
+
if (!node) return void 0;
|
|
1979
|
+
if (node.type === "Identifier") return node.name;
|
|
1980
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
1981
|
+
return void 0;
|
|
1982
|
+
}
|
|
1983
|
+
function isEmailFromClaims(node) {
|
|
1984
|
+
if (node?.type !== "MemberExpression") return false;
|
|
1985
|
+
const propertyName = node.computed ? propName2(node.property) : node.property?.name;
|
|
1986
|
+
if (propertyName !== "email") return false;
|
|
1987
|
+
const obj = node.object;
|
|
1988
|
+
return obj?.type === "Identifier" && CLAIMS_LIKE_NAMES.has(obj.name);
|
|
1989
|
+
}
|
|
1990
|
+
function objectHasEmailFromClaims(objExpr) {
|
|
1991
|
+
if (objExpr?.type !== "ObjectExpression") return false;
|
|
1992
|
+
for (const prop of objExpr.properties ?? []) {
|
|
1993
|
+
if (prop?.type !== "Property") continue;
|
|
1994
|
+
const keyName = propName2(prop.key);
|
|
1995
|
+
if (keyName === "email" && isEmailFromClaims(prop.value)) return true;
|
|
1996
|
+
if (keyName === "where" && objectHasEmailFromClaims(prop.value)) return true;
|
|
1997
|
+
}
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
function markVerifiedSeen(name) {
|
|
2001
|
+
if (!name || !name.includes("email_verified")) return;
|
|
2002
|
+
const fn = top();
|
|
2003
|
+
if (fn) ensureState(fn).sawVerified = true;
|
|
2004
|
+
}
|
|
2005
|
+
return {
|
|
2006
|
+
Program(node) {
|
|
2007
|
+
pushScope(node);
|
|
2008
|
+
},
|
|
2009
|
+
"Program:exit"() {
|
|
2010
|
+
for (const state of states.values()) {
|
|
2011
|
+
if (state.sawLookup && !state.sawVerified) {
|
|
2012
|
+
context.report({ node: state.lookupNode, messageId: "unverifiedEmailLink" });
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
},
|
|
2016
|
+
FunctionDeclaration(node) {
|
|
2017
|
+
pushScope(node);
|
|
2018
|
+
},
|
|
2019
|
+
"FunctionDeclaration:exit"() {
|
|
2020
|
+
popScope();
|
|
2021
|
+
},
|
|
2022
|
+
FunctionExpression(node) {
|
|
2023
|
+
pushScope(node);
|
|
2024
|
+
},
|
|
2025
|
+
"FunctionExpression:exit"() {
|
|
2026
|
+
popScope();
|
|
2027
|
+
},
|
|
2028
|
+
ArrowFunctionExpression(node) {
|
|
2029
|
+
pushScope(node);
|
|
2030
|
+
},
|
|
2031
|
+
"ArrowFunctionExpression:exit"() {
|
|
2032
|
+
popScope();
|
|
2033
|
+
},
|
|
2034
|
+
CallExpression(node) {
|
|
2035
|
+
const callee = node?.callee;
|
|
2036
|
+
if (callee?.type !== "MemberExpression") return;
|
|
2037
|
+
const methodName = callee.property?.name;
|
|
2038
|
+
if (!methodName || !LOOKUP_METHODS.has(methodName)) return;
|
|
2039
|
+
const arg = node.arguments?.[0];
|
|
2040
|
+
if (!objectHasEmailFromClaims(arg)) return;
|
|
2041
|
+
const fn = top();
|
|
2042
|
+
if (!fn) return;
|
|
2043
|
+
const state = ensureState(fn);
|
|
2044
|
+
state.sawLookup = true;
|
|
2045
|
+
state.lookupNode = node;
|
|
2046
|
+
},
|
|
2047
|
+
MemberExpression(node) {
|
|
2048
|
+
const propertyName = node.computed ? propName2(node.property) : node.property?.name;
|
|
2049
|
+
markVerifiedSeen(propertyName);
|
|
2050
|
+
},
|
|
2051
|
+
Literal(node) {
|
|
2052
|
+
if (typeof node.value === "string") markVerifiedSeen(node.value);
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
var auth0NoAccountLinkWithoutVerifiedEmailRule = rule27;
|
|
2058
|
+
|
|
2059
|
+
// src/providers/auth0/rules/dead-claim-verification-check.ts
|
|
2060
|
+
var rule28 = {
|
|
2061
|
+
meta: {
|
|
2062
|
+
type: "problem",
|
|
2063
|
+
docs: {
|
|
2064
|
+
description: "A stringified boolean OIDC claim is checked with .includes() against a substring it can never contain",
|
|
2065
|
+
category: "correctness",
|
|
2066
|
+
rationale: 'Boolean OIDC claims like email_verified/phone_verified stringify to exactly "true" or "false". A .includes() check against any other substring is always false, so the guard this code appears to implement never runs \u2014 silently disabling whatever verification it was meant to perform.',
|
|
2067
|
+
docsUrl: "https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims",
|
|
2068
|
+
recommended: true
|
|
2069
|
+
},
|
|
2070
|
+
messages: {
|
|
2071
|
+
deadVerificationCheck: 'Stringifying this boolean claim only ever produces "true" or "false" \u2014 checking .includes("{{substring}}") against it is always false and never executes its guarded branch.'
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2074
|
+
create(context) {
|
|
2075
|
+
function propName2(node) {
|
|
2076
|
+
if (!node) return void 0;
|
|
2077
|
+
if (node.type === "Identifier") return node.name;
|
|
2078
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2079
|
+
return void 0;
|
|
2080
|
+
}
|
|
2081
|
+
function isBooleanClaimMember(node) {
|
|
2082
|
+
if (node?.type !== "MemberExpression") return false;
|
|
2083
|
+
const name = propName2(node.property);
|
|
2084
|
+
return typeof name === "string" && name.toLowerCase().endsWith("_verified");
|
|
2085
|
+
}
|
|
2086
|
+
function stringifiedClaimArg(callExpr) {
|
|
2087
|
+
if (callExpr?.type !== "CallExpression") return false;
|
|
2088
|
+
if (callExpr.callee?.type !== "Identifier" || callExpr.callee.name !== "String") return false;
|
|
2089
|
+
const arg = callExpr.arguments?.[0];
|
|
2090
|
+
if (isBooleanClaimMember(arg)) return true;
|
|
2091
|
+
if (arg?.type === "LogicalExpression" && arg.operator === "??") {
|
|
2092
|
+
return isBooleanClaimMember(arg.left);
|
|
2093
|
+
}
|
|
2094
|
+
return false;
|
|
2095
|
+
}
|
|
2096
|
+
function isClaimToStringCall(callExpr) {
|
|
2097
|
+
if (callExpr?.type !== "CallExpression") return false;
|
|
2098
|
+
const callee = callExpr.callee;
|
|
2099
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
2100
|
+
if (callee.property?.name !== "toString") return false;
|
|
2101
|
+
return isBooleanClaimMember(callee.object);
|
|
2102
|
+
}
|
|
2103
|
+
return {
|
|
2104
|
+
CallExpression(node) {
|
|
2105
|
+
const callee = node?.callee;
|
|
2106
|
+
if (callee?.type !== "MemberExpression") return;
|
|
2107
|
+
if (callee.property?.name !== "includes") return;
|
|
2108
|
+
const subject = callee.object;
|
|
2109
|
+
const isDeadShape = stringifiedClaimArg(subject) || isClaimToStringCall(subject);
|
|
2110
|
+
if (!isDeadShape) return;
|
|
2111
|
+
const substringArg = node.arguments?.[0];
|
|
2112
|
+
if (substringArg?.type !== "Literal" || typeof substringArg.value !== "string") return;
|
|
2113
|
+
if (substringArg.value === "true" || substringArg.value === "false") return;
|
|
2114
|
+
context.report({
|
|
2115
|
+
node,
|
|
2116
|
+
messageId: "deadVerificationCheck",
|
|
2117
|
+
data: { substring: substringArg.value }
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
var auth0DeadClaimVerificationCheckRule = rule28;
|
|
2124
|
+
|
|
2125
|
+
// src/providers/auth0/rules/jwks-refresh-on-unknown-kid.ts
|
|
2126
|
+
var rule29 = {
|
|
2127
|
+
meta: {
|
|
2128
|
+
type: "problem",
|
|
2129
|
+
docs: {
|
|
2130
|
+
description: "A hand-rolled JWKS key resolver must retry with a forced refresh when a kid is not found in the cache",
|
|
2131
|
+
category: "reliability",
|
|
2132
|
+
rationale: "On signing-key rotation, Auth0 immediately starts issuing tokens signed with a new key while the old key stays valid in parallel. A worker whose local JWKS cache predates the rotation has the old key but not the new one, so every newly issued token fails to validate until that worker's cache naturally expires \u2014 up to the full TTL. Retrying with a forced refresh on a kid miss (instead of failing immediately) closes that window.",
|
|
2133
|
+
docsUrl: "https://auth0.com/docs/get-started/tenant-settings/signing-keys/rotate-signing-keys",
|
|
2134
|
+
recommended: true
|
|
2135
|
+
},
|
|
2136
|
+
messages: {
|
|
2137
|
+
noRefreshOnMiss: "This kid lookup has no retry-with-forced-refresh path. If the cached JWKS predates a key rotation, every token signed with the new key fails until the cache naturally expires."
|
|
2138
|
+
}
|
|
2139
|
+
},
|
|
2140
|
+
create(context) {
|
|
2141
|
+
const fnStates = [];
|
|
2142
|
+
function nodeStartPos(n) {
|
|
2143
|
+
const rangeStart = typeof n?.range?.[0] === "number" ? n.range[0] : null;
|
|
2144
|
+
const line = n?.loc?.start?.line ?? 1;
|
|
2145
|
+
const column = n?.loc?.start?.column ?? 0;
|
|
2146
|
+
return { offset: rangeStart ?? line * 1e6 + column, line, column };
|
|
2147
|
+
}
|
|
2148
|
+
function nodeEndPos(n) {
|
|
2149
|
+
const rangeEnd = typeof n?.range?.[1] === "number" ? n.range[1] : null;
|
|
2150
|
+
const line = n?.loc?.end?.line ?? n?.loc?.start?.line ?? 1;
|
|
2151
|
+
const column = n?.loc?.end?.column ?? n?.loc?.start?.column ?? 0;
|
|
2152
|
+
return { offset: rangeEnd ?? line * 1e6 + column, line, column };
|
|
2153
|
+
}
|
|
2154
|
+
function within(state, n) {
|
|
2155
|
+
const p = nodeStartPos(n);
|
|
2156
|
+
return p.offset >= state.start.offset && p.offset <= state.end.offset;
|
|
2157
|
+
}
|
|
2158
|
+
function collectTopLevelFunctions(programNode) {
|
|
2159
|
+
const fns = [];
|
|
2160
|
+
for (const stmt of programNode.body ?? []) {
|
|
2161
|
+
const decl = stmt?.type === "ExportNamedDeclaration" && stmt.declaration ? stmt.declaration : stmt;
|
|
2162
|
+
if (decl?.type === "FunctionDeclaration") {
|
|
2163
|
+
fns.push(decl);
|
|
2164
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
2165
|
+
for (const d of decl.declarations ?? []) {
|
|
2166
|
+
if (d?.init?.type === "ArrowFunctionExpression" || d?.init?.type === "FunctionExpression") {
|
|
2167
|
+
fns.push(d.init);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
return fns;
|
|
2173
|
+
}
|
|
2174
|
+
function propName2(node) {
|
|
2175
|
+
if (!node) return void 0;
|
|
2176
|
+
if (node.type === "Identifier") return node.name;
|
|
2177
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2178
|
+
return void 0;
|
|
2179
|
+
}
|
|
2180
|
+
function isKidMember(node) {
|
|
2181
|
+
if (node?.type !== "MemberExpression") return false;
|
|
2182
|
+
return propName2(node.property) === "kid";
|
|
2183
|
+
}
|
|
2184
|
+
function isJwksFetchCallee(node) {
|
|
2185
|
+
return node?.type === "Identifier" && /jwks/i.test(node.name);
|
|
2186
|
+
}
|
|
2187
|
+
function callIndicatesForceRefresh(node) {
|
|
2188
|
+
for (const arg of node.arguments ?? []) {
|
|
2189
|
+
if (arg?.type === "Literal" && arg.value === true) return true;
|
|
2190
|
+
if (arg?.type === "ObjectExpression") {
|
|
2191
|
+
for (const prop of arg.properties ?? []) {
|
|
2192
|
+
if (prop?.type !== "Property") continue;
|
|
2193
|
+
const keyName = propName2(prop.key);
|
|
2194
|
+
if ((keyName === "forceRefresh" || keyName === "force" || keyName === "bypassCache") && prop.value?.type === "Literal" && prop.value.value === true) {
|
|
2195
|
+
return true;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
function stateFor(node) {
|
|
2203
|
+
return fnStates.find((s) => within(s, node));
|
|
2204
|
+
}
|
|
2205
|
+
return {
|
|
2206
|
+
Program(node) {
|
|
2207
|
+
for (const fn of collectTopLevelFunctions(node)) {
|
|
2208
|
+
fnStates.push({
|
|
2209
|
+
node: fn,
|
|
2210
|
+
start: nodeStartPos(fn),
|
|
2211
|
+
end: nodeEndPos(fn),
|
|
2212
|
+
findCallNode: null,
|
|
2213
|
+
sawKidComparison: false,
|
|
2214
|
+
fetchCallCount: 0,
|
|
2215
|
+
sawForceRefresh: false
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
},
|
|
2219
|
+
CallExpression(node) {
|
|
2220
|
+
const state = stateFor(node);
|
|
2221
|
+
if (!state) return;
|
|
2222
|
+
const callee = node.callee;
|
|
2223
|
+
if (callee?.type === "MemberExpression" && callee.property?.name === "find") {
|
|
2224
|
+
if (!state.findCallNode) state.findCallNode = node;
|
|
2225
|
+
}
|
|
2226
|
+
if (isJwksFetchCallee(callee)) {
|
|
2227
|
+
state.fetchCallCount += 1;
|
|
2228
|
+
if (callIndicatesForceRefresh(node)) state.sawForceRefresh = true;
|
|
2229
|
+
}
|
|
2230
|
+
},
|
|
2231
|
+
BinaryExpression(node) {
|
|
2232
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
2233
|
+
const state = stateFor(node);
|
|
2234
|
+
if (!state) return;
|
|
2235
|
+
if (isKidMember(node.left) || isKidMember(node.right)) {
|
|
2236
|
+
state.sawKidComparison = true;
|
|
2237
|
+
}
|
|
2238
|
+
},
|
|
2239
|
+
"Program:exit"() {
|
|
2240
|
+
for (const state of fnStates) {
|
|
2241
|
+
if (!state.findCallNode || !state.sawKidComparison) continue;
|
|
2242
|
+
if (state.sawForceRefresh) continue;
|
|
2243
|
+
if (state.fetchCallCount >= 2) continue;
|
|
2244
|
+
context.report({ node: state.findCallNode, messageId: "noRefreshOnMiss" });
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
var auth0JwksRefreshOnUnknownKidRule = rule29;
|
|
2251
|
+
|
|
2252
|
+
// src/providers/firebase/utils.ts
|
|
2253
|
+
function namedImportsFrom(node, sourceValue) {
|
|
2254
|
+
const map = /* @__PURE__ */ new Map();
|
|
2255
|
+
if (node?.type !== "ImportDeclaration" || node.source?.value !== sourceValue) return map;
|
|
2256
|
+
for (const s of node.specifiers ?? []) {
|
|
2257
|
+
if (s?.type === "ImportSpecifier" && s.imported?.type === "Identifier" && s.local?.type === "Identifier") {
|
|
2258
|
+
map.set(s.imported.name, s.local.name);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
return map;
|
|
2262
|
+
}
|
|
2263
|
+
function namespaceImportFrom(node, sourceValue) {
|
|
2264
|
+
if (node?.type !== "ImportDeclaration" || node.source?.value !== sourceValue) return void 0;
|
|
2265
|
+
for (const s of node.specifiers ?? []) {
|
|
2266
|
+
if (s?.type === "ImportNamespaceSpecifier" && s.local?.type === "Identifier") return s.local.name;
|
|
2267
|
+
}
|
|
2268
|
+
return void 0;
|
|
2269
|
+
}
|
|
2270
|
+
function isIdentifierCall(node, name) {
|
|
2271
|
+
if (!name) return false;
|
|
2272
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === name;
|
|
2273
|
+
}
|
|
2274
|
+
function isNamespaceMemberCall(node, objName, name) {
|
|
2275
|
+
if (!objName) return false;
|
|
2276
|
+
if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
|
|
2277
|
+
const callee = node.callee;
|
|
2278
|
+
if (callee.computed) return false;
|
|
2279
|
+
return callee.object?.type === "Identifier" && callee.object.name === objName && callee.property?.type === "Identifier" && callee.property.name === name;
|
|
2280
|
+
}
|
|
2281
|
+
function memberPropName2(node) {
|
|
2282
|
+
if (node?.type !== "CallExpression") return void 0;
|
|
2283
|
+
const callee = node.callee;
|
|
2284
|
+
if (callee?.type !== "MemberExpression") return void 0;
|
|
2285
|
+
const prop = callee.property;
|
|
2286
|
+
if (!callee.computed && prop?.type === "Identifier") return prop.name;
|
|
2287
|
+
if (callee.computed && prop?.type === "Literal" && typeof prop.value === "string") return prop.value;
|
|
2288
|
+
return void 0;
|
|
2289
|
+
}
|
|
2290
|
+
function chainObjectCall2(node) {
|
|
2291
|
+
const obj = node?.callee?.object;
|
|
2292
|
+
return obj?.type === "CallExpression" ? obj : null;
|
|
2293
|
+
}
|
|
2294
|
+
function chainLinks(node) {
|
|
2295
|
+
const links = [];
|
|
2296
|
+
let current = node;
|
|
2297
|
+
while (current?.type === "CallExpression") {
|
|
2298
|
+
links.push(current);
|
|
2299
|
+
current = chainObjectCall2(current);
|
|
2300
|
+
}
|
|
2301
|
+
return links;
|
|
2302
|
+
}
|
|
2303
|
+
function startOffset2(n) {
|
|
2304
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
2305
|
+
if (typeof n?.start === "number") return n.start;
|
|
2306
|
+
return (n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.start?.column ?? 0);
|
|
2307
|
+
}
|
|
2308
|
+
function endOffset2(n) {
|
|
2309
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
2310
|
+
if (typeof n?.end === "number") return n.end;
|
|
2311
|
+
return (n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.end?.column ?? 0);
|
|
2312
|
+
}
|
|
2313
|
+
function contains2(outer, inner) {
|
|
2314
|
+
const s = startOffset2(inner);
|
|
2315
|
+
return s >= startOffset2(outer) && s <= endOffset2(outer);
|
|
2316
|
+
}
|
|
2317
|
+
function someDescendant(node, predicate) {
|
|
2318
|
+
let found = false;
|
|
2319
|
+
function visit(n) {
|
|
2320
|
+
if (found || !n || typeof n !== "object") return;
|
|
2321
|
+
if (Array.isArray(n)) {
|
|
2322
|
+
for (const item of n) visit(item);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (typeof n.type !== "string") return;
|
|
2326
|
+
if (predicate(n)) {
|
|
2327
|
+
found = true;
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
for (const key of Object.keys(n)) {
|
|
2331
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
2332
|
+
visit(n[key]);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
visit(node);
|
|
2336
|
+
return found;
|
|
2337
|
+
}
|
|
2338
|
+
function isUidMemberAccess(node, objName) {
|
|
2339
|
+
const n = node?.type === "ChainExpression" ? node.expression : node;
|
|
2340
|
+
if (n?.type !== "MemberExpression" || n.computed) return false;
|
|
2341
|
+
return n.object?.type === "Identifier" && n.object.name === objName && n.property?.type === "Identifier" && n.property.name === "uid";
|
|
2342
|
+
}
|
|
2343
|
+
function comparesIdentifier(node, name) {
|
|
2344
|
+
if (node?.type !== "BinaryExpression") return false;
|
|
2345
|
+
if (node.operator !== "===" && node.operator !== "==") return false;
|
|
2346
|
+
return [node.left, node.right].some((s) => s?.type === "Identifier" && s.name === name);
|
|
2347
|
+
}
|
|
2348
|
+
function isInsideTestFile2(filename) {
|
|
2349
|
+
return /(^|[\\/])__tests__[\\/]|\.(test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// src/providers/firebase/rules/missing-app-check.ts
|
|
2353
|
+
var rule30 = {
|
|
2354
|
+
meta: {
|
|
2355
|
+
type: "suggestion",
|
|
2356
|
+
docs: {
|
|
2357
|
+
description: "Firebase app initialization should configure App Check",
|
|
2358
|
+
category: "security",
|
|
2359
|
+
cwe: "CWE-285",
|
|
2360
|
+
owasp: "A04:2021 Insecure Design",
|
|
2361
|
+
rationale: "Firebase web API keys are public by design and only identify the project to Google's backend \u2014 they are not a secret. The only things standing between an attacker who clones the client config and your database/auth quota are Security Rules and App Check. A file that calls initializeApp but never initializeAppCheck leaves App Check unconfigured, so any client presenting a valid project config (not necessarily yours) can reach Firebase Auth and, subject to rules, the database.",
|
|
2362
|
+
docsUrl: "https://firebase.google.com/docs/database/web/start",
|
|
2363
|
+
recommended: true
|
|
2364
|
+
},
|
|
2365
|
+
messages: {
|
|
2366
|
+
missingAppCheck: "This file initializes the Firebase app but never calls initializeAppCheck. Without App Check, only Security Rules gate access to a publicly-knowable project config."
|
|
2367
|
+
},
|
|
2368
|
+
schema: []
|
|
2369
|
+
},
|
|
2370
|
+
create(context) {
|
|
2371
|
+
if (isInsideTestFile2(String(context.filename ?? ""))) {
|
|
2372
|
+
return {};
|
|
2373
|
+
}
|
|
2374
|
+
let initializeAppLocalName;
|
|
2375
|
+
let initializeAppCheckLocalName;
|
|
2376
|
+
let appCheckNamespace;
|
|
2377
|
+
let initializeAppCallNode;
|
|
2378
|
+
let sawAppCheckCall = false;
|
|
2379
|
+
return {
|
|
2380
|
+
ImportDeclaration(node) {
|
|
2381
|
+
const appImports = namedImportsFrom(node, "firebase/app");
|
|
2382
|
+
if (appImports.has("initializeApp")) initializeAppLocalName = appImports.get("initializeApp");
|
|
2383
|
+
const appCheckImports = namedImportsFrom(node, "firebase/app-check");
|
|
2384
|
+
if (appCheckImports.has("initializeAppCheck")) {
|
|
2385
|
+
initializeAppCheckLocalName = appCheckImports.get("initializeAppCheck");
|
|
2386
|
+
}
|
|
2387
|
+
const ns = namespaceImportFrom(node, "firebase/app-check");
|
|
2388
|
+
if (ns) appCheckNamespace = ns;
|
|
2389
|
+
},
|
|
2390
|
+
CallExpression(node) {
|
|
2391
|
+
if (!initializeAppCallNode && isIdentifierCall(node, initializeAppLocalName)) {
|
|
2392
|
+
initializeAppCallNode = node;
|
|
2393
|
+
}
|
|
2394
|
+
if (isIdentifierCall(node, initializeAppCheckLocalName)) sawAppCheckCall = true;
|
|
2395
|
+
if (isNamespaceMemberCall(node, appCheckNamespace, "initializeAppCheck")) sawAppCheckCall = true;
|
|
2396
|
+
},
|
|
2397
|
+
"Program:exit"() {
|
|
2398
|
+
if (initializeAppCallNode && !sawAppCheckCall) {
|
|
2399
|
+
context.report({ node: initializeAppCallNode, messageId: "missingAppCheck" });
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
var firebaseMissingAppCheckRule = rule30;
|
|
2406
|
+
|
|
2407
|
+
// src/providers/firebase/rules/unhandled-auth-popup-rejection.ts
|
|
2408
|
+
var rule31 = {
|
|
2409
|
+
meta: {
|
|
2410
|
+
type: "problem",
|
|
2411
|
+
docs: {
|
|
2412
|
+
description: "signInWithPopup calls must handle rejection",
|
|
2413
|
+
category: "correctness",
|
|
2414
|
+
rationale: "signInWithPopup rejects with auth/popup-closed-by-user, auth/popup-blocked, and auth/cancelled-popup-request \u2014 all common, expected outcomes, not edge cases. Without a try/catch (or a chained .catch), the rejection is unhandled: loading state set before the call never clears, and the user has no way to retry without reloading the page.",
|
|
2415
|
+
docsUrl: "https://firebase.google.com/docs/auth/web/google-signin",
|
|
2416
|
+
recommended: true
|
|
2417
|
+
},
|
|
2418
|
+
messages: {
|
|
2419
|
+
unhandledPopupRejection: "signInWithPopup is called with no try/catch and no chained .catch. Popup-closed and popup-blocked are expected rejections, not edge cases."
|
|
2420
|
+
},
|
|
2421
|
+
schema: []
|
|
2422
|
+
},
|
|
2423
|
+
create(context) {
|
|
2424
|
+
let signInLocalName;
|
|
2425
|
+
const calls = [];
|
|
2426
|
+
const tryBlocks = [];
|
|
2427
|
+
const caughtCalls = /* @__PURE__ */ new Set();
|
|
2428
|
+
return {
|
|
2429
|
+
ImportDeclaration(node) {
|
|
2430
|
+
const imports = namedImportsFrom(node, "firebase/auth");
|
|
2431
|
+
if (imports.has("signInWithPopup")) signInLocalName = imports.get("signInWithPopup");
|
|
2432
|
+
},
|
|
2433
|
+
TryStatement(node) {
|
|
2434
|
+
if (node.block) tryBlocks.push(node.block);
|
|
2435
|
+
},
|
|
2436
|
+
CallExpression(node) {
|
|
2437
|
+
if (isIdentifierCall(node, signInLocalName)) {
|
|
2438
|
+
calls.push(node);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const prop = memberPropName2(node);
|
|
2442
|
+
const isCatch = prop === "catch";
|
|
2443
|
+
const isTwoArgThen = prop === "then" && (node.arguments ?? []).length >= 2;
|
|
2444
|
+
if (!isCatch && !isTwoArgThen) return;
|
|
2445
|
+
for (const link of chainLinks(node)) {
|
|
2446
|
+
if (isIdentifierCall(link, signInLocalName)) caughtCalls.add(link);
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
"Program:exit"() {
|
|
2450
|
+
for (const call of calls) {
|
|
2451
|
+
if (caughtCalls.has(call)) continue;
|
|
2452
|
+
if (tryBlocks.some((block) => contains2(block, call))) continue;
|
|
2453
|
+
context.report({ node: call, messageId: "unhandledPopupRejection" });
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
var firebaseUnhandledAuthPopupRejectionRule = rule31;
|
|
2460
|
+
|
|
2461
|
+
// src/providers/firebase/rules/rtdb-list-read-for-single-item.ts
|
|
2462
|
+
function isSetterName(name) {
|
|
2463
|
+
return /^set[A-Z]/.test(name);
|
|
2464
|
+
}
|
|
2465
|
+
function stateVarFromSetter(name) {
|
|
2466
|
+
return name.slice(3, 4).toLowerCase() + name.slice(4);
|
|
2467
|
+
}
|
|
2468
|
+
var rule32 = {
|
|
2469
|
+
meta: {
|
|
2470
|
+
type: "suggestion",
|
|
2471
|
+
docs: {
|
|
2472
|
+
description: "Pages keyed by a single id should not subscribe to the whole collection",
|
|
2473
|
+
category: "correctness",
|
|
2474
|
+
rationale: 'Subscribing to an entire collection and then Array.prototype.find()-ing by a route id param means the page renders a false "not found" state until the full list streams in, and downloads every row the user has just to read one. Use a single-node read/subscribe (ref(db, `path/${id}`)) instead.',
|
|
2475
|
+
docsUrl: "https://firebase.google.com/docs/database/web/read-and-write",
|
|
2476
|
+
recommended: true
|
|
2477
|
+
},
|
|
2478
|
+
messages: {
|
|
2479
|
+
listReadForSingleItem: "This subscribes to a whole collection and finds one item by a route id param. Read/subscribe to that single node directly instead."
|
|
2480
|
+
},
|
|
2481
|
+
schema: []
|
|
2482
|
+
},
|
|
2483
|
+
create(context) {
|
|
2484
|
+
let idParamName;
|
|
2485
|
+
const listStateVars = /* @__PURE__ */ new Set();
|
|
2486
|
+
const findCalls = [];
|
|
2487
|
+
return {
|
|
2488
|
+
VariableDeclarator(node) {
|
|
2489
|
+
if (node.init?.type === "CallExpression" && node.init.callee?.type === "Identifier" && node.init.callee.name === "useParams" && node.id?.type === "ObjectPattern") {
|
|
2490
|
+
for (const p of node.id.properties ?? []) {
|
|
2491
|
+
if (p?.type === "Property" && p.value?.type === "Identifier" && /id$/i.test(p.value.name)) {
|
|
2492
|
+
idParamName = p.value.name;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
},
|
|
2497
|
+
CallExpression(node) {
|
|
2498
|
+
for (const arg of node.arguments ?? []) {
|
|
2499
|
+
if (arg?.type === "Identifier" && isSetterName(arg.name)) {
|
|
2500
|
+
listStateVars.add(stateVarFromSetter(arg.name));
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
if (node.callee?.type !== "MemberExpression" || node.callee.computed) return;
|
|
2504
|
+
if (node.callee.property?.type !== "Identifier" || node.callee.property.name !== "find") return;
|
|
2505
|
+
const obj = node.callee.object;
|
|
2506
|
+
if (obj?.type !== "Identifier") return;
|
|
2507
|
+
findCalls.push({ node, objectName: obj.name, callback: node.arguments?.[0] });
|
|
2508
|
+
},
|
|
2509
|
+
"Program:exit"() {
|
|
2510
|
+
if (!idParamName) return;
|
|
2511
|
+
for (const { node, objectName, callback } of findCalls) {
|
|
2512
|
+
if (!listStateVars.has(objectName)) continue;
|
|
2513
|
+
if (!callback) continue;
|
|
2514
|
+
if (someDescendant(callback, (n) => comparesIdentifier(n, idParamName))) {
|
|
2515
|
+
context.report({ node, messageId: "listReadForSingleItem" });
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
var firebaseRtdbListReadForSingleItemRule = rule32;
|
|
2523
|
+
|
|
2524
|
+
// src/providers/firebase/rules/unvalidated-external-data-to-rtdb.ts
|
|
2525
|
+
function isJsonParseCall(node) {
|
|
2526
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.object?.type === "Identifier" && node.callee.object.name === "JSON" && node.callee.property?.type === "Identifier" && node.callee.property.name === "parse";
|
|
2527
|
+
}
|
|
2528
|
+
function isResponseJsonCall(node) {
|
|
2529
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.property?.type === "Identifier" && node.callee.property.name === "json" && (node.arguments ?? []).length === 0;
|
|
2530
|
+
}
|
|
2531
|
+
function isValidationLikeCall(node) {
|
|
2532
|
+
if (node?.type !== "CallExpression") return false;
|
|
2533
|
+
const callee = node.callee;
|
|
2534
|
+
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
2535
|
+
if (!name) return false;
|
|
2536
|
+
return /valid|^test$|check|assert|schema/i.test(name);
|
|
2537
|
+
}
|
|
2538
|
+
var rule33 = {
|
|
2539
|
+
meta: {
|
|
2540
|
+
type: "problem",
|
|
2541
|
+
docs: {
|
|
2542
|
+
description: "Parsed external data must be validated before an RTDB write",
|
|
2543
|
+
category: "correctness",
|
|
2544
|
+
rationale: "Realtime Database has no client-side schema enforcement \u2014 data is written exactly as given. When a function parses an external response (e.g. an LLM completion) and forwards the result straight into set()/update()/push() with no validation call in between, a malformed or missing field silently corrupts the stored data with no error surfaced anywhere.",
|
|
2545
|
+
docsUrl: "https://firebase.google.com/docs/database/web/read-and-write",
|
|
2546
|
+
recommended: true
|
|
2547
|
+
},
|
|
2548
|
+
messages: {
|
|
2549
|
+
unvalidatedWrite: "Parsed external data flows into a Realtime Database write with no validation call in between. Validate shape/values before writing."
|
|
2550
|
+
},
|
|
2551
|
+
schema: []
|
|
2552
|
+
},
|
|
2553
|
+
create(context) {
|
|
2554
|
+
const writeLocalNames = /* @__PURE__ */ new Set();
|
|
2555
|
+
const scopes = [];
|
|
2556
|
+
function registerScope(node) {
|
|
2557
|
+
scopes.push({ node, start: startOffset2(node), end: endOffset2(node) });
|
|
2558
|
+
}
|
|
2559
|
+
function innermostScope(pos) {
|
|
2560
|
+
let best;
|
|
2561
|
+
for (const scope of scopes) {
|
|
2562
|
+
if (pos < scope.start || pos > scope.end) continue;
|
|
2563
|
+
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
2564
|
+
}
|
|
2565
|
+
return best;
|
|
2566
|
+
}
|
|
2567
|
+
function enclosingScopes(pos) {
|
|
2568
|
+
return scopes.filter((scope) => pos >= scope.start && pos <= scope.end);
|
|
2569
|
+
}
|
|
2570
|
+
return {
|
|
2571
|
+
ImportDeclaration(node) {
|
|
2572
|
+
const imports = namedImportsFrom(node, "firebase/database");
|
|
2573
|
+
for (const name of ["set", "update", "push"]) {
|
|
2574
|
+
const local = imports.get(name);
|
|
2575
|
+
if (local) writeLocalNames.add(local);
|
|
2576
|
+
}
|
|
2577
|
+
},
|
|
2578
|
+
FunctionDeclaration(node) {
|
|
2579
|
+
registerScope(node);
|
|
2580
|
+
},
|
|
2581
|
+
FunctionExpression(node) {
|
|
2582
|
+
registerScope(node);
|
|
2583
|
+
},
|
|
2584
|
+
ArrowFunctionExpression(node) {
|
|
2585
|
+
registerScope(node);
|
|
2586
|
+
},
|
|
2587
|
+
CallExpression(node) {
|
|
2588
|
+
const pos = startOffset2(node);
|
|
2589
|
+
if (isValidationLikeCall(node)) {
|
|
2590
|
+
for (const scope2 of enclosingScopes(pos)) {
|
|
2591
|
+
if (scope2.validatePos === void 0 || pos < scope2.validatePos) scope2.validatePos = pos;
|
|
2592
|
+
}
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
const scope = innermostScope(pos);
|
|
2596
|
+
if (!scope) return;
|
|
2597
|
+
if (isJsonParseCall(node) || isResponseJsonCall(node)) {
|
|
2598
|
+
if (scope.parsePos === void 0 || pos < scope.parsePos) scope.parsePos = pos;
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
if (writeLocalNames.size > 0 && [...writeLocalNames].some((n) => isIdentifierCall(node, n))) {
|
|
2602
|
+
if (scope.writePos === void 0 || pos < scope.writePos) {
|
|
2603
|
+
scope.writePos = pos;
|
|
2604
|
+
scope.writeNode = node;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
},
|
|
2608
|
+
"Program:exit"() {
|
|
2609
|
+
for (const scope of scopes) {
|
|
2610
|
+
if (scope.parsePos === void 0 || scope.writePos === void 0) continue;
|
|
2611
|
+
if (scope.writePos < scope.parsePos) continue;
|
|
2612
|
+
const validatedBetween = scope.validatePos !== void 0 && scope.validatePos > scope.parsePos && scope.validatePos < scope.writePos;
|
|
2613
|
+
if (!validatedBetween) {
|
|
2614
|
+
context.report({ node: scope.writeNode, messageId: "unvalidatedWrite" });
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
2621
|
+
var firebaseUnvalidatedExternalDataToRtdbRule = rule33;
|
|
2622
|
+
|
|
2623
|
+
// src/providers/firebase/rules/rtdb-batch-write-not-atomic.ts
|
|
2624
|
+
function isPromiseAllCall(node) {
|
|
2625
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.object?.type === "Identifier" && node.callee.object.name === "Promise" && node.callee.property?.type === "Identifier" && node.callee.property.name === "all";
|
|
2626
|
+
}
|
|
2627
|
+
function isMapCall(node) {
|
|
2628
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.property?.type === "Identifier" && node.callee.property.name === "map";
|
|
2629
|
+
}
|
|
2630
|
+
var rule34 = {
|
|
2631
|
+
meta: {
|
|
2632
|
+
type: "suggestion",
|
|
2633
|
+
docs: {
|
|
2634
|
+
description: "Promise.all over per-item push+set is not atomic",
|
|
2635
|
+
category: "correctness",
|
|
2636
|
+
rationale: "push() to generate a client-side unique key is correct, but firing one set() per item under Promise.all means N independent network writes. A partial failure mid-batch (permission revoked, connection drop) leaves some items committed and others not, with no atomicity. Building one update() call against the parent ref with all generated keys as fields commits the whole batch atomically instead.",
|
|
2637
|
+
docsUrl: "https://firebase.google.com/docs/database/web/read-and-write",
|
|
2638
|
+
recommended: true
|
|
2639
|
+
},
|
|
2640
|
+
messages: {
|
|
2641
|
+
batchWriteNotAtomic: "This batches per-item push()+set() calls under Promise.all, which is not atomic. Build one update() call against the parent ref instead."
|
|
2642
|
+
},
|
|
2643
|
+
schema: []
|
|
2644
|
+
},
|
|
2645
|
+
create(context) {
|
|
2646
|
+
let pushLocalName;
|
|
2647
|
+
let setLocalName;
|
|
2648
|
+
const mapCallsByVarName = /* @__PURE__ */ new Map();
|
|
2649
|
+
function isAtomicBatchMapCall(mapCallNode) {
|
|
2650
|
+
if (!isMapCall(mapCallNode)) return false;
|
|
2651
|
+
const callback = mapCallNode.arguments?.[mapCallNode.arguments.length - 1];
|
|
2652
|
+
if (!callback) return false;
|
|
2653
|
+
const hasPush = someDescendant(callback, (n) => isIdentifierCall(n, pushLocalName));
|
|
2654
|
+
const hasSet = someDescendant(callback, (n) => isIdentifierCall(n, setLocalName));
|
|
2655
|
+
return hasPush && hasSet;
|
|
2656
|
+
}
|
|
2657
|
+
return {
|
|
2658
|
+
ImportDeclaration(node) {
|
|
2659
|
+
const imports = namedImportsFrom(node, "firebase/database");
|
|
2660
|
+
if (imports.has("push")) pushLocalName = imports.get("push");
|
|
2661
|
+
if (imports.has("set")) setLocalName = imports.get("set");
|
|
2662
|
+
},
|
|
2663
|
+
VariableDeclarator(node) {
|
|
2664
|
+
if (node.id?.type === "Identifier" && isMapCall(node.init)) {
|
|
2665
|
+
mapCallsByVarName.set(node.id.name, node.init);
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
CallExpression(node) {
|
|
2669
|
+
if (!isPromiseAllCall(node)) return;
|
|
2670
|
+
const arg = node.arguments?.[0];
|
|
2671
|
+
if (isAtomicBatchMapCall(arg)) {
|
|
2672
|
+
context.report({ node, messageId: "batchWriteNotAtomic" });
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
if (arg?.type === "Identifier") {
|
|
2676
|
+
const mapCall = mapCallsByVarName.get(arg.name);
|
|
2677
|
+
if (mapCall && isAtomicBatchMapCall(mapCall)) {
|
|
2678
|
+
context.report({ node, messageId: "batchWriteNotAtomic" });
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
var firebaseRtdbBatchWriteNotAtomicRule = rule34;
|
|
2686
|
+
|
|
2687
|
+
// src/providers/firebase/rules/rtdb-listener-error-not-handled.ts
|
|
2688
|
+
var rule35 = {
|
|
2689
|
+
meta: {
|
|
2690
|
+
type: "suggestion",
|
|
2691
|
+
docs: {
|
|
2692
|
+
description: "onValue listeners should handle the error callback",
|
|
2693
|
+
category: "reliability",
|
|
2694
|
+
rationale: "onValue's callback signature includes a documented error callback specifically for permission-denied and connection-loss cases. A call with only the success callback (2 arguments) silently drops that path, so a PERMISSION_DENIED or offline event produces no UI feedback and no console output.",
|
|
2695
|
+
docsUrl: "https://firebase.google.com/docs/reference/js/database.md#onvalue",
|
|
2696
|
+
recommended: true
|
|
2697
|
+
},
|
|
2698
|
+
messages: {
|
|
2699
|
+
listenerErrorNotHandled: "onValue is called with no error/cancel callback. Pass a 3rd argument so PERMISSION_DENIED and dropped connections surface somewhere."
|
|
2700
|
+
},
|
|
2701
|
+
schema: []
|
|
2702
|
+
},
|
|
2703
|
+
create(context) {
|
|
2704
|
+
let onValueLocalName;
|
|
2705
|
+
return {
|
|
2706
|
+
ImportDeclaration(node) {
|
|
2707
|
+
const imports = namedImportsFrom(node, "firebase/database");
|
|
2708
|
+
if (imports.has("onValue")) onValueLocalName = imports.get("onValue");
|
|
2709
|
+
},
|
|
2710
|
+
CallExpression(node) {
|
|
2711
|
+
if (!isIdentifierCall(node, onValueLocalName)) return;
|
|
2712
|
+
if ((node.arguments ?? []).length < 3) {
|
|
2713
|
+
context.report({ node, messageId: "listenerErrorNotHandled" });
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
2719
|
+
var firebaseRtdbListenerErrorNotHandledRule = rule35;
|
|
2720
|
+
|
|
2721
|
+
// src/providers/firebase/rules/effect-deps-whole-user-object.ts
|
|
2722
|
+
function isUseEffectCall(node) {
|
|
2723
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "useEffect";
|
|
2724
|
+
}
|
|
2725
|
+
var rule36 = {
|
|
2726
|
+
meta: {
|
|
2727
|
+
type: "suggestion",
|
|
2728
|
+
docs: {
|
|
2729
|
+
description: "useEffect should depend on user?.uid, not the whole user object",
|
|
2730
|
+
category: "reliability",
|
|
2731
|
+
rationale: "onAuthStateChanged delivers a new user object reference on every internal token refresh (roughly hourly) even when uid is unchanged. An effect that only reads user.uid but lists the whole user object in its dependency array tears down and re-establishes its RTDB listener on every refresh \u2014 an avoidable unsubscribe/resubscribe cycle that briefly clears local state.",
|
|
2732
|
+
docsUrl: "https://firebase.google.com/docs/reference/js/auth.md#onauthstatechanged",
|
|
2733
|
+
recommended: true
|
|
2734
|
+
},
|
|
2735
|
+
messages: {
|
|
2736
|
+
wholeUserObjectDep: "This effect only reads {{name}}.uid but depends on the whole {{name}} object, which gets a new reference on every token refresh. Depend on {{name}}?.uid instead."
|
|
2737
|
+
},
|
|
2738
|
+
schema: []
|
|
2739
|
+
},
|
|
2740
|
+
create(context) {
|
|
2741
|
+
return {
|
|
2742
|
+
CallExpression(node) {
|
|
2743
|
+
if (!isUseEffectCall(node)) return;
|
|
2744
|
+
const [callback, depsArg] = node.arguments ?? [];
|
|
2745
|
+
if (!callback || depsArg?.type !== "ArrayExpression") return;
|
|
2746
|
+
for (const el of depsArg.elements ?? []) {
|
|
2747
|
+
if (el?.type !== "Identifier") continue;
|
|
2748
|
+
const name = el.name;
|
|
2749
|
+
if (someDescendant(callback, (n) => isUidMemberAccess(n, name))) {
|
|
2750
|
+
context.report({ node, messageId: "wholeUserObjectDep", data: { name } });
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
};
|
|
2757
|
+
var firebaseEffectDepsWholeUserObjectRule = rule36;
|
|
2758
|
+
|
|
2759
|
+
// src/providers/firebase/rules/rtdb-write-promise-not-handled.ts
|
|
2760
|
+
var rule37 = {
|
|
2761
|
+
meta: {
|
|
2762
|
+
type: "problem",
|
|
2763
|
+
docs: {
|
|
2764
|
+
description: "Realtime Database write promises must be handled",
|
|
2765
|
+
category: "reliability",
|
|
2766
|
+
rationale: "set()/update()/remove() return promises specifically so a rejected write (offline, permission-denied, rules-rejected) can be handled. A bare fire-and-forget call, or an awaited call with no surrounding try/catch, means the caller proceeds \u2014 and may navigate away \u2014 as if the write succeeded, while the rejection is silently dropped.",
|
|
2767
|
+
docsUrl: "https://firebase.google.com/docs/database/web/read-and-write",
|
|
2768
|
+
recommended: true
|
|
2769
|
+
},
|
|
2770
|
+
messages: {
|
|
2771
|
+
writePromiseNotHandled: "This Realtime Database write is neither awaited inside a try/catch nor given a .catch handler. A rejected write will be silently dropped."
|
|
2772
|
+
},
|
|
2773
|
+
schema: []
|
|
2774
|
+
},
|
|
2775
|
+
create(context) {
|
|
2776
|
+
const writeLocalNames = /* @__PURE__ */ new Set();
|
|
2777
|
+
const writeCalls = /* @__PURE__ */ new Set();
|
|
2778
|
+
const safeReturns = /* @__PURE__ */ new Set();
|
|
2779
|
+
const awaitedCalls = /* @__PURE__ */ new Set();
|
|
2780
|
+
const caughtCalls = /* @__PURE__ */ new Set();
|
|
2781
|
+
const tryBlocks = [];
|
|
2782
|
+
function isWriteCall(node) {
|
|
2783
|
+
return [...writeLocalNames].some((n) => isIdentifierCall(node, n));
|
|
2784
|
+
}
|
|
2785
|
+
return {
|
|
2786
|
+
ImportDeclaration(node) {
|
|
2787
|
+
const imports = namedImportsFrom(node, "firebase/database");
|
|
2788
|
+
for (const name of ["set", "update", "remove"]) {
|
|
2789
|
+
const local = imports.get(name);
|
|
2790
|
+
if (local) writeLocalNames.add(local);
|
|
2791
|
+
}
|
|
2792
|
+
},
|
|
2793
|
+
TryStatement(node) {
|
|
2794
|
+
if (node.block) tryBlocks.push(node.block);
|
|
2795
|
+
},
|
|
2796
|
+
ReturnStatement(node) {
|
|
2797
|
+
if (isWriteCall(node.argument)) safeReturns.add(node.argument);
|
|
2798
|
+
},
|
|
2799
|
+
AwaitExpression(node) {
|
|
2800
|
+
if (isWriteCall(node.argument)) awaitedCalls.add(node.argument);
|
|
2801
|
+
},
|
|
2802
|
+
CallExpression(node) {
|
|
2803
|
+
if (isWriteCall(node)) writeCalls.add(node);
|
|
2804
|
+
const prop = memberPropName2(node);
|
|
2805
|
+
const isCatch = prop === "catch";
|
|
2806
|
+
const isTwoArgThen = prop === "then" && (node.arguments ?? []).length >= 2;
|
|
2807
|
+
if (isCatch || isTwoArgThen) {
|
|
2808
|
+
for (const link of chainLinks(node)) {
|
|
2809
|
+
if (isWriteCall(link)) caughtCalls.add(link);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
},
|
|
2813
|
+
"Program:exit"() {
|
|
2814
|
+
for (const call of writeCalls) {
|
|
2815
|
+
if (safeReturns.has(call)) continue;
|
|
2816
|
+
if (caughtCalls.has(call)) continue;
|
|
2817
|
+
if (awaitedCalls.has(call)) {
|
|
2818
|
+
if (tryBlocks.some((block) => contains2(block, call))) continue;
|
|
2819
|
+
}
|
|
2820
|
+
context.report({ node: call, messageId: "writePromiseNotHandled" });
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
var firebaseRtdbWritePromiseNotHandledRule = rule37;
|
|
2827
|
+
|
|
2828
|
+
// src/providers/lovable/utils.ts
|
|
2829
|
+
var LLM_HOST_PATTERN = /api\.anthropic\.com|api\.openai\.com/;
|
|
2830
|
+
function containsKnownLlmHost(node) {
|
|
2831
|
+
if (node?.type === "Literal" && typeof node.value === "string") {
|
|
2832
|
+
return LLM_HOST_PATTERN.test(node.value);
|
|
2833
|
+
}
|
|
2834
|
+
if (node?.type === "TemplateLiteral") {
|
|
2835
|
+
return (node.quasis ?? []).some((q) => LLM_HOST_PATTERN.test(q.value?.raw ?? ""));
|
|
2836
|
+
}
|
|
2837
|
+
return false;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// src/providers/lovable/rules/no-client-side-secret-fetch.ts
|
|
2841
|
+
var rule38 = {
|
|
2842
|
+
meta: {
|
|
2843
|
+
type: "problem",
|
|
2844
|
+
docs: {
|
|
2845
|
+
description: "LLM provider calls must not run client-side with a VITE_-exposed API key",
|
|
2846
|
+
category: "security",
|
|
2847
|
+
cwe: "CWE-522",
|
|
2848
|
+
owasp: "A02:2021 \u2013 Cryptographic Failures",
|
|
2849
|
+
rationale: "Anything read from import.meta.env.VITE_* is inlined into the production JS bundle at build time and is visible to every site visitor via the Network tab or by reading the bundle \u2014 no git access required. Lovable documents Edge Functions specifically so the provider key stays server-side in Secrets and the browser calls your own Edge Function instead of the provider directly.",
|
|
2850
|
+
docsUrl: "https://docs.lovable.dev/features/security",
|
|
2851
|
+
recommended: true
|
|
2852
|
+
},
|
|
2853
|
+
messages: {
|
|
2854
|
+
clientSideSecretFetch: "This fetch() calls an LLM provider directly with a key sourced from import.meta.env.VITE_* \u2014 that key ships into the browser bundle and is visible to every visitor."
|
|
2855
|
+
}
|
|
2856
|
+
},
|
|
2857
|
+
create(context) {
|
|
2858
|
+
const viteVars = /* @__PURE__ */ new Set();
|
|
2859
|
+
function propName2(node) {
|
|
2860
|
+
if (!node) return void 0;
|
|
2861
|
+
if (node.type === "Identifier") return node.name;
|
|
2862
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2863
|
+
return void 0;
|
|
2864
|
+
}
|
|
2865
|
+
function isImportMetaEnvViteAccess(node) {
|
|
2866
|
+
if (node?.type !== "MemberExpression" || node.computed) return false;
|
|
2867
|
+
const propertyName = node.property?.name;
|
|
2868
|
+
if (typeof propertyName !== "string" || !propertyName.startsWith("VITE_")) return false;
|
|
2869
|
+
const envMember = node.object;
|
|
2870
|
+
if (envMember?.type !== "MemberExpression" || envMember.computed) return false;
|
|
2871
|
+
if (envMember.property?.name !== "env") return false;
|
|
2872
|
+
const metaProp = envMember.object;
|
|
2873
|
+
return metaProp?.type === "MetaProperty" && metaProp.meta?.name === "import" && metaProp.property?.name === "meta";
|
|
2874
|
+
}
|
|
2875
|
+
function referencesViteSecret(node) {
|
|
2876
|
+
if (!node) return false;
|
|
2877
|
+
if (isImportMetaEnvViteAccess(node)) return true;
|
|
2878
|
+
if (node.type === "Identifier" && viteVars.has(node.name)) return true;
|
|
2879
|
+
if (node.type === "TemplateLiteral") {
|
|
2880
|
+
return (node.expressions ?? []).some((e) => referencesViteSecret(e));
|
|
2881
|
+
}
|
|
2882
|
+
return false;
|
|
2883
|
+
}
|
|
2884
|
+
return {
|
|
2885
|
+
VariableDeclarator(node) {
|
|
2886
|
+
if (node.id?.type === "Identifier" && isImportMetaEnvViteAccess(node.init)) {
|
|
2887
|
+
viteVars.add(node.id.name);
|
|
2888
|
+
}
|
|
2889
|
+
},
|
|
2890
|
+
CallExpression(node) {
|
|
2891
|
+
const callee = node.callee;
|
|
2892
|
+
if (callee?.type !== "Identifier" || callee.name !== "fetch") return;
|
|
2893
|
+
const urlArg = node.arguments?.[0];
|
|
2894
|
+
if (!containsKnownLlmHost(urlArg)) return;
|
|
2895
|
+
const optsArg = node.arguments?.[1];
|
|
2896
|
+
if (optsArg?.type !== "ObjectExpression") return;
|
|
2897
|
+
const headersProp = (optsArg.properties ?? []).find(
|
|
2898
|
+
(p) => p?.type === "Property" && propName2(p.key) === "headers"
|
|
2899
|
+
);
|
|
2900
|
+
if (!headersProp || headersProp.value?.type !== "ObjectExpression") return;
|
|
2901
|
+
for (const hp of headersProp.value.properties ?? []) {
|
|
2902
|
+
if (hp?.type !== "Property") continue;
|
|
2903
|
+
const keyName = propName2(hp.key)?.toLowerCase();
|
|
2904
|
+
if (keyName !== "x-api-key" && keyName !== "authorization") continue;
|
|
2905
|
+
if (referencesViteSecret(hp.value)) {
|
|
2906
|
+
context.report({ node, messageId: "clientSideSecretFetch" });
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
var lovableNoClientSideSecretFetchRule = rule38;
|
|
2915
|
+
|
|
2916
|
+
// src/providers/lovable/rules/paid-flag-without-edge-function.ts
|
|
2917
|
+
var PRICE_PATTERN = /\$\s?\d/;
|
|
2918
|
+
var FLAG_PROPERTY_PATTERN = /^is_[a-z0-9_]+$/i;
|
|
2919
|
+
var FLAG_SUFFIX_PATTERN = /_(unlocked|active|enabled)$/i;
|
|
2920
|
+
var rule39 = {
|
|
2921
|
+
meta: {
|
|
2922
|
+
type: "problem",
|
|
2923
|
+
docs: {
|
|
2924
|
+
description: "A paid feature flag must not be set by a direct database update with no payment-provider call",
|
|
2925
|
+
category: "security",
|
|
2926
|
+
cwe: "CWE-840",
|
|
2927
|
+
owasp: "A04:2021 \u2013 Insecure Design",
|
|
2928
|
+
rationale: "Lovable documents payment processing as Edge Function territory specifically so purchase/premium-access flags are only ever set after a payment provider confirms payment server-side. A handler that writes a paid-looking flag straight from the client with no payment call in between either never actually charges anyone, or \u2014 even if a charge happens elsewhere \u2014 leaves the flag itself freely settable by any caller with write access to the row.",
|
|
2929
|
+
docsUrl: "https://docs.lovable.dev/features/cloud",
|
|
2930
|
+
recommended: true
|
|
2931
|
+
},
|
|
2932
|
+
messages: {
|
|
2933
|
+
paidFlagWithoutPayment: "This sets a paid-looking flag via a direct database update, but nothing in this function calls a payment provider or Edge Function first \u2014 the flag can be set without anyone paying."
|
|
2934
|
+
}
|
|
2935
|
+
},
|
|
2936
|
+
create(context) {
|
|
2937
|
+
const stack = [];
|
|
2938
|
+
const states = /* @__PURE__ */ new Map();
|
|
2939
|
+
let sawPriceLabel = false;
|
|
2940
|
+
function ensureState(fn) {
|
|
2941
|
+
let s = states.get(fn);
|
|
2942
|
+
if (!s) {
|
|
2943
|
+
s = { sawFlagUpdate: false, updateNode: null, sawPaymentCall: false };
|
|
2944
|
+
states.set(fn, s);
|
|
2945
|
+
}
|
|
2946
|
+
return s;
|
|
2947
|
+
}
|
|
2948
|
+
function top() {
|
|
2949
|
+
return stack[stack.length - 1];
|
|
2950
|
+
}
|
|
2951
|
+
function pushScope(node) {
|
|
2952
|
+
stack.push(node);
|
|
2953
|
+
ensureState(node);
|
|
2954
|
+
}
|
|
2955
|
+
function popScope() {
|
|
2956
|
+
stack.pop();
|
|
2957
|
+
}
|
|
2958
|
+
function propName2(node) {
|
|
2959
|
+
if (!node) return void 0;
|
|
2960
|
+
if (node.type === "Identifier") return node.name;
|
|
2961
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2962
|
+
return void 0;
|
|
2963
|
+
}
|
|
2964
|
+
function isBooleanFlagProperty(name) {
|
|
2965
|
+
if (!name) return false;
|
|
2966
|
+
return FLAG_PROPERTY_PATTERN.test(name) || FLAG_SUFFIX_PATTERN.test(name);
|
|
2967
|
+
}
|
|
2968
|
+
function isSupabaseUpdateCall(node) {
|
|
2969
|
+
if (node?.type !== "CallExpression") return false;
|
|
2970
|
+
const callee = node.callee;
|
|
2971
|
+
return callee?.type === "MemberExpression" && callee.property?.name === "update";
|
|
2972
|
+
}
|
|
2973
|
+
function updateSetsBooleanFlag(node) {
|
|
2974
|
+
const arg = node.arguments?.[0];
|
|
2975
|
+
if (arg?.type !== "ObjectExpression") return false;
|
|
2976
|
+
return (arg.properties ?? []).some(
|
|
2977
|
+
(p) => p?.type === "Property" && isBooleanFlagProperty(propName2(p.key))
|
|
2978
|
+
);
|
|
2979
|
+
}
|
|
2980
|
+
function memberChainNames2(node, names = []) {
|
|
2981
|
+
if (node?.type === "MemberExpression") {
|
|
2982
|
+
memberChainNames2(node.object, names);
|
|
2983
|
+
const n = propName2(node.property);
|
|
2984
|
+
if (n) names.push(n);
|
|
2985
|
+
} else if (node?.type === "Identifier") {
|
|
2986
|
+
names.push(node.name);
|
|
2987
|
+
} else if (node?.type === "CallExpression") {
|
|
2988
|
+
memberChainNames2(node.callee, names);
|
|
2989
|
+
}
|
|
2990
|
+
return names;
|
|
2991
|
+
}
|
|
2992
|
+
function isPaymentRelatedCall(node) {
|
|
2993
|
+
if (node?.type !== "CallExpression") return false;
|
|
2994
|
+
const chain = memberChainNames2(node.callee).join(".").toLowerCase();
|
|
2995
|
+
if (/stripe|checkout|payment/.test(chain)) return true;
|
|
2996
|
+
if (chain.includes("functions") && chain.endsWith(".invoke")) return true;
|
|
2997
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
2998
|
+
const urlArg = node.arguments?.[0];
|
|
2999
|
+
if (urlArg?.type === "Literal" && typeof urlArg.value === "string") {
|
|
3000
|
+
return urlArg.value.includes("/functions/");
|
|
3001
|
+
}
|
|
3002
|
+
if (urlArg?.type === "TemplateLiteral") {
|
|
3003
|
+
return (urlArg.quasis ?? []).some((q) => (q.value?.raw ?? "").includes("/functions/"));
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return false;
|
|
3007
|
+
}
|
|
3008
|
+
return {
|
|
3009
|
+
Program(node) {
|
|
3010
|
+
pushScope(node);
|
|
3011
|
+
},
|
|
3012
|
+
"Program:exit"() {
|
|
3013
|
+
if (!sawPriceLabel) return;
|
|
3014
|
+
for (const state of states.values()) {
|
|
3015
|
+
if (state.sawFlagUpdate && !state.sawPaymentCall) {
|
|
3016
|
+
context.report({ node: state.updateNode, messageId: "paidFlagWithoutPayment" });
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
},
|
|
3020
|
+
FunctionDeclaration(node) {
|
|
3021
|
+
pushScope(node);
|
|
3022
|
+
},
|
|
3023
|
+
"FunctionDeclaration:exit"() {
|
|
3024
|
+
popScope();
|
|
3025
|
+
},
|
|
3026
|
+
FunctionExpression(node) {
|
|
3027
|
+
pushScope(node);
|
|
3028
|
+
},
|
|
3029
|
+
"FunctionExpression:exit"() {
|
|
3030
|
+
popScope();
|
|
3031
|
+
},
|
|
3032
|
+
ArrowFunctionExpression(node) {
|
|
3033
|
+
pushScope(node);
|
|
3034
|
+
},
|
|
3035
|
+
"ArrowFunctionExpression:exit"() {
|
|
3036
|
+
popScope();
|
|
3037
|
+
},
|
|
3038
|
+
Literal(node) {
|
|
3039
|
+
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3040
|
+
},
|
|
3041
|
+
JSXText(node) {
|
|
3042
|
+
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3043
|
+
},
|
|
3044
|
+
TemplateElement(node) {
|
|
3045
|
+
if (PRICE_PATTERN.test(node.value?.raw ?? "")) sawPriceLabel = true;
|
|
3046
|
+
},
|
|
3047
|
+
CallExpression(node) {
|
|
3048
|
+
const fn = top();
|
|
3049
|
+
if (!fn) return;
|
|
3050
|
+
const state = ensureState(fn);
|
|
3051
|
+
if (isSupabaseUpdateCall(node) && updateSetsBooleanFlag(node) && !state.sawFlagUpdate) {
|
|
3052
|
+
state.sawFlagUpdate = true;
|
|
3053
|
+
state.updateNode = node;
|
|
3054
|
+
}
|
|
3055
|
+
if (isPaymentRelatedCall(node)) {
|
|
3056
|
+
state.sawPaymentCall = true;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
var lovablePaidFlagWithoutEdgeFunctionRule = rule39;
|
|
3063
|
+
|
|
3064
|
+
// src/providers/lovable/rules/expiry-column-never-checked.ts
|
|
3065
|
+
var EXPIRY_COLUMN_PATTERN = /_(until|expires?_at|expiry)$/i;
|
|
3066
|
+
var DATE_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([">", "<", ">=", "<="]);
|
|
3067
|
+
var FILTER_METHODS = /* @__PURE__ */ new Set(["gt", "gte", "lt", "lte"]);
|
|
3068
|
+
var rule40 = {
|
|
3069
|
+
meta: {
|
|
3070
|
+
type: "suggestion",
|
|
3071
|
+
docs: {
|
|
3072
|
+
description: "An expiry column must be checked against the current time somewhere",
|
|
3073
|
+
category: "correctness",
|
|
3074
|
+
rationale: "Writing a *_until/*_expires_at column without ever comparing it to the current time anywhere in the codebase means the expiry is purely cosmetic \u2014 whatever it was meant to gate (a boost, a trial, a temporary unlock) never actually expires once granted, whether it was granted legitimately or through an unrelated bug.",
|
|
3075
|
+
docsUrl: "https://docs.lovable.dev/features/cloud",
|
|
3076
|
+
recommended: true
|
|
3077
|
+
},
|
|
3078
|
+
messages: {
|
|
3079
|
+
expiryNeverChecked: 'The "{{column}}" column is written here but is never compared against the current time anywhere in this file, so it never actually expires anything.'
|
|
3080
|
+
}
|
|
3081
|
+
},
|
|
3082
|
+
create(context) {
|
|
3083
|
+
const writtenColumns = /* @__PURE__ */ new Map();
|
|
3084
|
+
const readColumns = /* @__PURE__ */ new Set();
|
|
3085
|
+
function propName2(node) {
|
|
3086
|
+
if (!node) return void 0;
|
|
3087
|
+
if (node.type === "Identifier") return node.name;
|
|
3088
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
3089
|
+
return void 0;
|
|
3090
|
+
}
|
|
3091
|
+
function isSupabaseUpdateCall(node) {
|
|
3092
|
+
if (node?.type !== "CallExpression") return false;
|
|
3093
|
+
const callee = node.callee;
|
|
3094
|
+
return callee?.type === "MemberExpression" && callee.property?.name === "update";
|
|
3095
|
+
}
|
|
3096
|
+
function containsDateNowOrNewDate(node, depth = 0) {
|
|
3097
|
+
if (!node || typeof node !== "object" || depth > 6) return false;
|
|
3098
|
+
if (node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "Date") {
|
|
3099
|
+
return true;
|
|
3100
|
+
}
|
|
3101
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Date" && node.callee.property?.name === "now") {
|
|
3102
|
+
return true;
|
|
3103
|
+
}
|
|
3104
|
+
if (node.type === "MemberExpression") {
|
|
3105
|
+
return containsDateNowOrNewDate(node.object, depth + 1) || containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3106
|
+
}
|
|
3107
|
+
if (node.type === "CallExpression") {
|
|
3108
|
+
return containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3109
|
+
}
|
|
3110
|
+
return false;
|
|
3111
|
+
}
|
|
3112
|
+
function memberPropertyName(node) {
|
|
3113
|
+
if (node?.type !== "MemberExpression") return void 0;
|
|
3114
|
+
return propName2(node.property);
|
|
3115
|
+
}
|
|
3116
|
+
return {
|
|
3117
|
+
CallExpression(node) {
|
|
3118
|
+
if (isSupabaseUpdateCall(node)) {
|
|
3119
|
+
const arg = node.arguments?.[0];
|
|
3120
|
+
if (arg?.type === "ObjectExpression") {
|
|
3121
|
+
for (const prop of arg.properties ?? []) {
|
|
3122
|
+
if (prop?.type !== "Property") continue;
|
|
3123
|
+
const keyName = propName2(prop.key);
|
|
3124
|
+
if (keyName && EXPIRY_COLUMN_PATTERN.test(keyName) && !writtenColumns.has(keyName)) {
|
|
3125
|
+
writtenColumns.set(keyName, node);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
const callee = node.callee;
|
|
3131
|
+
if (callee?.type === "MemberExpression" && FILTER_METHODS.has(callee.property?.name)) {
|
|
3132
|
+
const colArg = node.arguments?.[0];
|
|
3133
|
+
const colName = colArg?.type === "Literal" ? propName2(colArg) : void 0;
|
|
3134
|
+
if (colName && EXPIRY_COLUMN_PATTERN.test(colName)) {
|
|
3135
|
+
readColumns.add(colName);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
},
|
|
3139
|
+
BinaryExpression(node) {
|
|
3140
|
+
if (!DATE_COMPARISON_OPERATORS.has(node.operator)) return;
|
|
3141
|
+
const leftCol = memberPropertyName(node.left);
|
|
3142
|
+
const rightCol = memberPropertyName(node.right);
|
|
3143
|
+
if (leftCol && EXPIRY_COLUMN_PATTERN.test(leftCol) && containsDateNowOrNewDate(node.right)) {
|
|
3144
|
+
readColumns.add(leftCol);
|
|
3145
|
+
}
|
|
3146
|
+
if (rightCol && EXPIRY_COLUMN_PATTERN.test(rightCol) && containsDateNowOrNewDate(node.left)) {
|
|
3147
|
+
readColumns.add(rightCol);
|
|
3148
|
+
}
|
|
3149
|
+
},
|
|
3150
|
+
"Program:exit"() {
|
|
3151
|
+
for (const [column, node] of writtenColumns) {
|
|
3152
|
+
if (!readColumns.has(column)) {
|
|
3153
|
+
context.report({ node, messageId: "expiryNeverChecked", data: { column } });
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
var lovableExpiryColumnNeverCheckedRule = rule40;
|
|
3161
|
+
|
|
3162
|
+
// src/providers/lovable/rules/silent-catch-on-provider-call.ts
|
|
3163
|
+
var LOGGING_CONSOLE_METHODS = /* @__PURE__ */ new Set(["error", "warn", "log", "info"]);
|
|
3164
|
+
var rule41 = {
|
|
3165
|
+
meta: {
|
|
3166
|
+
type: "suggestion",
|
|
3167
|
+
docs: {
|
|
3168
|
+
description: "A catch block around an LLM provider call must log the failure",
|
|
3169
|
+
category: "correctness",
|
|
3170
|
+
rationale: `Returning null/undefined from a bare catch with no logging makes every failure mode \u2014 missing key, expired key, rate limit, malformed response, network error \u2014 look identical to "no key configured." If a deployment's provider key starts failing in production, this is invisible: there is nothing in the browser or error-tracking logs to distinguish a real outage from an intentionally-unconfigured feature.`,
|
|
3171
|
+
docsUrl: "https://docs.lovable.dev/features/cloud",
|
|
3172
|
+
recommended: true
|
|
3173
|
+
},
|
|
3174
|
+
messages: {
|
|
3175
|
+
silentCatch: 'This catch block swallows a failed LLM provider call with no logging \u2014 a real failure (expired key, rate limit, network error) is indistinguishable from "no key configured."'
|
|
3176
|
+
}
|
|
3177
|
+
},
|
|
3178
|
+
create(context) {
|
|
3179
|
+
function findFetchToLlmHost(node, depth = 0) {
|
|
3180
|
+
if (!node || typeof node !== "object" || depth > 12) return null;
|
|
3181
|
+
if (Array.isArray(node)) {
|
|
3182
|
+
for (const n of node) {
|
|
3183
|
+
const found = findFetchToLlmHost(n, depth + 1);
|
|
3184
|
+
if (found) return found;
|
|
3185
|
+
}
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
3189
|
+
const urlArg = node.arguments?.[0];
|
|
3190
|
+
if (containsKnownLlmHost(urlArg)) return node;
|
|
3191
|
+
}
|
|
3192
|
+
for (const key of Object.keys(node)) {
|
|
3193
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3194
|
+
const val = node[key];
|
|
3195
|
+
if (val && typeof val === "object") {
|
|
3196
|
+
const found = findFetchToLlmHost(val, depth + 1);
|
|
3197
|
+
if (found) return found;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return null;
|
|
3201
|
+
}
|
|
3202
|
+
function containsLoggingCall(node, depth = 0) {
|
|
3203
|
+
if (!node || typeof node !== "object" || depth > 12) return false;
|
|
3204
|
+
if (Array.isArray(node)) return node.some((n) => containsLoggingCall(n, depth + 1));
|
|
3205
|
+
if (node.type === "CallExpression") {
|
|
3206
|
+
const callee = node.callee;
|
|
3207
|
+
if (callee?.type === "MemberExpression") {
|
|
3208
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
3209
|
+
const propertyName = callee.property?.name;
|
|
3210
|
+
if (objName === "console" && LOGGING_CONSOLE_METHODS.has(propertyName)) return true;
|
|
3211
|
+
if (propertyName === "captureException") return true;
|
|
3212
|
+
}
|
|
3213
|
+
if (callee?.type === "Identifier" && callee.name === "reportError") return true;
|
|
3214
|
+
}
|
|
3215
|
+
for (const key of Object.keys(node)) {
|
|
3216
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3217
|
+
const val = node[key];
|
|
3218
|
+
if (val && typeof val === "object") {
|
|
3219
|
+
if (containsLoggingCall(val, depth + 1)) return true;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
return false;
|
|
3223
|
+
}
|
|
3224
|
+
return {
|
|
3225
|
+
TryStatement(node) {
|
|
3226
|
+
const fetchNode = findFetchToLlmHost(node.block);
|
|
3227
|
+
if (!fetchNode) return;
|
|
3228
|
+
const handler = node.handler;
|
|
3229
|
+
if (!handler) return;
|
|
3230
|
+
if (!containsLoggingCall(handler.body)) {
|
|
3231
|
+
context.report({ node: handler, messageId: "silentCatch" });
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
};
|
|
3237
|
+
var lovableSilentCatchOnProviderCallRule = rule41;
|
|
3238
|
+
|
|
3239
|
+
// src/providers/browserbase/utils.ts
|
|
3240
|
+
function memberPropName3(node) {
|
|
3241
|
+
if (node?.type !== "CallExpression") return void 0;
|
|
3242
|
+
const callee = node.callee;
|
|
3243
|
+
if (callee?.type !== "MemberExpression") return void 0;
|
|
3244
|
+
const prop = callee.property;
|
|
3245
|
+
if (!callee.computed && prop?.type === "Identifier") return prop.name;
|
|
3246
|
+
if (callee.computed && prop?.type === "Literal" && typeof prop.value === "string") return prop.value;
|
|
3247
|
+
return void 0;
|
|
3248
|
+
}
|
|
3249
|
+
function isSessionsCall(node, name) {
|
|
3250
|
+
if (memberPropName3(node) !== name) return false;
|
|
3251
|
+
const obj = node.callee.object;
|
|
3252
|
+
return obj?.type === "MemberExpression" && !obj.computed && obj.property?.type === "Identifier" && obj.property.name === "sessions";
|
|
3253
|
+
}
|
|
3254
|
+
function isSessionsRecordingCall(node, name) {
|
|
3255
|
+
if (memberPropName3(node) !== name) return false;
|
|
3256
|
+
const recordingObj = node.callee.object;
|
|
3257
|
+
if (recordingObj?.type !== "MemberExpression" || recordingObj.computed) return false;
|
|
3258
|
+
if (recordingObj.property?.type !== "Identifier" || recordingObj.property.name !== "recording") return false;
|
|
3259
|
+
const sessionsObj = recordingObj.object;
|
|
3260
|
+
return sessionsObj?.type === "MemberExpression" && !sessionsObj.computed && sessionsObj.property?.type === "Identifier" && sessionsObj.property.name === "sessions";
|
|
3261
|
+
}
|
|
3262
|
+
function findProperty2(objectExpression, name) {
|
|
3263
|
+
if (objectExpression?.type !== "ObjectExpression") return void 0;
|
|
3264
|
+
return objectExpression.properties?.find(
|
|
3265
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
function startOffset3(n) {
|
|
3269
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
3270
|
+
if (typeof n?.start === "number") return n.start;
|
|
3271
|
+
return (n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.start?.column ?? 0);
|
|
3272
|
+
}
|
|
3273
|
+
function endOffset3(n) {
|
|
3274
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
3275
|
+
if (typeof n?.end === "number") return n.end;
|
|
3276
|
+
return (n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.end?.column ?? 0);
|
|
3277
|
+
}
|
|
3278
|
+
function contains3(outer, inner) {
|
|
3279
|
+
const s = startOffset3(inner);
|
|
3280
|
+
return s >= startOffset3(outer) && s <= endOffset3(outer);
|
|
3281
|
+
}
|
|
3282
|
+
function someDescendant2(node, predicate) {
|
|
3283
|
+
let found = false;
|
|
3284
|
+
function visit(n) {
|
|
3285
|
+
if (found || !n || typeof n !== "object") return;
|
|
3286
|
+
if (Array.isArray(n)) {
|
|
3287
|
+
for (const item of n) visit(item);
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
if (typeof n.type !== "string") return;
|
|
3291
|
+
if (predicate(n)) {
|
|
3292
|
+
found = true;
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
for (const key of Object.keys(n)) {
|
|
3296
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3297
|
+
visit(n[key]);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
visit(node);
|
|
3301
|
+
return found;
|
|
3302
|
+
}
|
|
3303
|
+
function isResponseSendCall(node) {
|
|
3304
|
+
const name = memberPropName3(node);
|
|
3305
|
+
if (name !== "json" && name !== "send") return false;
|
|
3306
|
+
const obj = node.callee.object;
|
|
3307
|
+
if (obj?.type === "Identifier") {
|
|
3308
|
+
return /^(res|response)$/i.test(obj.name) || obj.name === "Response" || obj.name === "NextResponse";
|
|
3309
|
+
}
|
|
3310
|
+
return false;
|
|
3311
|
+
}
|
|
3312
|
+
function isInsideTestFile3(filename) {
|
|
3313
|
+
return /(^|[\\/])__tests__[\\/]|\.(test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
// src/providers/browserbase/rules/no-conditional-authz-on-anonymous-user.ts
|
|
3317
|
+
function isUserLikeExpr(node) {
|
|
3318
|
+
if (node?.type === "Identifier") return /user/i.test(node.name);
|
|
3319
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3320
|
+
return /user/i.test(node.property.name);
|
|
3321
|
+
}
|
|
3322
|
+
return false;
|
|
3323
|
+
}
|
|
3324
|
+
function isFalsyGuardTest(test) {
|
|
3325
|
+
if (test?.type === "UnaryExpression" && test.operator === "!") return isUserLikeExpr(test.argument);
|
|
3326
|
+
if (test?.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) {
|
|
3327
|
+
const sides = [test.left, test.right];
|
|
3328
|
+
const isNullish = (n) => n?.type === "Literal" && n.value === null || n?.type === "Identifier" && n.name === "undefined";
|
|
3329
|
+
const nullSide = sides.find(isNullish);
|
|
3330
|
+
const otherSide = sides.find((s) => s !== nullSide);
|
|
3331
|
+
return !!nullSide && isUserLikeExpr(otherSide);
|
|
3332
|
+
}
|
|
3333
|
+
return false;
|
|
3334
|
+
}
|
|
3335
|
+
function hasReturnOrThrow(stmt) {
|
|
3336
|
+
if (!stmt) return false;
|
|
3337
|
+
if (stmt.type === "ReturnStatement" || stmt.type === "ThrowStatement") return true;
|
|
3338
|
+
if (stmt.type === "BlockStatement") {
|
|
3339
|
+
return (stmt.body ?? []).some((s) => s.type === "ReturnStatement" || s.type === "ThrowStatement");
|
|
3340
|
+
}
|
|
3341
|
+
return false;
|
|
3342
|
+
}
|
|
3343
|
+
function isTruthyGateTest(test) {
|
|
3344
|
+
if (isUserLikeExpr(test)) return true;
|
|
3345
|
+
if (test?.type === "LogicalExpression" && test.operator === "&&") {
|
|
3346
|
+
return isTruthyGateTest(test.left) || isTruthyGateTest(test.right);
|
|
3347
|
+
}
|
|
3348
|
+
return false;
|
|
3349
|
+
}
|
|
3350
|
+
var rule42 = {
|
|
3351
|
+
meta: {
|
|
3352
|
+
type: "problem",
|
|
3353
|
+
docs: {
|
|
3354
|
+
description: "Sensitive Browserbase resources must be guarded by unconditional authorization",
|
|
3355
|
+
category: "security",
|
|
3356
|
+
cwe: "CWE-862",
|
|
3357
|
+
owasp: "A01:2021 Broken Access Control",
|
|
3358
|
+
rationale: "bb.sessions.debug() and bb.sessions.recording.retrieve() return a live CDP debugger URL or a full rrweb session replay \u2014 high-value resources. Gating the ownership check behind `if (user) { ...check... }` with no unconditional `if (!user) return` guard means an anonymous caller (no credentials at all) simply skips the whole block and falls through to the sensitive call. Authorization must reject the unauthenticated case explicitly, not rely on a conditional that happens to be true for legitimate callers.",
|
|
3359
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
3360
|
+
recommended: true
|
|
3361
|
+
},
|
|
3362
|
+
messages: {
|
|
3363
|
+
conditionalAuthz: "This route returns a Browserbase live-view/recording resource gated only by `if (user) {...}` with no unconditional rejection for anonymous callers. Add an `if (!user) return ...` guard before this."
|
|
3364
|
+
},
|
|
3365
|
+
schema: []
|
|
3366
|
+
},
|
|
3367
|
+
create(context) {
|
|
3368
|
+
const scopes = [];
|
|
3369
|
+
const gates = [];
|
|
3370
|
+
const sensitiveCalls = [];
|
|
3371
|
+
function registerScope(node) {
|
|
3372
|
+
scopes.push({ node, start: startOffset3(node), end: endOffset3(node) });
|
|
3373
|
+
}
|
|
3374
|
+
function innermostScope(pos) {
|
|
3375
|
+
let best;
|
|
3376
|
+
for (const scope of scopes) {
|
|
3377
|
+
if (pos < scope.start || pos > scope.end) continue;
|
|
3378
|
+
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
3379
|
+
}
|
|
3380
|
+
return best;
|
|
3381
|
+
}
|
|
3382
|
+
return {
|
|
3383
|
+
FunctionDeclaration(node) {
|
|
3384
|
+
registerScope(node);
|
|
3385
|
+
},
|
|
3386
|
+
FunctionExpression(node) {
|
|
3387
|
+
registerScope(node);
|
|
3388
|
+
},
|
|
3389
|
+
ArrowFunctionExpression(node) {
|
|
3390
|
+
registerScope(node);
|
|
3391
|
+
},
|
|
3392
|
+
IfStatement(node) {
|
|
3393
|
+
if (isFalsyGuardTest(node.test) && hasReturnOrThrow(node.consequent)) {
|
|
3394
|
+
const pos = startOffset3(node);
|
|
3395
|
+
const scope = innermostScope(pos);
|
|
3396
|
+
if (scope && (scope.guardPos === void 0 || pos < scope.guardPos)) scope.guardPos = pos;
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
if (isTruthyGateTest(node.test) && !node.alternate) {
|
|
3400
|
+
gates.push({ start: startOffset3(node), end: endOffset3(node) });
|
|
3401
|
+
}
|
|
3402
|
+
},
|
|
3403
|
+
CallExpression(node) {
|
|
3404
|
+
if (isSessionsCall(node, "debug") || isSessionsRecordingCall(node, "retrieve")) {
|
|
3405
|
+
sensitiveCalls.push(node);
|
|
3406
|
+
}
|
|
3407
|
+
},
|
|
3408
|
+
"Program:exit"() {
|
|
3409
|
+
for (const call of sensitiveCalls) {
|
|
3410
|
+
const gate = gates.find((g) => contains3(g, call));
|
|
3411
|
+
if (!gate) continue;
|
|
3412
|
+
const scope = innermostScope(startOffset3(call));
|
|
3413
|
+
const hasGuardBeforeGate = scope?.guardPos !== void 0 && scope.guardPos < gate.start;
|
|
3414
|
+
if (!hasGuardBeforeGate) {
|
|
3415
|
+
context.report({ node: call, messageId: "conditionalAuthz" });
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
};
|
|
3420
|
+
}
|
|
3421
|
+
};
|
|
3422
|
+
var browserbaseNoConditionalAuthzOnAnonymousUserRule = rule42;
|
|
3423
|
+
|
|
3424
|
+
// src/providers/browserbase/rules/no-connect-url-in-api-response.ts
|
|
3425
|
+
var rule43 = {
|
|
3426
|
+
meta: {
|
|
3427
|
+
type: "problem",
|
|
3428
|
+
docs: {
|
|
3429
|
+
description: "connectUrl must never be placed in an HTTP response body",
|
|
3430
|
+
category: "security",
|
|
3431
|
+
cwe: "CWE-200",
|
|
3432
|
+
owasp: "A04:2021 Insecure Design",
|
|
3433
|
+
rationale: "session.connectUrl is the WebSocket CDP URL returned by sessions.create() \u2014 it is self-authenticating; connecting to it grants full read/write control of the live browser (arbitrary JS execution, cookie/localStorage read, simulated input), equivalent to a bearer token. Putting it in a response body widens its exposure (network tab, browser extensions, error trackers, logs) for callers that almost never need it client-side \u2014 the intended sharable artifact is debuggerFullscreenUrl from sessions.debug().",
|
|
3434
|
+
docsUrl: "https://docs.browserbase.com/reference/api/create-a-session",
|
|
3435
|
+
recommended: true
|
|
3436
|
+
},
|
|
3437
|
+
messages: {
|
|
3438
|
+
connectUrlInResponse: "connectUrl is included in an HTTP response body. Keep it server-side only \u2014 it is a bearer credential for the live browser, not a sharable URL."
|
|
3439
|
+
},
|
|
3440
|
+
schema: []
|
|
3441
|
+
},
|
|
3442
|
+
create(context) {
|
|
3443
|
+
return {
|
|
3444
|
+
CallExpression(node) {
|
|
3445
|
+
if (!isResponseSendCall(node)) return;
|
|
3446
|
+
const arg = node.arguments?.[0];
|
|
3447
|
+
if (arg?.type !== "ObjectExpression") return;
|
|
3448
|
+
if (findProperty2(arg, "connectUrl")) {
|
|
3449
|
+
context.report({ node, messageId: "connectUrlInResponse" });
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
};
|
|
3455
|
+
var browserbaseNoConnectUrlInApiResponseRule = rule43;
|
|
3456
|
+
|
|
3457
|
+
// src/providers/browserbase/rules/session-id-requires-ownership-check.ts
|
|
3458
|
+
function isOwnershipCheckCall(node) {
|
|
3459
|
+
if (node?.type !== "CallExpression") return false;
|
|
3460
|
+
const callee = node.callee;
|
|
3461
|
+
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
3462
|
+
if (!name) return false;
|
|
3463
|
+
return /owner|belongsto|hasaccess|authorize|verifyaccess|checkaccess|findproject|getproject/i.test(name);
|
|
3464
|
+
}
|
|
3465
|
+
function isSessionIdLikeArg(node) {
|
|
3466
|
+
if (node?.type === "Identifier") return /sessionid/i.test(node.name);
|
|
3467
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3468
|
+
return /sessionid/i.test(node.property.name);
|
|
3469
|
+
}
|
|
3470
|
+
return false;
|
|
3471
|
+
}
|
|
3472
|
+
var rule44 = {
|
|
3473
|
+
meta: {
|
|
3474
|
+
type: "suggestion",
|
|
3475
|
+
docs: {
|
|
3476
|
+
description: "Session id lookups must verify ownership before returning live-view data",
|
|
3477
|
+
category: "security",
|
|
3478
|
+
cwe: "CWE-862",
|
|
3479
|
+
rationale: "A session id alone is not an authorization token \u2014 it is an opaque identifier that can leak via links, logs, screenshots, or support tickets. A handler that resolves a sessionId straight into sessions.debug()/sessions.recording.retrieve() with no ownership/org-membership check lets any authenticated caller view or replay a session that belongs to someone else.",
|
|
3480
|
+
docsUrl: "https://docs.browserbase.com/features/session-live-view",
|
|
3481
|
+
recommended: true
|
|
3482
|
+
},
|
|
3483
|
+
messages: {
|
|
3484
|
+
missingOwnershipCheck: "sessionId is passed directly into a Browserbase live-view/recording call with no ownership check beforehand."
|
|
3485
|
+
},
|
|
3486
|
+
schema: []
|
|
3487
|
+
},
|
|
3488
|
+
create(context) {
|
|
3489
|
+
const scopes = [];
|
|
3490
|
+
const sensitiveCalls = [];
|
|
3491
|
+
function registerScope(node) {
|
|
3492
|
+
scopes.push({ node, start: startOffset3(node), end: endOffset3(node), sawOwnershipCheck: false });
|
|
3493
|
+
}
|
|
3494
|
+
function innermostScope(pos) {
|
|
3495
|
+
let best;
|
|
3496
|
+
for (const scope of scopes) {
|
|
3497
|
+
if (pos < scope.start || pos > scope.end) continue;
|
|
3498
|
+
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
3499
|
+
}
|
|
3500
|
+
return best;
|
|
3501
|
+
}
|
|
3502
|
+
return {
|
|
3503
|
+
FunctionDeclaration(node) {
|
|
3504
|
+
registerScope(node);
|
|
3505
|
+
},
|
|
3506
|
+
FunctionExpression(node) {
|
|
3507
|
+
registerScope(node);
|
|
3508
|
+
},
|
|
3509
|
+
ArrowFunctionExpression(node) {
|
|
3510
|
+
registerScope(node);
|
|
3511
|
+
},
|
|
3512
|
+
CallExpression(node) {
|
|
3513
|
+
const pos = startOffset3(node);
|
|
3514
|
+
const scope = innermostScope(pos);
|
|
3515
|
+
if (isOwnershipCheckCall(node)) {
|
|
3516
|
+
for (const s of scopes) {
|
|
3517
|
+
if (pos >= s.start && pos <= s.end) s.sawOwnershipCheck = true;
|
|
3518
|
+
}
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
if (isSessionsCall(node, "debug") || isSessionsRecordingCall(node, "retrieve")) {
|
|
3522
|
+
const sessionIdArg = (node.arguments ?? []).some(isSessionIdLikeArg);
|
|
3523
|
+
if (sessionIdArg) sensitiveCalls.push({ node, scope });
|
|
3524
|
+
}
|
|
3525
|
+
},
|
|
3526
|
+
"Program:exit"() {
|
|
3527
|
+
for (const { node, scope } of sensitiveCalls) {
|
|
3528
|
+
if (!scope || !scope.sawOwnershipCheck) {
|
|
3529
|
+
context.report({ node, messageId: "missingOwnershipCheck" });
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
};
|
|
3534
|
+
}
|
|
3535
|
+
};
|
|
3536
|
+
var browserbaseSessionIdRequiresOwnershipCheckRule = rule44;
|
|
3537
|
+
|
|
3538
|
+
// src/providers/browserbase/rules/no-concurrent-shared-context.ts
|
|
3539
|
+
function isPromiseAllCall2(node) {
|
|
3540
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.object?.type === "Identifier" && node.callee.object.name === "Promise" && node.callee.property?.type === "Identifier" && node.callee.property.name === "all";
|
|
3541
|
+
}
|
|
3542
|
+
function isMapCall2(node) {
|
|
3543
|
+
return memberPropName3(node) === "map";
|
|
3544
|
+
}
|
|
3545
|
+
function callbackParamNames(callback) {
|
|
3546
|
+
const names = /* @__PURE__ */ new Set();
|
|
3547
|
+
const param = callback?.params?.[0];
|
|
3548
|
+
if (param?.type === "Identifier") {
|
|
3549
|
+
names.add(param.name);
|
|
3550
|
+
} else if (param?.type === "ObjectPattern") {
|
|
3551
|
+
for (const p of param.properties ?? []) {
|
|
3552
|
+
if (p?.type === "Property" && p.value?.type === "Identifier") names.add(p.value.name);
|
|
3553
|
+
else if (p?.type === "RestElement" && p.argument?.type === "Identifier") names.add(p.argument.name);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
return names;
|
|
3557
|
+
}
|
|
3558
|
+
function contextIdValueIsShared(callback, contextIdNode) {
|
|
3559
|
+
if (contextIdNode?.type !== "Identifier") return false;
|
|
3560
|
+
const params = callbackParamNames(callback);
|
|
3561
|
+
if (params.has(contextIdNode.name)) return false;
|
|
3562
|
+
return true;
|
|
3563
|
+
}
|
|
3564
|
+
function findSharedContextCreateCall(callback) {
|
|
3565
|
+
const body = callback?.body;
|
|
3566
|
+
if (!body) return null;
|
|
3567
|
+
let found = null;
|
|
3568
|
+
function visit(n) {
|
|
3569
|
+
if (found || !n || typeof n !== "object") return;
|
|
3570
|
+
if (Array.isArray(n)) {
|
|
3571
|
+
for (const item of n) visit(item);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
if (typeof n.type !== "string") return;
|
|
3575
|
+
if (isSessionsCall(n, "create")) {
|
|
3576
|
+
const optsArg = n.arguments?.[0];
|
|
3577
|
+
const browserSettings = findProperty2(optsArg, "browserSettings")?.value;
|
|
3578
|
+
const ctxProp = findProperty2(browserSettings, "context")?.value;
|
|
3579
|
+
const idProp = findProperty2(ctxProp, "id");
|
|
3580
|
+
if (idProp && contextIdValueIsShared(callback, idProp.value)) {
|
|
3581
|
+
found = n;
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
for (const key of Object.keys(n)) {
|
|
3586
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3587
|
+
visit(n[key]);
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
visit(body);
|
|
3591
|
+
return found;
|
|
3592
|
+
}
|
|
3593
|
+
var rule45 = {
|
|
3594
|
+
meta: {
|
|
3595
|
+
type: "problem",
|
|
3596
|
+
docs: {
|
|
3597
|
+
description: "A Browserbase Context must not be attached to concurrent sessions",
|
|
3598
|
+
category: "correctness",
|
|
3599
|
+
rationale: `Browserbase's docs state explicitly: "Avoid having multiple sessions using the same Context at once. Sites may force a log out." Passing the same browserSettings.context.id into every iteration of a Promise.all(items.map(...)) batch creates 2+ Browserbase sessions simultaneously attached to one Context, producing non-deterministic, flaky failures when a site force-logs-out one session because another concurrently authenticated against the same context.`,
|
|
3600
|
+
docsUrl: "https://docs.browserbase.com/features/contexts",
|
|
3601
|
+
recommended: true
|
|
3602
|
+
},
|
|
3603
|
+
messages: {
|
|
3604
|
+
concurrentSharedContext: "The same browserSettings.context.id is passed into every concurrent sessions.create() call in this Promise.all batch. Sites may force a log out when 2+ sessions share a Context at once."
|
|
3605
|
+
},
|
|
3606
|
+
schema: []
|
|
3607
|
+
},
|
|
3608
|
+
create(context) {
|
|
3609
|
+
const mapCallsByVarName = /* @__PURE__ */ new Map();
|
|
3610
|
+
function checkMapCall(mapCallNode) {
|
|
3611
|
+
if (!isMapCall2(mapCallNode)) return;
|
|
3612
|
+
const callback = mapCallNode.arguments?.[mapCallNode.arguments.length - 1];
|
|
3613
|
+
if (!callback) return;
|
|
3614
|
+
const sharedCall = findSharedContextCreateCall(callback);
|
|
3615
|
+
if (sharedCall) {
|
|
3616
|
+
context.report({ node: sharedCall, messageId: "concurrentSharedContext" });
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
return {
|
|
3620
|
+
VariableDeclarator(node) {
|
|
3621
|
+
if (node.id?.type === "Identifier" && isMapCall2(node.init)) {
|
|
3622
|
+
mapCallsByVarName.set(node.id.name, node.init);
|
|
3623
|
+
}
|
|
3624
|
+
},
|
|
3625
|
+
CallExpression(node) {
|
|
3626
|
+
if (!isPromiseAllCall2(node)) return;
|
|
3627
|
+
const arg = node.arguments?.[0];
|
|
3628
|
+
if (isMapCall2(arg)) {
|
|
3629
|
+
checkMapCall(arg);
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
if (arg?.type === "Identifier") {
|
|
3633
|
+
const mapCall = mapCallsByVarName.get(arg.name);
|
|
3634
|
+
if (mapCall) checkMapCall(mapCall);
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
};
|
|
3640
|
+
var browserbaseNoConcurrentSharedContextRule = rule45;
|
|
3641
|
+
|
|
3642
|
+
// src/providers/browserbase/rules/mobile-device-requires-os-setting.ts
|
|
3643
|
+
function isMobileLiteral(node) {
|
|
3644
|
+
return node?.type === "Literal" && (node.value === "mobile" || node.value === "tablet");
|
|
3645
|
+
}
|
|
3646
|
+
function testComparesToMobile(test) {
|
|
3647
|
+
if (test?.type !== "BinaryExpression") return false;
|
|
3648
|
+
if (test.operator !== "===" && test.operator !== "==") return false;
|
|
3649
|
+
return isMobileLiteral(test.left) || isMobileLiteral(test.right);
|
|
3650
|
+
}
|
|
3651
|
+
function isViewportReference(node) {
|
|
3652
|
+
if (node?.type === "Identifier") return /viewport/i.test(node.name);
|
|
3653
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3654
|
+
return /viewport|width|height/i.test(node.property.name);
|
|
3655
|
+
}
|
|
3656
|
+
if (node?.type === "Property") {
|
|
3657
|
+
return node.key?.type === "Identifier" && /viewport|width|height/i.test(node.key.name);
|
|
3658
|
+
}
|
|
3659
|
+
return false;
|
|
3660
|
+
}
|
|
3661
|
+
function setsViewport(node) {
|
|
3662
|
+
return someDescendant2(node, isViewportReference);
|
|
3663
|
+
}
|
|
3664
|
+
function isOsMobileSetting(node) {
|
|
3665
|
+
if (node?.type === "Property" && node.key?.type === "Identifier" && node.key.name === "os" && isMobileLiteral(node.value)) {
|
|
3666
|
+
return true;
|
|
3667
|
+
}
|
|
3668
|
+
if (node?.type === "AssignmentExpression" && node.left?.type === "MemberExpression" && !node.left.computed && node.left.property?.type === "Identifier" && node.left.property.name === "os" && isMobileLiteral(node.right)) {
|
|
3669
|
+
return true;
|
|
3670
|
+
}
|
|
3671
|
+
return false;
|
|
3672
|
+
}
|
|
3673
|
+
var rule46 = {
|
|
3674
|
+
meta: {
|
|
3675
|
+
type: "problem",
|
|
3676
|
+
docs: {
|
|
3677
|
+
description: "Mobile device combos must set browserSettings.os, not just resize the viewport",
|
|
3678
|
+
category: "correctness",
|
|
3679
|
+
rationale: `The Node SDK's device emulation lever is browserSettings.os: 'mobile' | 'tablet' \u2014 there is no fingerprint API in this SDK. A "mobile" session that only resizes the Playwright viewport is still a desktop Chrome browser with a desktop user-agent string in a small window. Sites that branch behavior on UA/touch capability (the majority of responsive sites) never actually exercise their mobile code path, silently undermining "test on mobile" results.`,
|
|
3680
|
+
docsUrl: "https://docs.browserbase.com/features/stealth-mode",
|
|
3681
|
+
recommended: true
|
|
3682
|
+
},
|
|
3683
|
+
messages: {
|
|
3684
|
+
missingOsSetting: 'A "mobile" branch resizes the viewport but never sets browserSettings.os to "mobile"/"tablet". Without it this is still a desktop browser in a small window.'
|
|
3685
|
+
},
|
|
3686
|
+
schema: []
|
|
3687
|
+
},
|
|
3688
|
+
create(context) {
|
|
3689
|
+
const mobileViewportBranches = [];
|
|
3690
|
+
let sawOsMobileSetting = false;
|
|
3691
|
+
return {
|
|
3692
|
+
IfStatement(node) {
|
|
3693
|
+
if (testComparesToMobile(node.test) && setsViewport(node.consequent)) {
|
|
3694
|
+
mobileViewportBranches.push(node);
|
|
3695
|
+
}
|
|
3696
|
+
},
|
|
3697
|
+
Property(node) {
|
|
3698
|
+
if (isOsMobileSetting(node)) sawOsMobileSetting = true;
|
|
3699
|
+
},
|
|
3700
|
+
AssignmentExpression(node) {
|
|
3701
|
+
if (isOsMobileSetting(node)) sawOsMobileSetting = true;
|
|
3702
|
+
},
|
|
3703
|
+
"Program:exit"() {
|
|
3704
|
+
if (sawOsMobileSetting) return;
|
|
3705
|
+
for (const branch of mobileViewportBranches) {
|
|
3706
|
+
context.report({ node: branch, messageId: "missingOsSetting" });
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
};
|
|
3712
|
+
var browserbaseMobileDeviceRequiresOsSettingRule = rule46;
|
|
3713
|
+
|
|
3714
|
+
// src/providers/browserbase/rules/use-typed-exception-status-not-substring.ts
|
|
3715
|
+
function isStringifiedErrorSource(node, errName) {
|
|
3716
|
+
if (node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String") {
|
|
3717
|
+
const arg = node.arguments?.[0];
|
|
3718
|
+
return arg?.type === "Identifier" && arg.name === errName;
|
|
3719
|
+
}
|
|
3720
|
+
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression") {
|
|
3721
|
+
const obj = node.callee.object;
|
|
3722
|
+
const prop = node.callee.property;
|
|
3723
|
+
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "toString") {
|
|
3724
|
+
return true;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
if (node?.type === "MemberExpression" && !node.computed) {
|
|
3728
|
+
const obj = node.object;
|
|
3729
|
+
const prop = node.property;
|
|
3730
|
+
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "message") {
|
|
3731
|
+
return true;
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
return false;
|
|
3735
|
+
}
|
|
3736
|
+
function unwrapToLowerCase(node) {
|
|
3737
|
+
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "toLowerCase") {
|
|
3738
|
+
return node.callee.object;
|
|
3739
|
+
}
|
|
3740
|
+
return node;
|
|
3741
|
+
}
|
|
3742
|
+
function usesTypedStatus(body, errName) {
|
|
3743
|
+
return someDescendant2(body, (n) => {
|
|
3744
|
+
if (n.type === "MemberExpression" && !n.computed && n.object?.type === "Identifier" && n.object.name === errName) {
|
|
3745
|
+
return n.property?.type === "Identifier" && n.property.name === "status";
|
|
3746
|
+
}
|
|
3747
|
+
if (n.type === "BinaryExpression" && n.operator === "instanceof" && n.left?.type === "Identifier" && n.left.name === errName) {
|
|
3748
|
+
return true;
|
|
3749
|
+
}
|
|
3750
|
+
return false;
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
function usesSubstringMatch(body, errName, stringifiedVars) {
|
|
3754
|
+
return someDescendant2(body, (n) => {
|
|
3755
|
+
if (n.type !== "CallExpression") return false;
|
|
3756
|
+
if (n.callee?.type !== "MemberExpression" || n.callee.computed) return false;
|
|
3757
|
+
if (n.callee.property?.type !== "Identifier" || n.callee.property.name !== "includes") return false;
|
|
3758
|
+
const source = unwrapToLowerCase(n.callee.object);
|
|
3759
|
+
if (isStringifiedErrorSource(source, errName)) return true;
|
|
3760
|
+
return source?.type === "Identifier" && stringifiedVars.has(source.name);
|
|
3761
|
+
});
|
|
3762
|
+
}
|
|
3763
|
+
function collectStringifiedVars(body, errName) {
|
|
3764
|
+
const names = /* @__PURE__ */ new Set();
|
|
3765
|
+
someDescendant2(body, (n) => {
|
|
3766
|
+
if (n.type === "VariableDeclarator" && n.id?.type === "Identifier" && n.init) {
|
|
3767
|
+
const source = unwrapToLowerCase(n.init);
|
|
3768
|
+
if (isStringifiedErrorSource(source, errName)) names.add(n.id.name);
|
|
3769
|
+
}
|
|
3770
|
+
return false;
|
|
3771
|
+
});
|
|
3772
|
+
return names;
|
|
3773
|
+
}
|
|
3774
|
+
var rule47 = {
|
|
3775
|
+
meta: {
|
|
3776
|
+
type: "suggestion",
|
|
3777
|
+
docs: {
|
|
3778
|
+
description: "Branch on err.status, not a substring match of the stringified error",
|
|
3779
|
+
category: "correctness",
|
|
3780
|
+
rationale: 'The SDK raises typed Browserbase.APIError (and subclasses like NotFoundError) carrying a real .status: number and .headers. Matching err.message or String(err) against substrings like "410" or "Session stopped" is fragile \u2014 that human-readable text is an unversioned implementation detail the provider can change at any time without notice. Check err.status (or `instanceof`) instead.',
|
|
3781
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
3782
|
+
recommended: true
|
|
3783
|
+
},
|
|
3784
|
+
messages: {
|
|
3785
|
+
substringStatusMatch: "This catch block matches a substring of the stringified error instead of checking err.status or `instanceof Browserbase.APIError`."
|
|
3786
|
+
},
|
|
3787
|
+
schema: []
|
|
3788
|
+
},
|
|
3789
|
+
create(context) {
|
|
3790
|
+
return {
|
|
3791
|
+
CatchClause(node) {
|
|
3792
|
+
const param = node.param;
|
|
3793
|
+
const errName = param?.type === "Identifier" ? param.name : void 0;
|
|
3794
|
+
if (!errName || !node.body) return;
|
|
3795
|
+
if (usesTypedStatus(node.body, errName)) return;
|
|
3796
|
+
const stringifiedVars = collectStringifiedVars(node.body, errName);
|
|
3797
|
+
if (usesSubstringMatch(node.body, errName, stringifiedVars)) {
|
|
3798
|
+
context.report({ node, messageId: "substringStatusMatch" });
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
};
|
|
3804
|
+
var browserbaseUseTypedExceptionStatusNotSubstringRule = rule47;
|
|
3805
|
+
|
|
3806
|
+
// src/providers/browserbase/rules/release-session-on-connect-failure.ts
|
|
3807
|
+
function isSessionsCreateAwait(node) {
|
|
3808
|
+
return node?.type === "AwaitExpression" && isSessionsCall(node.argument, "create");
|
|
3809
|
+
}
|
|
3810
|
+
function isConnectCall(node) {
|
|
3811
|
+
if (node?.type !== "CallExpression") return false;
|
|
3812
|
+
const callee = node.callee;
|
|
3813
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3814
|
+
return !!name && /connect/i.test(name);
|
|
3815
|
+
}
|
|
3816
|
+
function isReleaseCall(node) {
|
|
3817
|
+
if (node?.type !== "CallExpression") return false;
|
|
3818
|
+
if (isSessionsCall(node, "update")) {
|
|
3819
|
+
const optsArg = node.arguments?.[1];
|
|
3820
|
+
const statusProp = findProperty2(optsArg, "status");
|
|
3821
|
+
if (statusProp?.value?.type === "Literal" && statusProp.value.value === "REQUEST_RELEASE") return true;
|
|
3822
|
+
}
|
|
3823
|
+
const callee = node.callee;
|
|
3824
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3825
|
+
return !!name && /requeststop|releasesession|release_session/i.test(name);
|
|
3826
|
+
}
|
|
3827
|
+
var rule48 = {
|
|
3828
|
+
meta: {
|
|
3829
|
+
type: "problem",
|
|
3830
|
+
docs: {
|
|
3831
|
+
description: "A failed connect after session creation must release the session",
|
|
3832
|
+
category: "reliability",
|
|
3833
|
+
rationale: `If the CDP connect call raises after sessions.create() already succeeded on Browserbase's side, the session stays RUNNING and only terminates once its timeout elapses (up to 6h) unless something explicitly calls sessions.update(id, { status: 'REQUEST_RELEASE' }). The SDK's own docs warn to do this before the timeout "to avoid additional charges." A connect call with no try/catch (or a catch that doesn't release) leaks the session on every connect failure.`,
|
|
3834
|
+
docsUrl: "https://docs.browserbase.com/reference/api/update-a-session",
|
|
3835
|
+
recommended: true
|
|
3836
|
+
},
|
|
3837
|
+
messages: {
|
|
3838
|
+
sessionNotReleasedOnFailure: 'A session was created but this connect call has no catch that releases it (sessions.update with status: "REQUEST_RELEASE") on failure. A failed connect will leak the session until its timeout.'
|
|
3839
|
+
},
|
|
3840
|
+
schema: []
|
|
3841
|
+
},
|
|
3842
|
+
create(context) {
|
|
3843
|
+
let sawSessionCreate = false;
|
|
3844
|
+
const connectCalls = [];
|
|
3845
|
+
const tryStatements = [];
|
|
3846
|
+
return {
|
|
3847
|
+
AwaitExpression(node) {
|
|
3848
|
+
if (isSessionsCreateAwait(node)) sawSessionCreate = true;
|
|
3849
|
+
},
|
|
3850
|
+
TryStatement(node) {
|
|
3851
|
+
tryStatements.push(node);
|
|
3852
|
+
},
|
|
3853
|
+
CallExpression(node) {
|
|
3854
|
+
if (isConnectCall(node)) connectCalls.push(node);
|
|
3855
|
+
},
|
|
3856
|
+
"Program:exit"() {
|
|
3857
|
+
if (!sawSessionCreate) return;
|
|
3858
|
+
for (const call of connectCalls) {
|
|
3859
|
+
const enclosingTry = tryStatements.find((t) => t.block && contains3(t.block, call));
|
|
3860
|
+
const handled = !!enclosingTry?.handler && someDescendant2(enclosingTry.handler, isReleaseCall);
|
|
3861
|
+
if (!handled) {
|
|
3862
|
+
context.report({ node: call, messageId: "sessionNotReleasedOnFailure" });
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
};
|
|
3869
|
+
var browserbaseReleaseSessionOnConnectFailureRule = rule48;
|
|
3870
|
+
|
|
3871
|
+
// src/providers/browserbase/rules/dont-stack-custom-retry-on-sdk-retry.ts
|
|
3872
|
+
var rule49 = {
|
|
3873
|
+
meta: {
|
|
3874
|
+
type: "suggestion",
|
|
3875
|
+
docs: {
|
|
3876
|
+
description: "A custom retry loop around sessions.create must disable the SDK's own retry",
|
|
3877
|
+
category: "reliability",
|
|
3878
|
+
rationale: "The Browserbase client already retries internally (default maxRetries: 2, retrying 408/409/429/5xx with Retry-After-aware exponential backoff) before ever raising an exception back to the caller. A hand-rolled retry loop around sessions.create() that does not pass { maxRetries: 0 } stacks two independent, uncoordinated backoff schedules \u2014 a sustained 429 can trigger N outer attempts x (1 + 2 SDK-internal retries), multiplying worst-case latency well beyond what either layer alone would produce.",
|
|
3879
|
+
docsUrl: "https://docs.browserbase.com/optimizations/concurrency/overview",
|
|
3880
|
+
recommended: true
|
|
3881
|
+
},
|
|
3882
|
+
messages: {
|
|
3883
|
+
stackedRetry: "sessions.create() is called inside a custom retry loop with no { maxRetries: 0 } override \u2014 the SDK retries this call internally too, stacking two backoff schedules."
|
|
3884
|
+
},
|
|
3885
|
+
schema: []
|
|
3886
|
+
},
|
|
3887
|
+
create(context) {
|
|
3888
|
+
const loopRanges = [];
|
|
3889
|
+
return {
|
|
3890
|
+
ForStatement(node) {
|
|
3891
|
+
loopRanges.push(node);
|
|
3892
|
+
},
|
|
3893
|
+
WhileStatement(node) {
|
|
3894
|
+
loopRanges.push(node);
|
|
3895
|
+
},
|
|
3896
|
+
DoWhileStatement(node) {
|
|
3897
|
+
loopRanges.push(node);
|
|
3898
|
+
},
|
|
3899
|
+
CallExpression(node) {
|
|
3900
|
+
if (!isSessionsCall(node, "create")) return;
|
|
3901
|
+
const insideLoop = loopRanges.some((loop) => {
|
|
3902
|
+
const start = loop.range?.[0] ?? loop.start;
|
|
3903
|
+
const end = loop.range?.[1] ?? loop.end;
|
|
3904
|
+
const pos = node.range?.[0] ?? node.start;
|
|
3905
|
+
return pos >= start && pos <= end;
|
|
3906
|
+
});
|
|
3907
|
+
if (!insideLoop) return;
|
|
3908
|
+
const optsArg = node.arguments?.[1];
|
|
3909
|
+
const hasMaxRetries = optsArg?.type === "ObjectExpression" && !!findProperty2(optsArg, "maxRetries");
|
|
3910
|
+
if (!hasMaxRetries) {
|
|
3911
|
+
context.report({ node, messageId: "stackedRetry" });
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
var browserbaseDontStackCustomRetryOnSdkRetryRule = rule49;
|
|
3918
|
+
|
|
3919
|
+
// src/providers/browserbase/rules/no-overbroad-error-substring-match.ts
|
|
3920
|
+
var OVERBROAD_TERMS = /* @__PURE__ */ new Set(["session", "context", "browser", "browserbase"]);
|
|
3921
|
+
function collectIncludesLiterals(test, literals) {
|
|
3922
|
+
if (!test) return;
|
|
3923
|
+
if (test.type === "LogicalExpression" && test.operator === "||") {
|
|
3924
|
+
collectIncludesLiterals(test.left, literals);
|
|
3925
|
+
collectIncludesLiterals(test.right, literals);
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
if (test.type === "CallExpression" && memberPropName3(test) === "includes") {
|
|
3929
|
+
const arg = test.arguments?.[0];
|
|
3930
|
+
if (arg?.type === "Literal" && typeof arg.value === "string") literals.push(arg.value.toLowerCase());
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
function isCleanupCall(node) {
|
|
3934
|
+
if (node?.type !== "CallExpression") return false;
|
|
3935
|
+
const callee = node.callee;
|
|
3936
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3937
|
+
return !!name && /release|remove|cleanup|stop|teardown/i.test(name);
|
|
3938
|
+
}
|
|
3939
|
+
var rule50 = {
|
|
3940
|
+
meta: {
|
|
3941
|
+
type: "problem",
|
|
3942
|
+
docs: {
|
|
3943
|
+
description: "Error-message checks must not OR in a single generic resource-name substring",
|
|
3944
|
+
category: "reliability",
|
|
3945
|
+
rationale: 'A condition like `err.includes("not found") || err.includes("404") || err.includes("session")` looks like it is narrowing to a confirmed session-ended error, but the last clause matches a single generic word \u2014 the name of the resource being polled. Virtually any exception raised from a sessions.* call (a connection reset, an SSL error, a generic timeout that echoes the request URL) is likely to contain that substring too. A coincidental match then triggers the same destructive cleanup as a real 404/410, tearing down a healthy session on a transient blip.',
|
|
3946
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
3947
|
+
recommended: true
|
|
3948
|
+
},
|
|
3949
|
+
messages: {
|
|
3950
|
+
overbroadMatch: 'This OR-chain includes a generic substring check ("{{term}}") that matches almost any error from a sessions.* call, not just a confirmed session-ended response.'
|
|
3951
|
+
},
|
|
3952
|
+
schema: []
|
|
3953
|
+
},
|
|
3954
|
+
create(context) {
|
|
3955
|
+
return {
|
|
3956
|
+
IfStatement(node) {
|
|
3957
|
+
const literals = [];
|
|
3958
|
+
collectIncludesLiterals(node.test, literals);
|
|
3959
|
+
if (literals.length < 2) return;
|
|
3960
|
+
const overbroad = literals.find((l) => OVERBROAD_TERMS.has(l));
|
|
3961
|
+
if (!overbroad) return;
|
|
3962
|
+
if (someDescendant2(node.consequent, isCleanupCall)) {
|
|
3963
|
+
context.report({ node, messageId: "overbroadMatch", data: { term: overbroad } });
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
};
|
|
3969
|
+
var browserbaseNoOverbroadErrorSubstringMatchRule = rule50;
|
|
3970
|
+
|
|
3971
|
+
// src/providers/browserbase/rules/use-sdk-not-raw-requests.ts
|
|
3972
|
+
var BROWSERBASE_HOST = "api.browserbase.com";
|
|
3973
|
+
function templateLiteralContainsHost(node) {
|
|
3974
|
+
return (node.quasis ?? []).some((q) => {
|
|
3975
|
+
const text = q?.value?.cooked ?? q?.value?.raw ?? "";
|
|
3976
|
+
return typeof text === "string" && text.includes(BROWSERBASE_HOST);
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
function urlArgContainsHost(node) {
|
|
3980
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value.includes(BROWSERBASE_HOST);
|
|
3981
|
+
if (node?.type === "TemplateLiteral") return templateLiteralContainsHost(node);
|
|
3982
|
+
return false;
|
|
3983
|
+
}
|
|
3984
|
+
function isRawHttpCall(node) {
|
|
3985
|
+
if (node?.type !== "CallExpression") return { isCall: false, urlArg: void 0 };
|
|
3986
|
+
const callee = node.callee;
|
|
3987
|
+
if (callee?.type === "Identifier" && callee.name === "fetch") {
|
|
3988
|
+
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
3989
|
+
}
|
|
3990
|
+
if (callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier") {
|
|
3991
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
3992
|
+
const methodName = callee.property.name;
|
|
3993
|
+
if (objName && /^(axios|http|https)$/i.test(objName) && /^(get|post|put|patch|delete|request)$/i.test(methodName)) {
|
|
3994
|
+
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
if (callee?.type === "Identifier" && callee.name === "axios") {
|
|
3998
|
+
const arg = node.arguments?.[0];
|
|
3999
|
+
if (arg?.type === "ObjectExpression") {
|
|
4000
|
+
const urlProp = arg.properties?.find(
|
|
4001
|
+
(p) => p?.type === "Property" && p.key?.type === "Identifier" && p.key.name === "url"
|
|
4002
|
+
);
|
|
4003
|
+
return { isCall: true, urlArg: urlProp?.value };
|
|
4004
|
+
}
|
|
4005
|
+
return { isCall: true, urlArg: arg };
|
|
4006
|
+
}
|
|
4007
|
+
return { isCall: false, urlArg: void 0 };
|
|
4008
|
+
}
|
|
4009
|
+
var rule51 = {
|
|
4010
|
+
meta: {
|
|
4011
|
+
type: "suggestion",
|
|
4012
|
+
docs: {
|
|
4013
|
+
description: "Use the installed Browserbase SDK instead of hand-rolled HTTP requests",
|
|
4014
|
+
category: "integration",
|
|
4015
|
+
rationale: "A raw fetch/axios/http call to an api.browserbase.com endpoint duplicates a method the installed SDK already exposes (e.g. sessions.recording.retrieve()), forfeiting its typed exceptions, built-in retry/backoff, and consistent error handling \u2014 and risks drifting on auth header naming or response shape as the API evolves.",
|
|
4016
|
+
docsUrl: "https://docs.browserbase.com/reference/sdk/nodejs",
|
|
4017
|
+
recommended: true
|
|
4018
|
+
},
|
|
4019
|
+
messages: {
|
|
4020
|
+
rawRequestToBrowserbase: "This makes a raw HTTP request to a Browserbase API endpoint instead of using the installed SDK."
|
|
4021
|
+
},
|
|
4022
|
+
schema: []
|
|
4023
|
+
},
|
|
4024
|
+
create(context) {
|
|
4025
|
+
const urlVars = /* @__PURE__ */ new Map();
|
|
4026
|
+
return {
|
|
4027
|
+
VariableDeclarator(node) {
|
|
4028
|
+
if (node.id?.type === "Identifier" && urlArgContainsHost(node.init)) {
|
|
4029
|
+
urlVars.set(node.id.name, node.init);
|
|
4030
|
+
}
|
|
4031
|
+
},
|
|
4032
|
+
CallExpression(node) {
|
|
4033
|
+
const { isCall, urlArg } = isRawHttpCall(node);
|
|
4034
|
+
if (!isCall || !urlArg) return;
|
|
4035
|
+
const resolved = urlArg.type === "Identifier" ? urlVars.get(urlArg.name) ?? urlArg : urlArg;
|
|
4036
|
+
if (urlArgContainsHost(resolved)) {
|
|
4037
|
+
context.report({ node, messageId: "rawRequestToBrowserbase" });
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
};
|
|
4043
|
+
var browserbaseUseSdkNotRawRequestsRule = rule51;
|
|
4044
|
+
|
|
4045
|
+
// src/providers/browserbase/rules/centralize-request-release.ts
|
|
4046
|
+
var rule52 = {
|
|
4047
|
+
meta: {
|
|
4048
|
+
type: "suggestion",
|
|
4049
|
+
docs: {
|
|
4050
|
+
description: "Route REQUEST_RELEASE through a single shared abstraction",
|
|
4051
|
+
category: "integration",
|
|
4052
|
+
rationale: 'sessions.update(id, { status: "REQUEST_RELEASE" }) hand-rolled inline at each call site, with each one carrying slightly different error handling, means an API change (a new required field, a renamed status value) requires fixing every call site instead of one \u2014 and the duplicated copies already drift in error-handling quality. Centralize behind one designated provider method (e.g. requestStop()/releaseSession()) and call that everywhere instead.',
|
|
4053
|
+
docsUrl: "https://docs.browserbase.com/reference/api/update-a-session",
|
|
4054
|
+
recommended: true
|
|
4055
|
+
},
|
|
4056
|
+
messages: {
|
|
4057
|
+
inlineRequestRelease: 'sessions.update(..., { status: "REQUEST_RELEASE" }) is hand-rolled inline here. Route this through a single shared release/provider abstraction instead.'
|
|
4058
|
+
},
|
|
4059
|
+
schema: []
|
|
4060
|
+
},
|
|
4061
|
+
create(context) {
|
|
4062
|
+
const filename = String(context.filename ?? "");
|
|
4063
|
+
if (isInsideTestFile3(filename) || /provider|browserbaseclient/i.test(filename)) {
|
|
4064
|
+
return {};
|
|
4065
|
+
}
|
|
4066
|
+
return {
|
|
4067
|
+
CallExpression(node) {
|
|
4068
|
+
if (!isSessionsCall(node, "update")) return;
|
|
4069
|
+
const optsArg = node.arguments?.[1];
|
|
4070
|
+
const statusProp = findProperty2(optsArg, "status");
|
|
4071
|
+
const val = statusProp?.value;
|
|
4072
|
+
if (val?.type === "Literal" && val.value === "REQUEST_RELEASE") {
|
|
4073
|
+
context.report({ node, messageId: "inlineRequestRelease" });
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
};
|
|
4079
|
+
var browserbaseCentralizeRequestReleaseRule = rule52;
|
|
4080
|
+
|
|
4081
|
+
// src/providers/openai-cua/rules/no-domain-allowlist.ts
|
|
4082
|
+
var ACTION_METHOD_NAMES = /* @__PURE__ */ new Set(["click", "type", "press", "move", "goto", "fill", "dblclick", "dragAndDrop"]);
|
|
4083
|
+
var rule53 = {
|
|
4084
|
+
meta: {
|
|
4085
|
+
type: "problem",
|
|
4086
|
+
docs: {
|
|
4087
|
+
description: "Computer-use action execution must check the page origin against an allowlist",
|
|
4088
|
+
category: "security",
|
|
4089
|
+
cwe: "CWE-284",
|
|
4090
|
+
owasp: "A01:2021 \u2013 Broken Access Control",
|
|
4091
|
+
rationale: `OpenAI's Computer Use guide instructs integrators to "keep an allow list of domains and actions your agent should use, and block everything else." Without an origin check before executing actions, a click that follows an off-domain redirect (phishing link, ad, malicious iframe) is executed exactly like any in-domain click \u2014 and a field-fill action has no origin awareness either, so credentials could be typed into a page the agent was never meant to reach.`,
|
|
4092
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4093
|
+
recommended: true
|
|
4094
|
+
},
|
|
4095
|
+
messages: {
|
|
4096
|
+
noOriginCheck: "This function executes computer-use actions on the current page with no origin/domain allowlist check anywhere in it."
|
|
4097
|
+
}
|
|
4098
|
+
},
|
|
4099
|
+
create(context) {
|
|
4100
|
+
const stack = [];
|
|
4101
|
+
const states = /* @__PURE__ */ new Map();
|
|
4102
|
+
function ensureState(fn) {
|
|
4103
|
+
let s = states.get(fn);
|
|
4104
|
+
if (!s) {
|
|
4105
|
+
s = { sawAction: false, actionNode: null, sawOriginCheck: false };
|
|
4106
|
+
states.set(fn, s);
|
|
4107
|
+
}
|
|
4108
|
+
return s;
|
|
4109
|
+
}
|
|
4110
|
+
function top() {
|
|
4111
|
+
return stack[stack.length - 1];
|
|
4112
|
+
}
|
|
4113
|
+
function pushScope(node) {
|
|
4114
|
+
stack.push(node);
|
|
4115
|
+
ensureState(node);
|
|
4116
|
+
}
|
|
4117
|
+
function popScope() {
|
|
4118
|
+
stack.pop();
|
|
4119
|
+
}
|
|
4120
|
+
function propName2(node) {
|
|
4121
|
+
if (!node) return void 0;
|
|
4122
|
+
if (node.type === "Identifier") return node.name;
|
|
4123
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4124
|
+
return void 0;
|
|
4125
|
+
}
|
|
4126
|
+
function memberChainNames2(node, names = []) {
|
|
4127
|
+
if (node?.type === "MemberExpression") {
|
|
4128
|
+
memberChainNames2(node.object, names);
|
|
4129
|
+
const n = propName2(node.property);
|
|
4130
|
+
if (n) names.push(n);
|
|
4131
|
+
} else if (node?.type === "Identifier") {
|
|
4132
|
+
names.push(node.name);
|
|
4133
|
+
}
|
|
4134
|
+
return names;
|
|
4135
|
+
}
|
|
4136
|
+
function isPageActionCall(node) {
|
|
4137
|
+
if (node?.type !== "CallExpression") return false;
|
|
4138
|
+
if (node.callee?.type !== "MemberExpression") return false;
|
|
4139
|
+
const chain = memberChainNames2(node.callee);
|
|
4140
|
+
if (chain.length === 0) return false;
|
|
4141
|
+
const last = chain[chain.length - 1];
|
|
4142
|
+
if (!ACTION_METHOD_NAMES.has(last)) return false;
|
|
4143
|
+
return chain.some((n) => /^page$/i.test(n) || /page$/i.test(n));
|
|
4144
|
+
}
|
|
4145
|
+
function markOriginCheckSeen() {
|
|
4146
|
+
const fn = top();
|
|
4147
|
+
if (fn) ensureState(fn).sawOriginCheck = true;
|
|
4148
|
+
}
|
|
4149
|
+
return {
|
|
4150
|
+
Program(node) {
|
|
4151
|
+
pushScope(node);
|
|
4152
|
+
},
|
|
4153
|
+
"Program:exit"() {
|
|
4154
|
+
for (const state of states.values()) {
|
|
4155
|
+
if (state.sawAction && !state.sawOriginCheck) {
|
|
4156
|
+
context.report({ node: state.actionNode, messageId: "noOriginCheck" });
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
},
|
|
4160
|
+
FunctionDeclaration(node) {
|
|
4161
|
+
pushScope(node);
|
|
4162
|
+
},
|
|
4163
|
+
"FunctionDeclaration:exit"() {
|
|
4164
|
+
popScope();
|
|
4165
|
+
},
|
|
4166
|
+
FunctionExpression(node) {
|
|
4167
|
+
pushScope(node);
|
|
4168
|
+
},
|
|
4169
|
+
"FunctionExpression:exit"() {
|
|
4170
|
+
popScope();
|
|
4171
|
+
},
|
|
4172
|
+
ArrowFunctionExpression(node) {
|
|
4173
|
+
pushScope(node);
|
|
4174
|
+
},
|
|
4175
|
+
"ArrowFunctionExpression:exit"() {
|
|
4176
|
+
popScope();
|
|
4177
|
+
},
|
|
4178
|
+
CallExpression(node) {
|
|
4179
|
+
if (isPageActionCall(node)) {
|
|
4180
|
+
const fn = top();
|
|
4181
|
+
if (fn) {
|
|
4182
|
+
const state = ensureState(fn);
|
|
4183
|
+
if (!state.sawAction) {
|
|
4184
|
+
state.sawAction = true;
|
|
4185
|
+
state.actionNode = node;
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
},
|
|
4190
|
+
NewExpression(node) {
|
|
4191
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "URL") {
|
|
4192
|
+
markOriginCheckSeen();
|
|
4193
|
+
}
|
|
4194
|
+
},
|
|
4195
|
+
MemberExpression(node) {
|
|
4196
|
+
const name = propName2(node.property);
|
|
4197
|
+
if (name === "hostname" || name === "origin") {
|
|
4198
|
+
markOriginCheckSeen();
|
|
4199
|
+
}
|
|
4200
|
+
},
|
|
4201
|
+
Identifier(node) {
|
|
4202
|
+
if (/allow.?list/i.test(node.name) || /allowed.?domain/i.test(node.name)) {
|
|
4203
|
+
markOriginCheckSeen();
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
};
|
|
4207
|
+
}
|
|
4208
|
+
};
|
|
4209
|
+
var openaiCuaNoDomainAllowlistRule = rule53;
|
|
4210
|
+
|
|
4211
|
+
// src/providers/openai-cua/rules/scroll-delta-default-zero.ts
|
|
4212
|
+
var VERTICAL_DELTA_NAME_PATTERN = /^(dy|delta_?y|scroll_?y)$/i;
|
|
4213
|
+
var rule54 = {
|
|
4214
|
+
meta: {
|
|
4215
|
+
type: "problem",
|
|
4216
|
+
docs: {
|
|
4217
|
+
description: "A missing vertical scroll delta must default to 0, not an arbitrary non-zero value",
|
|
4218
|
+
category: "correctness",
|
|
4219
|
+
rationale: "The reference scroll handler in OpenAI's Computer Use guide defaults a missing scroll delta to 0 on both axes. Defaulting the vertical delta to any other value injects an unintended scroll the model never asked for whenever it omits that field, desyncing the model's mental model of the page from the page's actual state on the next turn.",
|
|
4220
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4221
|
+
recommended: true
|
|
4222
|
+
},
|
|
4223
|
+
messages: {
|
|
4224
|
+
nonZeroDefault: "A missing vertical scroll delta defaults to {{value}} here instead of 0, injecting an unintended scroll the model never requested."
|
|
4225
|
+
}
|
|
4226
|
+
},
|
|
4227
|
+
create(context) {
|
|
4228
|
+
function propName2(node) {
|
|
4229
|
+
if (!node) return void 0;
|
|
4230
|
+
if (node.type === "Identifier") return node.name;
|
|
4231
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4232
|
+
return void 0;
|
|
4233
|
+
}
|
|
4234
|
+
function targetName(node) {
|
|
4235
|
+
if (node?.type === "Identifier") return node.name;
|
|
4236
|
+
if (node?.type === "MemberExpression") return propName2(node.property);
|
|
4237
|
+
return void 0;
|
|
4238
|
+
}
|
|
4239
|
+
function isNonZeroNumberLiteral(node) {
|
|
4240
|
+
return node?.type === "Literal" && typeof node.value === "number" && node.value !== 0;
|
|
4241
|
+
}
|
|
4242
|
+
function reportIfBadDefault(targetNode, valueNode, reportNode) {
|
|
4243
|
+
const name = targetName(targetNode);
|
|
4244
|
+
if (!name || !VERTICAL_DELTA_NAME_PATTERN.test(name)) return;
|
|
4245
|
+
if (!isNonZeroNumberLiteral(valueNode)) return;
|
|
4246
|
+
context.report({ node: reportNode, messageId: "nonZeroDefault", data: { value: String(valueNode.value) } });
|
|
4247
|
+
}
|
|
4248
|
+
function findDirectAssignment(stmt) {
|
|
4249
|
+
let inner = stmt;
|
|
4250
|
+
if (inner?.type === "BlockStatement") {
|
|
4251
|
+
const exprStmts = (inner.body ?? []).filter((s) => s.type === "ExpressionStatement");
|
|
4252
|
+
if (exprStmts.length !== 1) return void 0;
|
|
4253
|
+
inner = exprStmts[0];
|
|
4254
|
+
}
|
|
4255
|
+
if (inner?.type !== "ExpressionStatement") return void 0;
|
|
4256
|
+
const expr = inner.expression;
|
|
4257
|
+
if (expr?.type !== "AssignmentExpression" || expr.operator !== "=") return void 0;
|
|
4258
|
+
return { target: expr.left, value: expr.right };
|
|
4259
|
+
}
|
|
4260
|
+
function undefinedCheckTargetName(test) {
|
|
4261
|
+
if (test?.type !== "BinaryExpression") return void 0;
|
|
4262
|
+
if (test.operator !== "===" && test.operator !== "==") return void 0;
|
|
4263
|
+
const sides = [test.left, test.right];
|
|
4264
|
+
const undefinedSide = sides.find(
|
|
4265
|
+
(s) => s?.type === "Identifier" && s.name === "undefined" || s?.type === "Literal" && s.value === null || s?.type === "UnaryExpression" && s.operator === "typeof"
|
|
4266
|
+
);
|
|
4267
|
+
const otherSide = sides.find((s) => s !== undefinedSide);
|
|
4268
|
+
if (!undefinedSide || !otherSide) return void 0;
|
|
4269
|
+
if (undefinedSide.type === "UnaryExpression") {
|
|
4270
|
+
return targetName(undefinedSide.argument);
|
|
4271
|
+
}
|
|
4272
|
+
return targetName(otherSide);
|
|
4273
|
+
}
|
|
4274
|
+
return {
|
|
4275
|
+
LogicalExpression(node) {
|
|
4276
|
+
if (node.operator !== "??") return;
|
|
4277
|
+
reportIfBadDefault(node.left, node.right, node);
|
|
4278
|
+
},
|
|
4279
|
+
IfStatement(node) {
|
|
4280
|
+
const name = undefinedCheckTargetName(node.test);
|
|
4281
|
+
if (!name) return;
|
|
4282
|
+
const assignment = findDirectAssignment(node.consequent);
|
|
4283
|
+
if (!assignment) return;
|
|
4284
|
+
reportIfBadDefault(assignment.target, assignment.value, node);
|
|
4285
|
+
}
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
};
|
|
4289
|
+
var openaiCuaScrollDeltaDefaultZeroRule = rule54;
|
|
4290
|
+
|
|
4291
|
+
// src/providers/openai-cua/rules/structured-step-metadata-not-text-json.ts
|
|
4292
|
+
var BRACE_SEARCH_METHODS = /* @__PURE__ */ new Set(["indexOf", "lastIndexOf"]);
|
|
4293
|
+
var SLICE_METHODS = /* @__PURE__ */ new Set(["slice", "substring", "substr"]);
|
|
4294
|
+
var rule55 = {
|
|
4295
|
+
meta: {
|
|
4296
|
+
type: "suggestion",
|
|
4297
|
+
docs: {
|
|
4298
|
+
description: "Step metadata must come from structured tool output, not brace-hunting in free text",
|
|
4299
|
+
category: "correctness",
|
|
4300
|
+
rationale: "Manually locating a JSON object inside a free-text model message (via indexOf/lastIndexOf plus a slice) is fragile by construction: it breaks if the model adds trailing commentary, wraps the JSON in a markdown fence, or reorders fields. The Responses API supports function tools and structured output specifically so required metadata is schema-validated by the API instead of regex/brace-scraped out of arbitrary text.",
|
|
4301
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4302
|
+
recommended: true
|
|
4303
|
+
},
|
|
4304
|
+
messages: {
|
|
4305
|
+
textJsonExtraction: "This function locates a JSON object in free text via indexOf/lastIndexOf and parses a slice of it \u2014 use a function tool or structured output instead of brace-hunting in the model's text message."
|
|
4306
|
+
}
|
|
4307
|
+
},
|
|
4308
|
+
create(context) {
|
|
4309
|
+
const stack = [];
|
|
4310
|
+
const states = /* @__PURE__ */ new Map();
|
|
4311
|
+
function ensureState(fn) {
|
|
4312
|
+
let s = states.get(fn);
|
|
4313
|
+
if (!s) {
|
|
4314
|
+
s = { sawBraceSearch: false, sawJsonParseOnSlice: false, reportNode: null };
|
|
4315
|
+
states.set(fn, s);
|
|
4316
|
+
}
|
|
4317
|
+
return s;
|
|
4318
|
+
}
|
|
4319
|
+
function top() {
|
|
4320
|
+
return stack[stack.length - 1];
|
|
4321
|
+
}
|
|
4322
|
+
function pushScope(node) {
|
|
4323
|
+
stack.push(node);
|
|
4324
|
+
ensureState(node);
|
|
4325
|
+
}
|
|
4326
|
+
function popScope() {
|
|
4327
|
+
stack.pop();
|
|
4328
|
+
}
|
|
4329
|
+
function isBraceSearchCall(node) {
|
|
4330
|
+
if (node?.type !== "CallExpression") return false;
|
|
4331
|
+
const callee = node.callee;
|
|
4332
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
4333
|
+
if (!BRACE_SEARCH_METHODS.has(callee.property?.name)) return false;
|
|
4334
|
+
const arg = node.arguments?.[0];
|
|
4335
|
+
return arg?.type === "Literal" && typeof arg.value === "string" && arg.value.includes("{");
|
|
4336
|
+
}
|
|
4337
|
+
function isJsonParseOnSliceCall(node) {
|
|
4338
|
+
if (node?.type !== "CallExpression") return false;
|
|
4339
|
+
const callee = node.callee;
|
|
4340
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
4341
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "JSON") return false;
|
|
4342
|
+
if (callee.property?.name !== "parse") return false;
|
|
4343
|
+
const arg = node.arguments?.[0];
|
|
4344
|
+
if (arg?.type !== "CallExpression") return false;
|
|
4345
|
+
const argCallee = arg.callee;
|
|
4346
|
+
return argCallee?.type === "MemberExpression" && SLICE_METHODS.has(argCallee.property?.name);
|
|
4347
|
+
}
|
|
4348
|
+
return {
|
|
4349
|
+
Program(node) {
|
|
4350
|
+
pushScope(node);
|
|
4351
|
+
},
|
|
4352
|
+
"Program:exit"() {
|
|
4353
|
+
for (const state of states.values()) {
|
|
4354
|
+
if (state.sawBraceSearch && state.sawJsonParseOnSlice) {
|
|
4355
|
+
context.report({ node: state.reportNode, messageId: "textJsonExtraction" });
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
},
|
|
4359
|
+
FunctionDeclaration(node) {
|
|
4360
|
+
pushScope(node);
|
|
4361
|
+
},
|
|
4362
|
+
"FunctionDeclaration:exit"() {
|
|
4363
|
+
popScope();
|
|
4364
|
+
},
|
|
4365
|
+
FunctionExpression(node) {
|
|
4366
|
+
pushScope(node);
|
|
4367
|
+
},
|
|
4368
|
+
"FunctionExpression:exit"() {
|
|
4369
|
+
popScope();
|
|
4370
|
+
},
|
|
4371
|
+
ArrowFunctionExpression(node) {
|
|
4372
|
+
pushScope(node);
|
|
4373
|
+
},
|
|
4374
|
+
"ArrowFunctionExpression:exit"() {
|
|
4375
|
+
popScope();
|
|
4376
|
+
},
|
|
4377
|
+
CallExpression(node) {
|
|
4378
|
+
const fn = top();
|
|
4379
|
+
if (!fn) return;
|
|
4380
|
+
const state = ensureState(fn);
|
|
4381
|
+
if (isBraceSearchCall(node)) {
|
|
4382
|
+
state.sawBraceSearch = true;
|
|
4383
|
+
if (!state.reportNode) state.reportNode = node;
|
|
4384
|
+
}
|
|
4385
|
+
if (isJsonParseOnSliceCall(node)) {
|
|
4386
|
+
state.sawJsonParseOnSlice = true;
|
|
4387
|
+
if (!state.reportNode) state.reportNode = node;
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
};
|
|
4391
|
+
}
|
|
4392
|
+
};
|
|
4393
|
+
var openaiCuaStructuredStepMetadataNotTextJsonRule = rule55;
|
|
4394
|
+
|
|
4395
|
+
// src/providers/openai-cua/rules/no-blind-safety-check-ack.ts
|
|
4396
|
+
var rule56 = {
|
|
4397
|
+
meta: {
|
|
4398
|
+
type: "suggestion",
|
|
4399
|
+
docs: {
|
|
4400
|
+
description: "Acknowledging pending safety checks must evaluate each check, not blanket-pass them",
|
|
4401
|
+
category: "correctness",
|
|
4402
|
+
rationale: "`acknowledged_safety_checks` exists specifically so a developer can selectively confirm checks they have actually reviewed \u2014 each entry carries an id/code/message. A filter that only checks the row's shape (e.g. that it is an object) and never inspects `.code` or `.message` acknowledges every check present, defeating the purpose of the field entirely.",
|
|
4403
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4404
|
+
recommended: true
|
|
4405
|
+
},
|
|
4406
|
+
messages: {
|
|
4407
|
+
blindAck: "This filter never inspects .code or .message on each safety check \u2014 it acknowledges every check present instead of evaluating it against a policy."
|
|
4408
|
+
}
|
|
4409
|
+
},
|
|
4410
|
+
create(context) {
|
|
4411
|
+
function propName2(node) {
|
|
4412
|
+
if (!node) return void 0;
|
|
4413
|
+
if (node.type === "Identifier") return node.name;
|
|
4414
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4415
|
+
return void 0;
|
|
4416
|
+
}
|
|
4417
|
+
function sourceLooksLikeSafetyChecks(node) {
|
|
4418
|
+
if (!node) return false;
|
|
4419
|
+
const name = propName2(node) ?? (node.type === "MemberExpression" ? propName2(node.property) : void 0);
|
|
4420
|
+
if (name && /safety.?check/i.test(name)) return true;
|
|
4421
|
+
if (node.type === "LogicalExpression") {
|
|
4422
|
+
return sourceLooksLikeSafetyChecks(node.left) || sourceLooksLikeSafetyChecks(node.right);
|
|
4423
|
+
}
|
|
4424
|
+
if (node.type === "MemberExpression") {
|
|
4425
|
+
return sourceLooksLikeSafetyChecks(node.object) || sourceLooksLikeSafetyChecks(node.property);
|
|
4426
|
+
}
|
|
4427
|
+
return false;
|
|
4428
|
+
}
|
|
4429
|
+
function referencesCodeOrMessage(node, depth = 0) {
|
|
4430
|
+
if (!node || typeof node !== "object" || depth > 10) return false;
|
|
4431
|
+
if (Array.isArray(node)) return node.some((n) => referencesCodeOrMessage(n, depth + 1));
|
|
4432
|
+
if (node.type === "MemberExpression") {
|
|
4433
|
+
const name = propName2(node.property);
|
|
4434
|
+
if (name === "code" || name === "message") return true;
|
|
4435
|
+
}
|
|
4436
|
+
for (const key of Object.keys(node)) {
|
|
4437
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
4438
|
+
const val = node[key];
|
|
4439
|
+
if (val && typeof val === "object") {
|
|
4440
|
+
if (referencesCodeOrMessage(val, depth + 1)) return true;
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
return false;
|
|
4444
|
+
}
|
|
4445
|
+
return {
|
|
4446
|
+
CallExpression(node) {
|
|
4447
|
+
const callee = node.callee;
|
|
4448
|
+
if (callee?.type !== "MemberExpression" || callee.property?.name !== "filter") return;
|
|
4449
|
+
if (!sourceLooksLikeSafetyChecks(callee.object)) return;
|
|
4450
|
+
const fn = node.arguments?.[0];
|
|
4451
|
+
if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return;
|
|
4452
|
+
if (!referencesCodeOrMessage(fn.body)) {
|
|
4453
|
+
context.report({ node, messageId: "blindAck" });
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
};
|
|
4457
|
+
}
|
|
4458
|
+
};
|
|
4459
|
+
var openaiCuaNoBlindSafetyCheckAckRule = rule56;
|
|
4460
|
+
|
|
4461
|
+
// src/providers/openai-cua/utils.ts
|
|
4462
|
+
function propName(node) {
|
|
4463
|
+
if (!node) return void 0;
|
|
4464
|
+
if (node.type === "Identifier") return node.name;
|
|
4465
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4466
|
+
return void 0;
|
|
4467
|
+
}
|
|
4468
|
+
function memberChainNames(node, names = []) {
|
|
4469
|
+
if (node?.type === "MemberExpression") {
|
|
4470
|
+
memberChainNames(node.object, names);
|
|
4471
|
+
const n = propName(node.property);
|
|
4472
|
+
if (n) names.push(n);
|
|
4473
|
+
} else if (node?.type === "Identifier") {
|
|
4474
|
+
names.push(node.name);
|
|
4475
|
+
}
|
|
4476
|
+
return names;
|
|
4477
|
+
}
|
|
4478
|
+
function isResponsesCreateCall(node) {
|
|
4479
|
+
if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
|
|
4480
|
+
const chain = memberChainNames(node.callee);
|
|
4481
|
+
return chain.length >= 2 && chain[chain.length - 1] === "create" && chain[chain.length - 2] === "responses";
|
|
4482
|
+
}
|
|
4483
|
+
function findResponsesCreateCall(node, depth = 0) {
|
|
4484
|
+
if (!node || typeof node !== "object" || depth > 12) return null;
|
|
4485
|
+
if (Array.isArray(node)) {
|
|
4486
|
+
for (const n of node) {
|
|
4487
|
+
const found = findResponsesCreateCall(n, depth + 1);
|
|
4488
|
+
if (found) return found;
|
|
4489
|
+
}
|
|
4490
|
+
return null;
|
|
4491
|
+
}
|
|
4492
|
+
if (isResponsesCreateCall(node)) return node;
|
|
4493
|
+
for (const key of Object.keys(node)) {
|
|
4494
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
4495
|
+
const val = node[key];
|
|
4496
|
+
if (val && typeof val === "object") {
|
|
4497
|
+
const found = findResponsesCreateCall(val, depth + 1);
|
|
4498
|
+
if (found) return found;
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
return null;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
// src/providers/openai-cua/rules/retry-transient-turn-errors.ts
|
|
4505
|
+
var rule57 = {
|
|
4506
|
+
meta: {
|
|
4507
|
+
type: "problem",
|
|
4508
|
+
docs: {
|
|
4509
|
+
description: "A responses.create() call must be retried at the turn level on transient errors",
|
|
4510
|
+
category: "reliability",
|
|
4511
|
+
rationale: "The SDK's own internal retry budget (DEFAULT_MAX_RETRIES) is finite. Treating any exception that survives it as fatal for the whole run \u2014 instead of retrying that one turn with backoff \u2014 means a single transient RateLimitError/APIConnectionError/InternalServerError can discard a long-running, multi-turn, expensive agent loop that may have already executed dozens of prior turns.",
|
|
4512
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4513
|
+
recommended: true
|
|
4514
|
+
},
|
|
4515
|
+
messages: {
|
|
4516
|
+
noTurnRetry: "This responses.create() call has no turn-level retry \u2014 neither a surrounding loop nor a retry call in the catch block \u2014 so any error beyond the SDK's own retry budget ends the entire run."
|
|
4517
|
+
}
|
|
4518
|
+
},
|
|
4519
|
+
create(context) {
|
|
4520
|
+
let loopDepth = 0;
|
|
4521
|
+
function propName2(node) {
|
|
4522
|
+
if (!node) return void 0;
|
|
4523
|
+
if (node.type === "Identifier") return node.name;
|
|
4524
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4525
|
+
return void 0;
|
|
4526
|
+
}
|
|
4527
|
+
function catchHasRetryCall(node, depth = 0) {
|
|
4528
|
+
if (!node || typeof node !== "object" || depth > 12) return false;
|
|
4529
|
+
if (Array.isArray(node)) return node.some((n) => catchHasRetryCall(n, depth + 1));
|
|
4530
|
+
if (node.type === "CallExpression") {
|
|
4531
|
+
const callee = node.callee;
|
|
4532
|
+
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" ? propName2(callee.property) : void 0;
|
|
4533
|
+
if (name && /retry/i.test(name)) return true;
|
|
4534
|
+
}
|
|
4535
|
+
for (const key of Object.keys(node)) {
|
|
4536
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
4537
|
+
const val = node[key];
|
|
4538
|
+
if (val && typeof val === "object") {
|
|
4539
|
+
if (catchHasRetryCall(val, depth + 1)) return true;
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
return false;
|
|
4543
|
+
}
|
|
4544
|
+
return {
|
|
4545
|
+
ForStatement() {
|
|
4546
|
+
loopDepth += 1;
|
|
4547
|
+
},
|
|
4548
|
+
"ForStatement:exit"() {
|
|
4549
|
+
loopDepth -= 1;
|
|
4550
|
+
},
|
|
4551
|
+
WhileStatement() {
|
|
4552
|
+
loopDepth += 1;
|
|
4553
|
+
},
|
|
4554
|
+
"WhileStatement:exit"() {
|
|
4555
|
+
loopDepth -= 1;
|
|
4556
|
+
},
|
|
4557
|
+
DoWhileStatement() {
|
|
4558
|
+
loopDepth += 1;
|
|
4559
|
+
},
|
|
4560
|
+
"DoWhileStatement:exit"() {
|
|
4561
|
+
loopDepth -= 1;
|
|
4562
|
+
},
|
|
4563
|
+
ForOfStatement() {
|
|
4564
|
+
loopDepth += 1;
|
|
4565
|
+
},
|
|
4566
|
+
"ForOfStatement:exit"() {
|
|
4567
|
+
loopDepth -= 1;
|
|
4568
|
+
},
|
|
4569
|
+
ForInStatement() {
|
|
4570
|
+
loopDepth += 1;
|
|
4571
|
+
},
|
|
4572
|
+
"ForInStatement:exit"() {
|
|
4573
|
+
loopDepth -= 1;
|
|
4574
|
+
},
|
|
4575
|
+
TryStatement(node) {
|
|
4576
|
+
const createCall = findResponsesCreateCall(node.block);
|
|
4577
|
+
if (!createCall) return;
|
|
4578
|
+
if (loopDepth > 0) return;
|
|
4579
|
+
const handler = node.handler;
|
|
4580
|
+
if (!handler) return;
|
|
4581
|
+
if (catchHasRetryCall(handler.body)) return;
|
|
4582
|
+
context.report({ node: handler, messageId: "noTurnRetry" });
|
|
4583
|
+
}
|
|
4584
|
+
};
|
|
4585
|
+
}
|
|
4586
|
+
};
|
|
4587
|
+
var openaiCuaRetryTransientTurnErrorsRule = rule57;
|
|
4588
|
+
|
|
4589
|
+
// src/providers/openai-cua/rules/check-response-status-incomplete.ts
|
|
4590
|
+
var rule58 = {
|
|
4591
|
+
meta: {
|
|
4592
|
+
type: "problem",
|
|
4593
|
+
docs: {
|
|
4594
|
+
description: 'A completion check must rule out response.status === "incomplete" before reporting success',
|
|
4595
|
+
category: "reliability",
|
|
4596
|
+
rationale: 'The Responses API can return status "incomplete" with zero visible message output when generation is cut off by the token budget \u2014 the same shape ("no computer_call, no message") a purely structural completion check treats as "the model voluntarily finished." Without checking response.status, a token-budget truncation can be silently reported as a successful task completion.',
|
|
4597
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4598
|
+
recommended: true
|
|
4599
|
+
},
|
|
4600
|
+
messages: {
|
|
4601
|
+
missingIncompleteCheck: 'This treats the absence of tool calls as a successful completion, but never checks response.status === "incomplete" \u2014 a token-budget truncation can be misreported as success.'
|
|
4602
|
+
}
|
|
4603
|
+
},
|
|
4604
|
+
create(context) {
|
|
4605
|
+
const stack = [];
|
|
4606
|
+
const states = /* @__PURE__ */ new Map();
|
|
4607
|
+
function ensureState(fn) {
|
|
4608
|
+
let s = states.get(fn);
|
|
4609
|
+
if (!s) {
|
|
4610
|
+
s = { sawCreateCall: false, sawSuccessReturn: false, successNode: null, sawStatusCheck: false };
|
|
4611
|
+
states.set(fn, s);
|
|
4612
|
+
}
|
|
4613
|
+
return s;
|
|
4614
|
+
}
|
|
4615
|
+
function top() {
|
|
4616
|
+
return stack[stack.length - 1];
|
|
4617
|
+
}
|
|
4618
|
+
function pushScope(node) {
|
|
4619
|
+
stack.push(node);
|
|
4620
|
+
ensureState(node);
|
|
4621
|
+
}
|
|
4622
|
+
function popScope() {
|
|
4623
|
+
stack.pop();
|
|
4624
|
+
}
|
|
4625
|
+
function propName2(node) {
|
|
4626
|
+
if (!node) return void 0;
|
|
4627
|
+
if (node.type === "Identifier") return node.name;
|
|
4628
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4629
|
+
return void 0;
|
|
4630
|
+
}
|
|
4631
|
+
function isSuccessObjectLiteral(node) {
|
|
4632
|
+
if (node?.type !== "ObjectExpression") return false;
|
|
4633
|
+
return (node.properties ?? []).some((p) => {
|
|
4634
|
+
if (p?.type !== "Property") return false;
|
|
4635
|
+
const keyName = propName2(p.key);
|
|
4636
|
+
return (keyName === "success" || keyName === "completed") && p.value?.type === "Literal" && p.value.value === true;
|
|
4637
|
+
});
|
|
4638
|
+
}
|
|
4639
|
+
return {
|
|
4640
|
+
Program(node) {
|
|
4641
|
+
pushScope(node);
|
|
4642
|
+
},
|
|
4643
|
+
"Program:exit"() {
|
|
4644
|
+
for (const state of states.values()) {
|
|
4645
|
+
if (state.sawCreateCall && state.sawSuccessReturn && !state.sawStatusCheck) {
|
|
4646
|
+
context.report({ node: state.successNode, messageId: "missingIncompleteCheck" });
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
},
|
|
4650
|
+
FunctionDeclaration(node) {
|
|
4651
|
+
pushScope(node);
|
|
4652
|
+
},
|
|
4653
|
+
"FunctionDeclaration:exit"() {
|
|
4654
|
+
popScope();
|
|
4655
|
+
},
|
|
4656
|
+
FunctionExpression(node) {
|
|
4657
|
+
pushScope(node);
|
|
4658
|
+
},
|
|
4659
|
+
"FunctionExpression:exit"() {
|
|
4660
|
+
popScope();
|
|
4661
|
+
},
|
|
4662
|
+
ArrowFunctionExpression(node) {
|
|
4663
|
+
pushScope(node);
|
|
4664
|
+
},
|
|
4665
|
+
"ArrowFunctionExpression:exit"() {
|
|
4666
|
+
popScope();
|
|
4667
|
+
},
|
|
4668
|
+
CallExpression(node) {
|
|
4669
|
+
if (findResponsesCreateCall(node)) {
|
|
4670
|
+
const fn = top();
|
|
4671
|
+
if (fn) ensureState(fn).sawCreateCall = true;
|
|
4672
|
+
}
|
|
4673
|
+
},
|
|
4674
|
+
ObjectExpression(node) {
|
|
4675
|
+
if (isSuccessObjectLiteral(node)) {
|
|
4676
|
+
const fn = top();
|
|
4677
|
+
if (fn) {
|
|
4678
|
+
const state = ensureState(fn);
|
|
4679
|
+
if (!state.sawSuccessReturn) {
|
|
4680
|
+
state.sawSuccessReturn = true;
|
|
4681
|
+
state.successNode = node;
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
},
|
|
4686
|
+
BinaryExpression(node) {
|
|
4687
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
4688
|
+
const sides = [node.left, node.right];
|
|
4689
|
+
const statusSide = sides.find((s) => s?.type === "MemberExpression" && propName2(s.property) === "status");
|
|
4690
|
+
const valueSide = sides.find((s) => s !== statusSide);
|
|
4691
|
+
if (statusSide && valueSide?.type === "Literal" && valueSide.value === "incomplete") {
|
|
4692
|
+
const fn = top();
|
|
4693
|
+
if (fn) ensureState(fn).sawStatusCheck = true;
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
};
|
|
4697
|
+
}
|
|
4698
|
+
};
|
|
4699
|
+
var openaiCuaCheckResponseStatusIncompleteRule = rule58;
|
|
4700
|
+
|
|
4701
|
+
// src/providers/openai-cua/rules/set-safety-identifier.ts
|
|
4702
|
+
var rule59 = {
|
|
4703
|
+
meta: {
|
|
4704
|
+
type: "problem",
|
|
4705
|
+
docs: {
|
|
4706
|
+
description: "responses.create() must set safety_identifier for per-end-user policy attribution",
|
|
4707
|
+
category: "integration",
|
|
4708
|
+
rationale: "OpenAI documents that a stable per-end-user safety_identifier lets policy violations be attributed and acted on per end user. Without one on a multi-tenant integration routing many customers through a single shared API key, a single customer triggering a high-confidence policy-violation heuristic can result in access being temporarily revoked for the entire organization, not just the offending identifier.",
|
|
4709
|
+
docsUrl: "https://help.openai.com/en/articles/5428082-how-to-incorporate-a-safety-identifier",
|
|
4710
|
+
recommended: true
|
|
4711
|
+
},
|
|
4712
|
+
messages: {
|
|
4713
|
+
missingSafetyIdentifier: "This responses.create() call sets no safety_identifier (or user) \u2014 a policy violation here could be attributed to the entire shared API key instead of one end user."
|
|
4714
|
+
}
|
|
4715
|
+
},
|
|
4716
|
+
create(context) {
|
|
4717
|
+
function propName2(node) {
|
|
4718
|
+
if (!node) return void 0;
|
|
4719
|
+
if (node.type === "Identifier") return node.name;
|
|
4720
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4721
|
+
return void 0;
|
|
4722
|
+
}
|
|
4723
|
+
return {
|
|
4724
|
+
CallExpression(node) {
|
|
4725
|
+
if (!isResponsesCreateCall(node)) return;
|
|
4726
|
+
const optionsArg = node.arguments?.[0];
|
|
4727
|
+
if (optionsArg?.type !== "ObjectExpression") return;
|
|
4728
|
+
const hasIdentifier = (optionsArg.properties ?? []).some((p) => {
|
|
4729
|
+
if (p?.type !== "Property") return false;
|
|
4730
|
+
const keyName = propName2(p.key);
|
|
4731
|
+
return keyName === "safety_identifier" || keyName === "user";
|
|
4732
|
+
});
|
|
4733
|
+
if (!hasIdentifier) {
|
|
4734
|
+
context.report({ node, messageId: "missingSafetyIdentifier" });
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
};
|
|
4738
|
+
}
|
|
4739
|
+
};
|
|
4740
|
+
var openaiCuaSetSafetyIdentifierRule = rule59;
|
|
4741
|
+
|
|
4742
|
+
// src/plugin/index.ts
|
|
4743
|
+
var plugin = {
|
|
4744
|
+
meta: { name: PLUGIN_NAME, version: "0.0.1" },
|
|
4745
|
+
rules: {
|
|
4746
|
+
"resend-webhook-signature": resendWebhookSignatureRule,
|
|
4747
|
+
"resend-api-key-hardcoded": resendApiKeyHardcodedRule,
|
|
4748
|
+
"resend-api-key-in-client-bundle": resendApiKeyInClientBundleRule,
|
|
4749
|
+
"resend-marketing-via-batch-send": resendMarketingViaBatchSendRule,
|
|
4750
|
+
"resend-marketing-missing-unsubscribe": resendMarketingMissingUnsubscribeRule,
|
|
4751
|
+
"resend-test-domain-in-production-path": resendTestDomainInProductionPathRule,
|
|
4752
|
+
"resend-from-address-not-friendly-format": resendFromAddressNotFriendlyFormatRule,
|
|
4753
|
+
"resend-batch-size-not-enforced": resendBatchSizeNotEnforcedRule,
|
|
4754
|
+
"resend-missing-idempotency-key": resendMissingIdempotencyKeyRule,
|
|
4755
|
+
"resend-no-error-code-mapping": resendNoErrorCodeMappingRule,
|
|
4756
|
+
"resend-webhook-no-idempotency": resendWebhookNoIdempotencyRule,
|
|
4757
|
+
"resend-missing-tags": resendMissingTagsRule,
|
|
4758
|
+
"resend-request-id-not-logged": resendRequestIdNotLoggedRule,
|
|
4759
|
+
"supabase-scope-queries-by-tenant-column": supabaseScopeQueriesByTenantColumnRule,
|
|
4760
|
+
"supabase-validate-uuid-columns": supabaseValidateUuidColumnsRule,
|
|
4761
|
+
"supabase-order-by-timestamp-not-identity": supabaseOrderByTimestampNotIdentityRule,
|
|
4762
|
+
"supabase-consistent-input-length-limits": supabaseConsistentInputLengthLimitsRule,
|
|
4763
|
+
"supabase-idempotent-mutations": supabaseIdempotentMutationsRule,
|
|
4764
|
+
"supabase-fail-fast-env-validation": supabaseFailFastEnvValidationRule,
|
|
4765
|
+
"supabase-no-user-metadata-authz": supabaseNoUserMetadataAuthzRule,
|
|
4766
|
+
"supabase-single-without-error-check": supabaseSingleWithoutErrorCheckRule,
|
|
4767
|
+
"supabase-non-atomic-replace-pattern": supabaseNonAtomicReplacePatternRule,
|
|
4768
|
+
"supabase-unchecked-mutation-error": supabaseUncheckedMutationErrorRule,
|
|
4769
|
+
"supabase-realtime-missing-filter": supabaseRealtimeMissingFilterRule,
|
|
4770
|
+
"supabase-storage-error-not-surfaced": supabaseStorageErrorNotSurfacedRule,
|
|
4771
|
+
"auth0-required-audience-validation": auth0RequiredAudienceValidationRule,
|
|
4772
|
+
"auth0-no-account-link-without-verified-email": auth0NoAccountLinkWithoutVerifiedEmailRule,
|
|
4773
|
+
"auth0-dead-claim-verification-check": auth0DeadClaimVerificationCheckRule,
|
|
4774
|
+
"auth0-jwks-refresh-on-unknown-kid": auth0JwksRefreshOnUnknownKidRule,
|
|
4775
|
+
"firebase-missing-app-check": firebaseMissingAppCheckRule,
|
|
4776
|
+
"firebase-unhandled-auth-popup-rejection": firebaseUnhandledAuthPopupRejectionRule,
|
|
4777
|
+
"firebase-rtdb-list-read-for-single-item": firebaseRtdbListReadForSingleItemRule,
|
|
4778
|
+
"firebase-unvalidated-external-data-to-rtdb": firebaseUnvalidatedExternalDataToRtdbRule,
|
|
4779
|
+
"firebase-rtdb-batch-write-not-atomic": firebaseRtdbBatchWriteNotAtomicRule,
|
|
4780
|
+
"firebase-rtdb-listener-error-not-handled": firebaseRtdbListenerErrorNotHandledRule,
|
|
4781
|
+
"firebase-effect-deps-whole-user-object": firebaseEffectDepsWholeUserObjectRule,
|
|
4782
|
+
"firebase-rtdb-write-promise-not-handled": firebaseRtdbWritePromiseNotHandledRule,
|
|
4783
|
+
"lovable-no-client-side-secret-fetch": lovableNoClientSideSecretFetchRule,
|
|
4784
|
+
"lovable-paid-flag-without-edge-function": lovablePaidFlagWithoutEdgeFunctionRule,
|
|
4785
|
+
"lovable-expiry-column-never-checked": lovableExpiryColumnNeverCheckedRule,
|
|
4786
|
+
"lovable-silent-catch-on-provider-call": lovableSilentCatchOnProviderCallRule,
|
|
4787
|
+
"browserbase-no-conditional-authz-on-anonymous-user": browserbaseNoConditionalAuthzOnAnonymousUserRule,
|
|
4788
|
+
"browserbase-no-connect-url-in-api-response": browserbaseNoConnectUrlInApiResponseRule,
|
|
4789
|
+
"browserbase-session-id-requires-ownership-check": browserbaseSessionIdRequiresOwnershipCheckRule,
|
|
4790
|
+
"browserbase-no-concurrent-shared-context": browserbaseNoConcurrentSharedContextRule,
|
|
4791
|
+
"browserbase-mobile-device-requires-os-setting": browserbaseMobileDeviceRequiresOsSettingRule,
|
|
4792
|
+
"browserbase-use-typed-exception-status-not-substring": browserbaseUseTypedExceptionStatusNotSubstringRule,
|
|
4793
|
+
"browserbase-release-session-on-connect-failure": browserbaseReleaseSessionOnConnectFailureRule,
|
|
4794
|
+
"browserbase-dont-stack-custom-retry-on-sdk-retry": browserbaseDontStackCustomRetryOnSdkRetryRule,
|
|
4795
|
+
"browserbase-no-overbroad-error-substring-match": browserbaseNoOverbroadErrorSubstringMatchRule,
|
|
4796
|
+
"browserbase-use-sdk-not-raw-requests": browserbaseUseSdkNotRawRequestsRule,
|
|
4797
|
+
"browserbase-centralize-request-release": browserbaseCentralizeRequestReleaseRule,
|
|
4798
|
+
"openai-cua-no-domain-allowlist": openaiCuaNoDomainAllowlistRule,
|
|
4799
|
+
"openai-cua-scroll-delta-default-zero": openaiCuaScrollDeltaDefaultZeroRule,
|
|
4800
|
+
"openai-cua-structured-step-metadata-not-text-json": openaiCuaStructuredStepMetadataNotTextJsonRule,
|
|
4801
|
+
"openai-cua-no-blind-safety-check-ack": openaiCuaNoBlindSafetyCheckAckRule,
|
|
4802
|
+
"openai-cua-retry-transient-turn-errors": openaiCuaRetryTransientTurnErrorsRule,
|
|
4803
|
+
"openai-cua-check-response-status-incomplete": openaiCuaCheckResponseStatusIncompleteRule,
|
|
4804
|
+
"openai-cua-set-safety-identifier": openaiCuaSetSafetyIdentifierRule
|
|
4805
|
+
}
|
|
4806
|
+
};
|
|
4807
|
+
var plugin_default = plugin;
|
|
4808
|
+
export {
|
|
4809
|
+
plugin_default as default,
|
|
4810
|
+
plugin
|
|
4811
|
+
};
|
|
4812
|
+
//# sourceMappingURL=plugin.js.map
|