@api-doctor/cli 0.0.4 → 0.0.6
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/README.md +5 -2
- package/dist/cli.cjs +2889 -480
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +2889 -480
- package/dist/cli.mjs.map +1 -1
- package/dist/plugin.d.ts +538 -35
- package/dist/plugin.js +2413 -263
- package/dist/plugin.js.map +1 -1
- package/package.json +2 -1
package/dist/plugin.js
CHANGED
|
@@ -11,7 +11,7 @@ var rule = {
|
|
|
11
11
|
cwe: "CWE-345",
|
|
12
12
|
owasp: "API2:2023 Broken Authentication",
|
|
13
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/
|
|
14
|
+
docsUrl: "https://resend.com/docs/webhooks/verify-webhooks-requests",
|
|
15
15
|
recommended: true
|
|
16
16
|
},
|
|
17
17
|
messages: {
|
|
@@ -88,6 +88,15 @@ var rule = {
|
|
|
88
88
|
const obj = callee.object;
|
|
89
89
|
return obj?.type === "Identifier" && (obj.name === "req" || obj.name === "request");
|
|
90
90
|
}
|
|
91
|
+
function isReqTextCall(n) {
|
|
92
|
+
if (n?.type !== "CallExpression") return false;
|
|
93
|
+
const callee = n.callee;
|
|
94
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
95
|
+
const prop = callee.property;
|
|
96
|
+
if (prop?.type !== "Identifier" || prop.name !== "text") return false;
|
|
97
|
+
const obj = callee.object;
|
|
98
|
+
return obj?.type === "Identifier" && (obj.name === "req" || obj.name === "request");
|
|
99
|
+
}
|
|
91
100
|
function isBodyMember(n) {
|
|
92
101
|
if (n?.type !== "MemberExpression") return false;
|
|
93
102
|
const prop = n.property;
|
|
@@ -112,6 +121,17 @@ var rule = {
|
|
|
112
121
|
const obj = callee.object;
|
|
113
122
|
return obj?.type === "Identifier" && svixImports.has(obj.name);
|
|
114
123
|
}
|
|
124
|
+
function isResendWebhooksVerifyCall(n) {
|
|
125
|
+
if (n?.type !== "CallExpression") return false;
|
|
126
|
+
const callee = n.callee;
|
|
127
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
128
|
+
const prop = callee.property;
|
|
129
|
+
if (prop?.type !== "Identifier" || prop.name !== "verify") return false;
|
|
130
|
+
const obj = callee.object;
|
|
131
|
+
if (obj?.type !== "MemberExpression") return false;
|
|
132
|
+
const webhooksProp = obj.property;
|
|
133
|
+
return webhooksProp?.type === "Identifier" && webhooksProp.name === "webhooks";
|
|
134
|
+
}
|
|
115
135
|
function recordFirst(posKey, handler, pos) {
|
|
116
136
|
const existing = handler[posKey];
|
|
117
137
|
if (!existing) {
|
|
@@ -126,6 +146,11 @@ var rule = {
|
|
|
126
146
|
ImportDeclaration(node) {
|
|
127
147
|
const importSource = node?.source?.value;
|
|
128
148
|
if (importSource === "resend") importsResend = true;
|
|
149
|
+
for (const s of node.specifiers ?? []) {
|
|
150
|
+
if ((s?.type === "ImportSpecifier" || s?.type === "ImportDefaultSpecifier") && s.local?.type === "Identifier" && /^resend$/i.test(s.local.name)) {
|
|
151
|
+
importsResend = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
129
154
|
if (importSource === "svix") {
|
|
130
155
|
for (const s of node.specifiers ?? []) {
|
|
131
156
|
if (s?.type === "ImportSpecifier" && s.local?.type === "Identifier") {
|
|
@@ -147,8 +172,10 @@ var rule = {
|
|
|
147
172
|
for (const handler of postHandlers) {
|
|
148
173
|
if (!within(handler, node)) continue;
|
|
149
174
|
if (isReqJsonCall(node)) recordFirst("firstBodyPos", handler, pos);
|
|
175
|
+
if (isReqTextCall(node)) recordFirst("firstRawBodyPos", handler, pos);
|
|
150
176
|
if (isSvixVerifyCall(node)) recordFirst("firstVerifyPos", handler, pos);
|
|
151
177
|
if (isCryptoCreateHmacCall(node)) recordFirst("firstVerifyPos", handler, pos);
|
|
178
|
+
if (isResendWebhooksVerifyCall(node)) recordFirst("firstVerifyPos", handler, pos);
|
|
152
179
|
}
|
|
153
180
|
},
|
|
154
181
|
MemberExpression(node) {
|
|
@@ -163,6 +190,8 @@ var rule = {
|
|
|
163
190
|
"Program:exit"() {
|
|
164
191
|
if (!importsResend) return;
|
|
165
192
|
for (const handler of postHandlers) {
|
|
193
|
+
const consumesRequest = handler.firstBodyPos || handler.firstRawBodyPos;
|
|
194
|
+
if (!consumesRequest) continue;
|
|
166
195
|
if (!handler.firstVerifyPos) {
|
|
167
196
|
context.report({ node: handler.node, messageId: "missingVerification" });
|
|
168
197
|
continue;
|
|
@@ -734,7 +763,7 @@ var rule11 = {
|
|
|
734
763
|
description: "Resend webhook handlers should deduplicate retried events",
|
|
735
764
|
category: "reliability",
|
|
736
765
|
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/
|
|
766
|
+
docsUrl: "https://resend.com/docs/webhooks/introduction",
|
|
738
767
|
recommended: true
|
|
739
768
|
},
|
|
740
769
|
messages: {
|
|
@@ -1036,12 +1065,12 @@ var rule14 = {
|
|
|
1036
1065
|
docs: {
|
|
1037
1066
|
description: "Supabase queries that select a tenant column must filter by it",
|
|
1038
1067
|
category: "correctness",
|
|
1039
|
-
rationale: "A column like session_id or user_id
|
|
1068
|
+
rationale: "A column like session_id or user_id being selected signals intent to scope rows to one caller, but selecting it is not the same as filtering by it. If RLS is not enabled on the table, the unfiltered query returns every row for every tenant \u2014 a per-user feed becomes a shared, cross-user one. Even when RLS scopes the rows server-side, an explicit .eq()/.match()/.filter() is defense-in-depth (a dropped policy no longer leaks data) and avoids fetching rows the page never uses.",
|
|
1040
1069
|
docsUrl: "https://supabase.com/docs/reference/javascript/eq",
|
|
1041
1070
|
recommended: true
|
|
1042
1071
|
},
|
|
1043
1072
|
messages: {
|
|
1044
|
-
missingTenantFilter:
|
|
1073
|
+
missingTenantFilter: `This query selects "{{column}}" but never filters by it \u2014 without RLS on this table that is every tenant's rows. Add .eq("{{column}}", ...) (or .match()/.filter()) to scope results to the caller.`
|
|
1045
1074
|
},
|
|
1046
1075
|
schema: []
|
|
1047
1076
|
},
|
|
@@ -1313,7 +1342,8 @@ function objectHasIdempotencyKey(objectExpression) {
|
|
|
1313
1342
|
return (objectExpression.properties ?? []).some((p) => {
|
|
1314
1343
|
if (p?.type !== "Property") return false;
|
|
1315
1344
|
const name = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : void 0;
|
|
1316
|
-
|
|
1345
|
+
if (typeof name !== "string") return false;
|
|
1346
|
+
return /idempot|dedupe/i.test(name) || /^(id|uuid)$/i.test(name) || /_(key|uuid)$/i.test(name);
|
|
1317
1347
|
});
|
|
1318
1348
|
}
|
|
1319
1349
|
function insertPayloadHasIdempotencyKey(arg) {
|
|
@@ -1329,12 +1359,12 @@ var rule18 = {
|
|
|
1329
1359
|
docs: {
|
|
1330
1360
|
description: "Supabase insert calls should be retry-safe via an idempotency key",
|
|
1331
1361
|
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
|
|
1362
|
+
rationale: 'Nothing prevents a duplicate row if the client fetch behind an insert is retried (flaky network, double-click, browser replay) \u2014 no unique key is visible in the payload, and no upsert semantics. Include a client-generated unique key (an id, or a *_key field backed by a unique constraint), or use .upsert(..., { onConflict: "<key column>" }). This is general retry-safety practice rather than a documented Supabase requirement, hence info severity.',
|
|
1333
1363
|
docsUrl: "https://supabase.com/docs/reference/javascript/upsert",
|
|
1334
1364
|
recommended: true
|
|
1335
1365
|
},
|
|
1336
1366
|
messages: {
|
|
1337
|
-
missingIdempotencyKey: 'This insert has no idempotency
|
|
1367
|
+
missingIdempotencyKey: 'This insert payload has no unique/idempotency key field, so a retried request can create a duplicate row. Include a client-generated key, or use .upsert(..., { onConflict: "<key column>" }).'
|
|
1338
1368
|
},
|
|
1339
1369
|
schema: []
|
|
1340
1370
|
},
|
|
@@ -1383,7 +1413,7 @@ var rule19 = {
|
|
|
1383
1413
|
docs: {
|
|
1384
1414
|
description: "createClient must fail fast when required env vars are missing",
|
|
1385
1415
|
category: "reliability",
|
|
1386
|
-
rationale:
|
|
1416
|
+
rationale: `createClient throws immediately when an argument is missing, but with the SDK's message ("supabaseKey is required.") \u2014 it names the SDK parameter, not your env var. An explicit presence check produces an error naming the exact variable to set, which matters most with Next.js NEXT_PUBLIC_* inlining where the fix is a rebuild with the var present, not a server restart.`,
|
|
1387
1417
|
docsUrl: "https://supabase.com/docs/reference/javascript/initializing",
|
|
1388
1418
|
recommended: true
|
|
1389
1419
|
},
|
|
@@ -1393,7 +1423,11 @@ var rule19 = {
|
|
|
1393
1423
|
schema: []
|
|
1394
1424
|
},
|
|
1395
1425
|
create(context) {
|
|
1396
|
-
|
|
1426
|
+
const FACTORY_IMPORTS = {
|
|
1427
|
+
"@supabase/supabase-js": ["createClient"],
|
|
1428
|
+
"@supabase/ssr": ["createBrowserClient", "createServerClient"]
|
|
1429
|
+
};
|
|
1430
|
+
const factoryLocalNames = /* @__PURE__ */ new Set();
|
|
1397
1431
|
const envVarOfVariable = /* @__PURE__ */ new Map();
|
|
1398
1432
|
const validatedVarNames = /* @__PURE__ */ new Set();
|
|
1399
1433
|
const validatedEnvNames = /* @__PURE__ */ new Set();
|
|
@@ -1427,10 +1461,11 @@ var rule19 = {
|
|
|
1427
1461
|
}
|
|
1428
1462
|
return {
|
|
1429
1463
|
ImportDeclaration(node) {
|
|
1430
|
-
|
|
1464
|
+
const factories = FACTORY_IMPORTS[node.source?.value];
|
|
1465
|
+
if (!factories) return;
|
|
1431
1466
|
for (const s of node.specifiers ?? []) {
|
|
1432
|
-
if (s?.type === "ImportSpecifier" && s.imported?.type === "Identifier" && s.imported.name
|
|
1433
|
-
|
|
1467
|
+
if (s?.type === "ImportSpecifier" && s.imported?.type === "Identifier" && factories.includes(s.imported.name) && s.local?.type === "Identifier") {
|
|
1468
|
+
factoryLocalNames.add(s.local.name);
|
|
1434
1469
|
}
|
|
1435
1470
|
}
|
|
1436
1471
|
},
|
|
@@ -1444,8 +1479,8 @@ var rule19 = {
|
|
|
1444
1479
|
collectGuardTargets(node.test);
|
|
1445
1480
|
},
|
|
1446
1481
|
CallExpression(node) {
|
|
1447
|
-
if (
|
|
1448
|
-
if (node.callee?.type !== "Identifier" || node.callee.name
|
|
1482
|
+
if (factoryLocalNames.size === 0) return;
|
|
1483
|
+
if (node.callee?.type !== "Identifier" || !factoryLocalNames.has(node.callee.name)) return;
|
|
1449
1484
|
const missing = [];
|
|
1450
1485
|
for (const rawArg of node.arguments ?? []) {
|
|
1451
1486
|
const arg = unwrapNonNull(rawArg);
|
|
@@ -1513,8 +1548,8 @@ var rule20 = {
|
|
|
1513
1548
|
category: "security",
|
|
1514
1549
|
cwe: "CWE-285",
|
|
1515
1550
|
owasp: "A01:2021 Broken Access Control",
|
|
1516
|
-
rationale:
|
|
1517
|
-
docsUrl: "https://supabase.com/docs/guides/
|
|
1551
|
+
rationale: 'Supabase documents user_metadata as user-editable: "Do not use it in security sensitive context (such as in RLS policies or authorization logic), as this value is editable by the user without any checks." 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.',
|
|
1552
|
+
docsUrl: "https://supabase.com/docs/guides/auth/users",
|
|
1518
1553
|
recommended: true
|
|
1519
1554
|
},
|
|
1520
1555
|
messages: {
|
|
@@ -1550,7 +1585,7 @@ var rule21 = {
|
|
|
1550
1585
|
docs: {
|
|
1551
1586
|
description: "Supabase .single() calls must inspect the returned error field",
|
|
1552
1587
|
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.",
|
|
1588
|
+
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. Chains ending in .throwOnError() opt into exceptions instead and are exempt.",
|
|
1554
1589
|
docsUrl: "https://supabase.com/docs/reference/javascript/single",
|
|
1555
1590
|
recommended: true
|
|
1556
1591
|
},
|
|
@@ -1560,14 +1595,29 @@ var rule21 = {
|
|
|
1560
1595
|
schema: []
|
|
1561
1596
|
},
|
|
1562
1597
|
create(context) {
|
|
1598
|
+
const deferredBindings = [];
|
|
1599
|
+
const errorReadNames = /* @__PURE__ */ new Set();
|
|
1563
1600
|
function checkAwaitBinding(node, pattern, awaitExpr) {
|
|
1564
1601
|
if (!isSingleSupabaseQuery(awaitExpr.argument)) return;
|
|
1602
|
+
if (chainHasMethod(awaitExpr.argument, "throwOnError")) return;
|
|
1603
|
+
if (pattern?.type === "Identifier") {
|
|
1604
|
+
deferredBindings.push({ node, name: pattern.name });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1565
1607
|
const names = destructuredNames(pattern);
|
|
1566
1608
|
if (names.has("error")) return;
|
|
1567
1609
|
context.report({ node, messageId: "missingErrorCheck" });
|
|
1568
1610
|
}
|
|
1569
1611
|
return {
|
|
1612
|
+
MemberExpression(node) {
|
|
1613
|
+
if (!node.computed && node.object?.type === "Identifier" && node.property?.type === "Identifier" && node.property.name === "error") {
|
|
1614
|
+
errorReadNames.add(node.object.name);
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1570
1617
|
VariableDeclarator(node) {
|
|
1618
|
+
if (node.init?.type === "Identifier" && node.id?.type === "ObjectPattern") {
|
|
1619
|
+
if (destructuredNames(node.id).has("error")) errorReadNames.add(node.init.name);
|
|
1620
|
+
}
|
|
1571
1621
|
if (node.init?.type !== "AwaitExpression") return;
|
|
1572
1622
|
checkAwaitBinding(node, node.id, node.init);
|
|
1573
1623
|
},
|
|
@@ -1579,9 +1629,17 @@ var rule21 = {
|
|
|
1579
1629
|
const expr = node.expression;
|
|
1580
1630
|
if (expr?.type !== "AwaitExpression") return;
|
|
1581
1631
|
if (!isSingleSupabaseQuery(expr.argument)) return;
|
|
1632
|
+
if (chainHasMethod(expr.argument, "throwOnError")) return;
|
|
1582
1633
|
if (memberPropName(expr.argument) === "single") {
|
|
1583
1634
|
context.report({ node, messageId: "missingErrorCheck" });
|
|
1584
1635
|
}
|
|
1636
|
+
},
|
|
1637
|
+
"Program:exit"() {
|
|
1638
|
+
for (const { node, name } of deferredBindings) {
|
|
1639
|
+
if (!errorReadNames.has(name)) {
|
|
1640
|
+
context.report({ node, messageId: "missingErrorCheck" });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1585
1643
|
}
|
|
1586
1644
|
};
|
|
1587
1645
|
}
|
|
@@ -1690,7 +1748,7 @@ var rule23 = {
|
|
|
1690
1748
|
docs: {
|
|
1691
1749
|
description: "Supabase mutations must check the returned error field",
|
|
1692
1750
|
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.",
|
|
1751
|
+
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. Chains ending in .throwOnError() opt into exceptions instead and are exempt.",
|
|
1694
1752
|
docsUrl: "https://supabase.com/docs/reference/javascript/insert",
|
|
1695
1753
|
recommended: true
|
|
1696
1754
|
},
|
|
@@ -1700,30 +1758,52 @@ var rule23 = {
|
|
|
1700
1758
|
schema: []
|
|
1701
1759
|
},
|
|
1702
1760
|
create(context) {
|
|
1761
|
+
const deferredBindings = [];
|
|
1762
|
+
const errorReadNames = /* @__PURE__ */ new Set();
|
|
1703
1763
|
function checkMutationAwait(node, pattern, awaitExpr) {
|
|
1704
1764
|
if (!isSupabaseMutationCall(awaitExpr.argument)) return;
|
|
1765
|
+
if (chainHasMethod(awaitExpr.argument, "throwOnError")) return;
|
|
1705
1766
|
if (!pattern) {
|
|
1706
1767
|
context.report({ node, messageId: "uncheckedMutation" });
|
|
1707
1768
|
return;
|
|
1708
1769
|
}
|
|
1770
|
+
if (pattern.type === "Identifier") {
|
|
1771
|
+
deferredBindings.push({ node, name: pattern.name });
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1709
1774
|
const names = destructuredNames(pattern);
|
|
1710
1775
|
if (!names.has("error")) {
|
|
1711
1776
|
context.report({ node, messageId: "uncheckedMutation" });
|
|
1712
1777
|
}
|
|
1713
1778
|
}
|
|
1714
1779
|
return {
|
|
1780
|
+
MemberExpression(node) {
|
|
1781
|
+
if (!node.computed && node.object?.type === "Identifier" && node.property?.type === "Identifier" && node.property.name === "error") {
|
|
1782
|
+
errorReadNames.add(node.object.name);
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1715
1785
|
ExpressionStatement(node) {
|
|
1716
1786
|
const expr = node.expression;
|
|
1717
1787
|
if (expr?.type !== "AwaitExpression") return;
|
|
1718
1788
|
checkMutationAwait(node, void 0, expr);
|
|
1719
1789
|
},
|
|
1720
1790
|
VariableDeclarator(node) {
|
|
1791
|
+
if (node.init?.type === "Identifier" && node.id?.type === "ObjectPattern") {
|
|
1792
|
+
if (destructuredNames(node.id).has("error")) errorReadNames.add(node.init.name);
|
|
1793
|
+
}
|
|
1721
1794
|
if (node.init?.type !== "AwaitExpression") return;
|
|
1722
1795
|
checkMutationAwait(node, node.id, node.init);
|
|
1723
1796
|
},
|
|
1724
1797
|
AssignmentExpression(node) {
|
|
1725
1798
|
if (node.right?.type !== "AwaitExpression") return;
|
|
1726
1799
|
checkMutationAwait(node, node.left, node.right);
|
|
1800
|
+
},
|
|
1801
|
+
"Program:exit"() {
|
|
1802
|
+
for (const { node, name } of deferredBindings) {
|
|
1803
|
+
if (!errorReadNames.has(name)) {
|
|
1804
|
+
context.report({ node, messageId: "uncheckedMutation" });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1727
1807
|
}
|
|
1728
1808
|
};
|
|
1729
1809
|
}
|
|
@@ -1773,11 +1853,20 @@ function isStorageUploadCall(node) {
|
|
|
1773
1853
|
let current = node;
|
|
1774
1854
|
let sawStorage = false;
|
|
1775
1855
|
let sawUpload = false;
|
|
1776
|
-
while (current
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1856
|
+
while (current) {
|
|
1857
|
+
if (current.type === "CallExpression") {
|
|
1858
|
+
const prop = memberPropName(current);
|
|
1859
|
+
if (prop === "storage") sawStorage = true;
|
|
1860
|
+
if (prop === "upload") sawUpload = true;
|
|
1861
|
+
current = current.callee?.object;
|
|
1862
|
+
} else if (current.type === "MemberExpression") {
|
|
1863
|
+
const p = current.property;
|
|
1864
|
+
const name = !current.computed && p?.type === "Identifier" ? p.name : p?.type === "Literal" && typeof p.value === "string" ? p.value : void 0;
|
|
1865
|
+
if (name === "storage") sawStorage = true;
|
|
1866
|
+
current = current.object;
|
|
1867
|
+
} else {
|
|
1868
|
+
break;
|
|
1869
|
+
}
|
|
1781
1870
|
}
|
|
1782
1871
|
return sawStorage && sawUpload;
|
|
1783
1872
|
}
|
|
@@ -2358,8 +2447,8 @@ var rule30 = {
|
|
|
2358
2447
|
category: "security",
|
|
2359
2448
|
cwe: "CWE-285",
|
|
2360
2449
|
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/
|
|
2450
|
+
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. Note the client-side call alone is not enough: App Check only blocks traffic once enforcement is turned on per-product in the Firebase console.",
|
|
2451
|
+
docsUrl: "https://firebase.google.com/docs/app-check/web/recaptcha-provider",
|
|
2363
2452
|
recommended: true
|
|
2364
2453
|
},
|
|
2365
2454
|
messages: {
|
|
@@ -2533,7 +2622,13 @@ function isValidationLikeCall(node) {
|
|
|
2533
2622
|
const callee = node.callee;
|
|
2534
2623
|
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
2535
2624
|
if (!name) return false;
|
|
2536
|
-
|
|
2625
|
+
if (/valid|^test$|check|assert|schema/i.test(name)) return true;
|
|
2626
|
+
if (callee?.type === "MemberExpression") {
|
|
2627
|
+
if (/^(safeParse|safeParseAsync|parseAsync)$/.test(name)) return true;
|
|
2628
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
2629
|
+
if (name === "parse" && objName !== "JSON") return true;
|
|
2630
|
+
}
|
|
2631
|
+
return false;
|
|
2537
2632
|
}
|
|
2538
2633
|
var rule33 = {
|
|
2539
2634
|
meta: {
|
|
@@ -2722,18 +2817,24 @@ var firebaseRtdbListenerErrorNotHandledRule = rule35;
|
|
|
2722
2817
|
function isUseEffectCall(node) {
|
|
2723
2818
|
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "useEffect";
|
|
2724
2819
|
}
|
|
2820
|
+
function isNonUidMemberAccess(node, objName) {
|
|
2821
|
+
if (node?.type !== "MemberExpression") return false;
|
|
2822
|
+
if (node.object?.type !== "Identifier" || node.object.name !== objName) return false;
|
|
2823
|
+
if (node.computed) return true;
|
|
2824
|
+
return node.property?.type === "Identifier" && node.property.name !== "uid";
|
|
2825
|
+
}
|
|
2725
2826
|
var rule36 = {
|
|
2726
2827
|
meta: {
|
|
2727
2828
|
type: "suggestion",
|
|
2728
2829
|
docs: {
|
|
2729
2830
|
description: "useEffect should depend on user?.uid, not the whole user object",
|
|
2730
2831
|
category: "reliability",
|
|
2731
|
-
rationale: "
|
|
2832
|
+
rationale: "Auth context providers commonly hand out a new user object reference on every token refresh (roughly hourly) \u2014 providers subscribed to onIdTokenChanged, or ones that clone the user into React state, re-render with a fresh reference 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
2833
|
docsUrl: "https://firebase.google.com/docs/reference/js/auth.md#onauthstatechanged",
|
|
2733
2834
|
recommended: true
|
|
2734
2835
|
},
|
|
2735
2836
|
messages: {
|
|
2736
|
-
wholeUserObjectDep: "This effect only reads {{name}}.uid but depends on the whole {{name}} object, which
|
|
2837
|
+
wholeUserObjectDep: "This effect only reads {{name}}.uid but depends on the whole {{name}} object, which can get a new reference on every token refresh. Depend on {{name}}?.uid instead."
|
|
2737
2838
|
},
|
|
2738
2839
|
schema: []
|
|
2739
2840
|
},
|
|
@@ -2746,9 +2847,9 @@ var rule36 = {
|
|
|
2746
2847
|
for (const el of depsArg.elements ?? []) {
|
|
2747
2848
|
if (el?.type !== "Identifier") continue;
|
|
2748
2849
|
const name = el.name;
|
|
2749
|
-
if (someDescendant(callback, (n) => isUidMemberAccess(n, name)))
|
|
2750
|
-
|
|
2751
|
-
}
|
|
2850
|
+
if (!someDescendant(callback, (n) => isUidMemberAccess(n, name))) continue;
|
|
2851
|
+
if (someDescendant(callback, (n) => isNonUidMemberAccess(n, name))) continue;
|
|
2852
|
+
context.report({ node, messageId: "wholeUserObjectDep", data: { name } });
|
|
2752
2853
|
}
|
|
2753
2854
|
}
|
|
2754
2855
|
};
|
|
@@ -2844,13 +2945,13 @@ var rule38 = {
|
|
|
2844
2945
|
schema: []
|
|
2845
2946
|
},
|
|
2846
2947
|
create(context) {
|
|
2847
|
-
const EXPIRED_YEAR = 2025;
|
|
2848
2948
|
function checkStringForExpiredDate(value, reportNode) {
|
|
2849
|
-
const re = /timestamp\.date\(\s*(\d{4})\s
|
|
2949
|
+
const re = /timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)/g;
|
|
2850
2950
|
let match;
|
|
2851
2951
|
while ((match = re.exec(value)) !== null) {
|
|
2852
|
-
const year =
|
|
2853
|
-
|
|
2952
|
+
const [, year, month, day] = match;
|
|
2953
|
+
const expiry = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
|
|
2954
|
+
if (expiry.getTime() <= Date.now()) {
|
|
2854
2955
|
context.report({ node: reportNode, messageId: "firestoreRulesExpired" });
|
|
2855
2956
|
return;
|
|
2856
2957
|
}
|
|
@@ -2882,6 +2983,18 @@ function findProp(obj, name) {
|
|
|
2882
2983
|
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
2883
2984
|
);
|
|
2884
2985
|
}
|
|
2986
|
+
var TOKEN_NAME = /token|session/i;
|
|
2987
|
+
function isTokenLiteral(node) {
|
|
2988
|
+
return node?.type === "Literal" && typeof node.value === "string" && TOKEN_NAME.test(node.value);
|
|
2989
|
+
}
|
|
2990
|
+
function isCookieReceiver(obj) {
|
|
2991
|
+
if (obj?.type === "Identifier") return /cookie/i.test(obj.name);
|
|
2992
|
+
if (obj?.type === "CallExpression" && obj.callee?.type === "Identifier") return /^cookies$/i.test(obj.callee.name);
|
|
2993
|
+
if (obj?.type === "MemberExpression" && !obj.computed && obj.property?.type === "Identifier") {
|
|
2994
|
+
return /cookie/i.test(obj.property.name);
|
|
2995
|
+
}
|
|
2996
|
+
return false;
|
|
2997
|
+
}
|
|
2885
2998
|
var rule39 = {
|
|
2886
2999
|
meta: {
|
|
2887
3000
|
type: "problem",
|
|
@@ -2889,110 +3002,71 @@ var rule39 = {
|
|
|
2889
3002
|
description: "Firebase ID token stored in a cookie without httpOnly flag",
|
|
2890
3003
|
category: "security",
|
|
2891
3004
|
cwe: "CWE-1004",
|
|
2892
|
-
owasp: "
|
|
2893
|
-
rationale: "Storing a Firebase ID token in a non-httpOnly cookie makes it readable by any JavaScript on the page. An XSS vulnerability can steal the token and impersonate the user. Use the Firebase Admin SDK createSessionCookie flow to issue a proper httpOnly session cookie instead.",
|
|
3005
|
+
owasp: "A05:2021 Security Misconfiguration",
|
|
3006
|
+
rationale: "Storing a Firebase ID token in a non-httpOnly cookie makes it readable by any JavaScript on the page. An XSS vulnerability can steal the token and impersonate the user. Use the Firebase Admin SDK createSessionCookie flow to issue a proper httpOnly session cookie instead \u2014 note httpOnly can only be set from server-side code, never from client-side JavaScript.",
|
|
2894
3007
|
docsUrl: "https://firebase.google.com/docs/auth/admin/manage-cookies",
|
|
2895
3008
|
recommended: true
|
|
2896
3009
|
},
|
|
2897
3010
|
messages: {
|
|
2898
|
-
idTokenCookieMissingHttpOnly: "
|
|
3011
|
+
idTokenCookieMissingHttpOnly: "Auth token stored in cookie without httpOnly: true. Any XSS vulnerability can steal the token. Use the Firebase Admin SDK createSessionCookie flow instead."
|
|
2899
3012
|
},
|
|
2900
3013
|
schema: []
|
|
2901
3014
|
},
|
|
2902
3015
|
create(context) {
|
|
2903
|
-
function isTokenName(val) {
|
|
2904
|
-
return /token/i.test(val);
|
|
2905
|
-
}
|
|
2906
3016
|
function hasHttpOnlyTrue(optsNode) {
|
|
2907
|
-
if (optsNode?.type !== "ObjectExpression") return false;
|
|
2908
3017
|
const prop = findProp(optsNode, "httpOnly");
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
const
|
|
2915
|
-
|
|
2916
|
-
if (!
|
|
2917
|
-
const args = node.arguments ?? [];
|
|
2918
|
-
const nameArg = args[0];
|
|
2919
|
-
if (nameArg?.type !== "Literal" || typeof nameArg.value !== "string") return;
|
|
2920
|
-
if (!isTokenName(nameArg.value)) return;
|
|
2921
|
-
const optsArg = args.length >= 3 ? args[2] : null;
|
|
2922
|
-
if (!optsArg || optsArg.type !== "ObjectExpression") {
|
|
2923
|
-
context.report({ node, messageId: "idTokenCookieMissingHttpOnly" });
|
|
2924
|
-
return;
|
|
2925
|
-
}
|
|
2926
|
-
if (!hasHttpOnlyTrue(optsArg)) {
|
|
3018
|
+
return prop?.value?.type === "Literal" && prop.value.value === true;
|
|
3019
|
+
}
|
|
3020
|
+
function checkNameValueOptsCall(node) {
|
|
3021
|
+
const args = node.arguments ?? [];
|
|
3022
|
+
if (args.length === 1 && args[0]?.type === "ObjectExpression") {
|
|
3023
|
+
const nameProp = findProp(args[0], "name");
|
|
3024
|
+
if (!isTokenLiteral(nameProp?.value)) return;
|
|
3025
|
+
if (!hasHttpOnlyTrue(args[0])) {
|
|
2927
3026
|
context.report({ node, messageId: "idTokenCookieMissingHttpOnly" });
|
|
2928
3027
|
}
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
if (!isTokenLiteral(args[0])) return;
|
|
3031
|
+
const optsArg = args.length >= 3 ? args[2] : null;
|
|
3032
|
+
if (!optsArg || optsArg.type !== "ObjectExpression" || !hasHttpOnlyTrue(optsArg)) {
|
|
3033
|
+
context.report({ node, messageId: "idTokenCookieMissingHttpOnly" });
|
|
2929
3034
|
}
|
|
2930
|
-
};
|
|
2931
|
-
}
|
|
2932
|
-
};
|
|
2933
|
-
var firebaseIdTokenCookieFlagsRule = rule39;
|
|
2934
|
-
|
|
2935
|
-
// src/providers/firebase/rules/middleware-token-not-verified.ts
|
|
2936
|
-
var rule40 = {
|
|
2937
|
-
meta: {
|
|
2938
|
-
type: "problem",
|
|
2939
|
-
docs: {
|
|
2940
|
-
description: "Next.js middleware reads auth cookie but never verifies it",
|
|
2941
|
-
category: "security",
|
|
2942
|
-
cwe: "CWE-345",
|
|
2943
|
-
owasp: "A07:2021 Identification and Authentication Failures",
|
|
2944
|
-
rationale: "Checking only that an auth cookie is non-empty does not verify the token. An attacker who sets any non-empty cookie value will bypass the guard entirely. The middleware must call verifySessionCookie or verifyIdToken to confirm the token is valid and unexpired.",
|
|
2945
|
-
docsUrl: "https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookies_and_check_permissions",
|
|
2946
|
-
recommended: true
|
|
2947
|
-
},
|
|
2948
|
-
messages: {
|
|
2949
|
-
tokenNotVerified: "Middleware reads the auth cookie but never verifies it. Any non-empty cookie value bypasses the guard. Call adminAuth.verifySessionCookie() or verifyIdToken() before allowing access."
|
|
2950
|
-
},
|
|
2951
|
-
schema: []
|
|
2952
|
-
},
|
|
2953
|
-
create(context) {
|
|
2954
|
-
const AUTH_COOKIE_NAMES = /token|session/i;
|
|
2955
|
-
const VERIFY_METHOD_NAMES = /* @__PURE__ */ new Set(["verifySessionCookie", "verifyIdToken", "verify"]);
|
|
2956
|
-
let readsCookieWithAuthName = false;
|
|
2957
|
-
let hasVerifyCall = false;
|
|
2958
|
-
const cookieReadNodes = [];
|
|
2959
|
-
function isAuthCookieGet(node) {
|
|
2960
|
-
if (node?.type !== "CallExpression") return false;
|
|
2961
|
-
const callee = node.callee;
|
|
2962
|
-
if (callee?.type !== "MemberExpression") return false;
|
|
2963
|
-
if (callee.property?.type !== "Identifier" || callee.property.name !== "get") return false;
|
|
2964
|
-
const arg = node.arguments?.[0];
|
|
2965
|
-
if (arg?.type !== "Literal" || typeof arg.value !== "string") return false;
|
|
2966
|
-
return AUTH_COOKIE_NAMES.test(arg.value);
|
|
2967
3035
|
}
|
|
2968
3036
|
return {
|
|
2969
3037
|
CallExpression(node) {
|
|
2970
|
-
if (isAuthCookieGet(node)) {
|
|
2971
|
-
readsCookieWithAuthName = true;
|
|
2972
|
-
cookieReadNodes.push(node);
|
|
2973
|
-
}
|
|
2974
3038
|
const callee = node.callee;
|
|
2975
|
-
if (callee?.type === "Identifier" &&
|
|
2976
|
-
|
|
3039
|
+
if (callee?.type === "Identifier" && callee.name === "setCookie") {
|
|
3040
|
+
checkNameValueOptsCall(node);
|
|
3041
|
+
return;
|
|
2977
3042
|
}
|
|
2978
|
-
if (callee?.type
|
|
2979
|
-
|
|
3043
|
+
if (callee?.type !== "MemberExpression" || callee.computed || callee.property?.type !== "Identifier") return;
|
|
3044
|
+
if (callee.property.name === "cookie") {
|
|
3045
|
+
checkNameValueOptsCall(node);
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
if (callee.property.name === "set" && isCookieReceiver(callee.object)) {
|
|
3049
|
+
checkNameValueOptsCall(node);
|
|
2980
3050
|
}
|
|
2981
3051
|
},
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
3052
|
+
// document.cookie = `token=${idToken}` — can never be httpOnly
|
|
3053
|
+
AssignmentExpression(node) {
|
|
3054
|
+
const left = node.left;
|
|
3055
|
+
const isDocumentCookie = left?.type === "MemberExpression" && !left.computed && left.object?.type === "Identifier" && left.object.name === "document" && left.property?.type === "Identifier" && left.property.name === "cookie";
|
|
3056
|
+
if (!isDocumentCookie) return;
|
|
3057
|
+
const right = node.right;
|
|
3058
|
+
const text = right?.type === "Literal" && typeof right.value === "string" ? right.value : right?.type === "TemplateLiteral" ? (right.quasis ?? []).map((q) => q?.value?.cooked ?? "").join("") : "";
|
|
3059
|
+
if (/(?:^|;\s*)[^=;]*(?:token|session)[^=;]*=/i.test(text)) {
|
|
3060
|
+
context.report({ node, messageId: "idTokenCookieMissingHttpOnly" });
|
|
2987
3061
|
}
|
|
2988
3062
|
}
|
|
2989
3063
|
};
|
|
2990
3064
|
}
|
|
2991
3065
|
};
|
|
2992
|
-
var
|
|
3066
|
+
var firebaseIdTokenCookieFlagsRule = rule39;
|
|
2993
3067
|
|
|
2994
3068
|
// src/providers/firebase/rules/hardcoded-user-id.ts
|
|
2995
|
-
var
|
|
3069
|
+
var rule40 = {
|
|
2996
3070
|
meta: {
|
|
2997
3071
|
type: "problem",
|
|
2998
3072
|
docs: {
|
|
@@ -3010,6 +3084,9 @@ var rule41 = {
|
|
|
3010
3084
|
schema: []
|
|
3011
3085
|
},
|
|
3012
3086
|
create(context) {
|
|
3087
|
+
if (isInsideTestFile2(String(context.filename ?? ""))) {
|
|
3088
|
+
return {};
|
|
3089
|
+
}
|
|
3013
3090
|
const USER_ID_NAMES = /* @__PURE__ */ new Set(["userId", "uid", "userID", "user_id"]);
|
|
3014
3091
|
return {
|
|
3015
3092
|
VariableDeclarator(node) {
|
|
@@ -3024,10 +3101,10 @@ var rule41 = {
|
|
|
3024
3101
|
};
|
|
3025
3102
|
}
|
|
3026
3103
|
};
|
|
3027
|
-
var firebaseHardcodedUserIdRule =
|
|
3104
|
+
var firebaseHardcodedUserIdRule = rule40;
|
|
3028
3105
|
|
|
3029
3106
|
// src/providers/firebase/rules/auth-user-not-found-disclosure.ts
|
|
3030
|
-
var
|
|
3107
|
+
var rule41 = {
|
|
3031
3108
|
meta: {
|
|
3032
3109
|
type: "suggestion",
|
|
3033
3110
|
docs: {
|
|
@@ -3066,10 +3143,10 @@ var rule42 = {
|
|
|
3066
3143
|
};
|
|
3067
3144
|
}
|
|
3068
3145
|
};
|
|
3069
|
-
var firebaseAuthUserNotFoundDisclosureRule =
|
|
3146
|
+
var firebaseAuthUserNotFoundDisclosureRule = rule41;
|
|
3070
3147
|
|
|
3071
3148
|
// src/providers/firebase/rules/signup-password-confirm.ts
|
|
3072
|
-
var
|
|
3149
|
+
var rule42 = {
|
|
3073
3150
|
meta: {
|
|
3074
3151
|
type: "suggestion",
|
|
3075
3152
|
docs: {
|
|
@@ -3103,7 +3180,7 @@ var rule43 = {
|
|
|
3103
3180
|
},
|
|
3104
3181
|
BinaryExpression(node) {
|
|
3105
3182
|
if (node.operator !== "===" && node.operator !== "==" && node.operator !== "!==" && node.operator !== "!=") return;
|
|
3106
|
-
const mentionsConfirm = (n) => n?.type === "Identifier" && n.name === "confirmPassword";
|
|
3183
|
+
const mentionsConfirm = (n) => n?.type === "Identifier" && n.name === "confirmPassword" || n?.type === "MemberExpression" && !n.computed && n.property?.type === "Identifier" && n.property.name === "confirmPassword";
|
|
3107
3184
|
if (mentionsConfirm(node.left) || mentionsConfirm(node.right)) {
|
|
3108
3185
|
hasPasswordComparison = true;
|
|
3109
3186
|
}
|
|
@@ -3126,16 +3203,16 @@ var rule43 = {
|
|
|
3126
3203
|
};
|
|
3127
3204
|
}
|
|
3128
3205
|
};
|
|
3129
|
-
var firebaseSignupPasswordConfirmRule =
|
|
3206
|
+
var firebaseSignupPasswordConfirmRule = rule42;
|
|
3130
3207
|
|
|
3131
3208
|
// src/providers/firebase/rules/use-array-union-remove.ts
|
|
3132
|
-
var
|
|
3209
|
+
var rule43 = {
|
|
3133
3210
|
meta: {
|
|
3134
3211
|
type: "suggestion",
|
|
3135
3212
|
docs: {
|
|
3136
3213
|
description: "Firestore array field updated with read-modify-write spread/filter instead of arrayUnion/arrayRemove",
|
|
3137
3214
|
category: "correctness",
|
|
3138
|
-
rationale: "Spreading an array or using filter() inside updateDoc() performs a non-atomic read-modify-write that loses concurrent updates. Firestore provides arrayUnion() and arrayRemove() specifically for atomic array updates that do not require reading the document first.",
|
|
3215
|
+
rationale: "Spreading an array or using filter() inside updateDoc() performs a non-atomic read-modify-write that loses concurrent updates. Firestore provides arrayUnion() and arrayRemove() specifically for atomic array updates that do not require reading the document first. Caveat: arrayRemove() matches whole element values \u2014 to remove object items by a predicate (e.g. by id), use runTransaction() instead so the read-modify-write is atomic.",
|
|
3139
3216
|
docsUrl: "https://firebase.google.com/docs/firestore/manage-data/add-data#update_elements_in_an_array",
|
|
3140
3217
|
recommended: true
|
|
3141
3218
|
},
|
|
@@ -3175,10 +3252,10 @@ var rule44 = {
|
|
|
3175
3252
|
};
|
|
3176
3253
|
}
|
|
3177
3254
|
};
|
|
3178
|
-
var firebaseUseArrayUnionRemoveRule =
|
|
3255
|
+
var firebaseUseArrayUnionRemoveRule = rule43;
|
|
3179
3256
|
|
|
3180
3257
|
// src/providers/firebase/rules/duplicate-initialize-app.ts
|
|
3181
|
-
var
|
|
3258
|
+
var rule44 = {
|
|
3182
3259
|
meta: {
|
|
3183
3260
|
type: "suggestion",
|
|
3184
3261
|
docs: {
|
|
@@ -3211,9 +3288,11 @@ var rule45 = {
|
|
|
3211
3288
|
const callee = node.callee;
|
|
3212
3289
|
if (callee?.type !== "Identifier") return;
|
|
3213
3290
|
if (initializeAppLocalName && callee.name === initializeAppLocalName) {
|
|
3214
|
-
|
|
3291
|
+
if ((node.arguments ?? []).length < 2) {
|
|
3292
|
+
initializeAppCalls.push(node);
|
|
3293
|
+
}
|
|
3215
3294
|
}
|
|
3216
|
-
if (callee.name === "getApps") {
|
|
3295
|
+
if (callee.name === "getApps" || callee.name === "getApp") {
|
|
3217
3296
|
hasGetAppsCall = true;
|
|
3218
3297
|
}
|
|
3219
3298
|
},
|
|
@@ -3226,21 +3305,21 @@ var rule45 = {
|
|
|
3226
3305
|
};
|
|
3227
3306
|
}
|
|
3228
3307
|
};
|
|
3229
|
-
var firebaseDuplicateInitializeAppRule =
|
|
3308
|
+
var firebaseDuplicateInitializeAppRule = rule44;
|
|
3230
3309
|
|
|
3231
3310
|
// src/providers/firebase/rules/onSnapshot-async-throw.ts
|
|
3232
|
-
var
|
|
3311
|
+
var rule45 = {
|
|
3233
3312
|
meta: {
|
|
3234
3313
|
type: "suggestion",
|
|
3235
3314
|
docs: {
|
|
3236
3315
|
description: "throw inside async onSnapshot callback creates an unhandled promise rejection",
|
|
3237
3316
|
category: "reliability",
|
|
3238
|
-
rationale: "onSnapshot
|
|
3317
|
+
rationale: "onSnapshot ignores the promise returned by an async callback, so a throw inside one becomes an unhandled promise rejection: the error never reaches the UI or the onSnapshot error callback (which only fires for stream errors), and in Node it can crash the process. The listener keeps delivering snapshots, but every one that hits the throw fails invisibly.",
|
|
3239
3318
|
docsUrl: "https://firebase.google.com/docs/firestore/query-data/listen#handle_listen_errors",
|
|
3240
3319
|
recommended: true
|
|
3241
3320
|
},
|
|
3242
3321
|
messages: {
|
|
3243
|
-
asyncThrowInSnapshot: "throw inside an async onSnapshot callback creates an unhandled promise rejection
|
|
3322
|
+
asyncThrowInSnapshot: "throw inside an async onSnapshot callback creates an unhandled promise rejection \u2014 the error surfaces nowhere. Catch it in the callback and set error state instead of throwing."
|
|
3244
3323
|
},
|
|
3245
3324
|
schema: []
|
|
3246
3325
|
},
|
|
@@ -3280,10 +3359,10 @@ var rule46 = {
|
|
|
3280
3359
|
};
|
|
3281
3360
|
}
|
|
3282
3361
|
};
|
|
3283
|
-
var firebaseOnSnapshotAsyncThrowRule =
|
|
3362
|
+
var firebaseOnSnapshotAsyncThrowRule = rule45;
|
|
3284
3363
|
|
|
3285
3364
|
// src/providers/firebase/rules/onSnapshot-missing-error-callback.ts
|
|
3286
|
-
var
|
|
3365
|
+
var rule46 = {
|
|
3287
3366
|
meta: {
|
|
3288
3367
|
type: "suggestion",
|
|
3289
3368
|
docs: {
|
|
@@ -3321,21 +3400,21 @@ var rule47 = {
|
|
|
3321
3400
|
};
|
|
3322
3401
|
}
|
|
3323
3402
|
};
|
|
3324
|
-
var firebaseOnSnapshotMissingErrorCallbackRule =
|
|
3403
|
+
var firebaseOnSnapshotMissingErrorCallbackRule = rule46;
|
|
3325
3404
|
|
|
3326
3405
|
// src/providers/firebase/rules/firestore-document-size-guard.ts
|
|
3327
|
-
var
|
|
3406
|
+
var rule47 = {
|
|
3328
3407
|
meta: {
|
|
3329
3408
|
type: "suggestion",
|
|
3330
3409
|
docs: {
|
|
3331
3410
|
description: "Firestore write includes editor.getJSON() without a document size guard",
|
|
3332
3411
|
category: "reliability",
|
|
3333
|
-
rationale: "Firestore documents have a 1 MiB limit. When a rich-text editor stores base64 images inline, a single paste can push the document over the limit. The write
|
|
3412
|
+
rationale: "Firestore documents have a 1 MiB limit. When a rich-text editor stores base64 images inline, a single paste can push the document over the limit. The write is rejected with INVALID_ARGUMENT \u2014 and if that rejection is not handled, nothing surfaces to the user. Check document size (e.g. new Blob([JSON.stringify(payload)]).size) before calling updateDoc/setDoc when the payload includes editor JSON.",
|
|
3334
3413
|
docsUrl: "https://firebase.google.com/docs/firestore/quotas#limits",
|
|
3335
3414
|
recommended: true
|
|
3336
3415
|
},
|
|
3337
3416
|
messages: {
|
|
3338
|
-
missingDocumentSizeGuard: "Firestore write includes editor.getJSON() without a document size check. Base64 images can exceed the 1 MiB Firestore limit
|
|
3417
|
+
missingDocumentSizeGuard: "Firestore write includes editor.getJSON() without a document size check. Base64 images can exceed the 1 MiB Firestore limit and the write is rejected. Add a size guard (new Blob([JSON.stringify(payload)]).size) before calling updateDoc/setDoc."
|
|
3339
3418
|
},
|
|
3340
3419
|
schema: []
|
|
3341
3420
|
},
|
|
@@ -3375,21 +3454,21 @@ var rule48 = {
|
|
|
3375
3454
|
};
|
|
3376
3455
|
}
|
|
3377
3456
|
};
|
|
3378
|
-
var firebaseFirestoreDocumentSizeGuardRule =
|
|
3457
|
+
var firebaseFirestoreDocumentSizeGuardRule = rule47;
|
|
3379
3458
|
|
|
3380
3459
|
// src/providers/firebase/rules/use-timestamp-now.ts
|
|
3381
|
-
var
|
|
3460
|
+
var rule48 = {
|
|
3382
3461
|
meta: {
|
|
3383
3462
|
type: "suggestion",
|
|
3384
3463
|
docs: {
|
|
3385
|
-
description: "new Date() used for Firestore timestamp instead of
|
|
3464
|
+
description: "new Date() used for Firestore timestamp instead of serverTimestamp()",
|
|
3386
3465
|
category: "correctness",
|
|
3387
|
-
rationale: "
|
|
3466
|
+
rationale: "Firestore auto-converts Date objects to Timestamps on write, so new Date() and Timestamp.now() are equivalent \u2014 both use the client clock, which cannot be trusted (skewed or deliberately changed clocks produce wrong orderings). serverTimestamp() stamps the value on the server instead, and it is the only option that satisfies security rules comparing a field against request.time (e.g. createdAt == request.time), which reject both new Date() and Timestamp.now().",
|
|
3388
3467
|
docsUrl: "https://firebase.google.com/docs/reference/js/firestore_.timestamp",
|
|
3389
3468
|
recommended: true
|
|
3390
3469
|
},
|
|
3391
3470
|
messages: {
|
|
3392
|
-
useTimestampNow: "Use
|
|
3471
|
+
useTimestampNow: "Use serverTimestamp() instead of new Date() for Firestore timestamp fields. new Date() uses the untrusted client clock and fails rules that compare against request.time."
|
|
3393
3472
|
},
|
|
3394
3473
|
schema: []
|
|
3395
3474
|
},
|
|
@@ -3398,7 +3477,7 @@ var rule49 = {
|
|
|
3398
3477
|
return {
|
|
3399
3478
|
ImportDeclaration(node) {
|
|
3400
3479
|
const src = node.source?.value;
|
|
3401
|
-
if (typeof src === "string" && (src.startsWith("firebase/") || src === "firebase-admin/firestore")) {
|
|
3480
|
+
if (typeof src === "string" && (src.startsWith("firebase/firestore") || src === "firebase-admin/firestore")) {
|
|
3402
3481
|
importsFromFirestore = true;
|
|
3403
3482
|
}
|
|
3404
3483
|
},
|
|
@@ -3411,7 +3490,7 @@ var rule49 = {
|
|
|
3411
3490
|
};
|
|
3412
3491
|
}
|
|
3413
3492
|
};
|
|
3414
|
-
var firebaseUseTimestampNowRule =
|
|
3493
|
+
var firebaseUseTimestampNowRule = rule48;
|
|
3415
3494
|
|
|
3416
3495
|
// src/providers/lovable/utils.ts
|
|
3417
3496
|
var LLM_HOST_PATTERN = /api\.anthropic\.com|api\.openai\.com/;
|
|
@@ -3426,7 +3505,7 @@ function containsKnownLlmHost(node) {
|
|
|
3426
3505
|
}
|
|
3427
3506
|
|
|
3428
3507
|
// src/providers/lovable/rules/no-client-side-secret-fetch.ts
|
|
3429
|
-
var
|
|
3508
|
+
var rule49 = {
|
|
3430
3509
|
meta: {
|
|
3431
3510
|
type: "problem",
|
|
3432
3511
|
docs: {
|
|
@@ -3499,13 +3578,13 @@ var rule50 = {
|
|
|
3499
3578
|
};
|
|
3500
3579
|
}
|
|
3501
3580
|
};
|
|
3502
|
-
var lovableNoClientSideSecretFetchRule =
|
|
3581
|
+
var lovableNoClientSideSecretFetchRule = rule49;
|
|
3503
3582
|
|
|
3504
3583
|
// src/providers/lovable/rules/paid-flag-without-edge-function.ts
|
|
3505
3584
|
var PRICE_PATTERN = /\$\s?\d/;
|
|
3506
3585
|
var FLAG_PROPERTY_PATTERN = /^is_[a-z0-9_]+$/i;
|
|
3507
3586
|
var FLAG_SUFFIX_PATTERN = /_(unlocked|active|enabled)$/i;
|
|
3508
|
-
var
|
|
3587
|
+
var rule50 = {
|
|
3509
3588
|
meta: {
|
|
3510
3589
|
type: "problem",
|
|
3511
3590
|
docs: {
|
|
@@ -3514,7 +3593,7 @@ var rule51 = {
|
|
|
3514
3593
|
cwe: "CWE-840",
|
|
3515
3594
|
owasp: "A04:2021 \u2013 Insecure Design",
|
|
3516
3595
|
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.",
|
|
3517
|
-
docsUrl: "https://docs.lovable.dev/
|
|
3596
|
+
docsUrl: "https://docs.lovable.dev/integrations/stripe",
|
|
3518
3597
|
recommended: true
|
|
3519
3598
|
},
|
|
3520
3599
|
messages: {
|
|
@@ -3647,20 +3726,20 @@ var rule51 = {
|
|
|
3647
3726
|
};
|
|
3648
3727
|
}
|
|
3649
3728
|
};
|
|
3650
|
-
var lovablePaidFlagWithoutEdgeFunctionRule =
|
|
3729
|
+
var lovablePaidFlagWithoutEdgeFunctionRule = rule50;
|
|
3651
3730
|
|
|
3652
3731
|
// src/providers/lovable/rules/expiry-column-never-checked.ts
|
|
3653
3732
|
var EXPIRY_COLUMN_PATTERN = /_(until|expires?_at|expiry)$/i;
|
|
3654
3733
|
var DATE_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([">", "<", ">=", "<="]);
|
|
3655
3734
|
var FILTER_METHODS = /* @__PURE__ */ new Set(["gt", "gte", "lt", "lte"]);
|
|
3656
|
-
var
|
|
3735
|
+
var rule51 = {
|
|
3657
3736
|
meta: {
|
|
3658
3737
|
type: "suggestion",
|
|
3659
3738
|
docs: {
|
|
3660
3739
|
description: "An expiry column must be checked against the current time somewhere",
|
|
3661
3740
|
category: "correctness",
|
|
3662
3741
|
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.",
|
|
3663
|
-
docsUrl: "https://docs.lovable.dev/
|
|
3742
|
+
docsUrl: "https://docs.lovable.dev/integrations/stripe",
|
|
3664
3743
|
recommended: true
|
|
3665
3744
|
},
|
|
3666
3745
|
messages: {
|
|
@@ -3745,18 +3824,18 @@ var rule52 = {
|
|
|
3745
3824
|
};
|
|
3746
3825
|
}
|
|
3747
3826
|
};
|
|
3748
|
-
var lovableExpiryColumnNeverCheckedRule =
|
|
3827
|
+
var lovableExpiryColumnNeverCheckedRule = rule51;
|
|
3749
3828
|
|
|
3750
3829
|
// src/providers/lovable/rules/silent-catch-on-provider-call.ts
|
|
3751
3830
|
var LOGGING_CONSOLE_METHODS = /* @__PURE__ */ new Set(["error", "warn", "log", "info"]);
|
|
3752
|
-
var
|
|
3831
|
+
var rule52 = {
|
|
3753
3832
|
meta: {
|
|
3754
3833
|
type: "suggestion",
|
|
3755
3834
|
docs: {
|
|
3756
3835
|
description: "A catch block around an LLM provider call must log the failure",
|
|
3757
3836
|
category: "correctness",
|
|
3758
3837
|
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.`,
|
|
3759
|
-
docsUrl: "https://docs.lovable.dev/
|
|
3838
|
+
docsUrl: "https://docs.lovable.dev/integrations/cloud",
|
|
3760
3839
|
recommended: true
|
|
3761
3840
|
},
|
|
3762
3841
|
messages: {
|
|
@@ -3822,7 +3901,7 @@ var rule53 = {
|
|
|
3822
3901
|
};
|
|
3823
3902
|
}
|
|
3824
3903
|
};
|
|
3825
|
-
var lovableSilentCatchOnProviderCallRule =
|
|
3904
|
+
var lovableSilentCatchOnProviderCallRule = rule52;
|
|
3826
3905
|
|
|
3827
3906
|
// src/providers/browserbase/utils.ts
|
|
3828
3907
|
function memberPropName3(node) {
|
|
@@ -3935,7 +4014,7 @@ function isTruthyGateTest(test) {
|
|
|
3935
4014
|
}
|
|
3936
4015
|
return false;
|
|
3937
4016
|
}
|
|
3938
|
-
var
|
|
4017
|
+
var rule53 = {
|
|
3939
4018
|
meta: {
|
|
3940
4019
|
type: "problem",
|
|
3941
4020
|
docs: {
|
|
@@ -4007,10 +4086,10 @@ var rule54 = {
|
|
|
4007
4086
|
};
|
|
4008
4087
|
}
|
|
4009
4088
|
};
|
|
4010
|
-
var browserbaseNoConditionalAuthzOnAnonymousUserRule =
|
|
4089
|
+
var browserbaseNoConditionalAuthzOnAnonymousUserRule = rule53;
|
|
4011
4090
|
|
|
4012
4091
|
// src/providers/browserbase/rules/no-connect-url-in-api-response.ts
|
|
4013
|
-
var
|
|
4092
|
+
var rule54 = {
|
|
4014
4093
|
meta: {
|
|
4015
4094
|
type: "problem",
|
|
4016
4095
|
docs: {
|
|
@@ -4040,7 +4119,7 @@ var rule55 = {
|
|
|
4040
4119
|
};
|
|
4041
4120
|
}
|
|
4042
4121
|
};
|
|
4043
|
-
var browserbaseNoConnectUrlInApiResponseRule =
|
|
4122
|
+
var browserbaseNoConnectUrlInApiResponseRule = rule54;
|
|
4044
4123
|
|
|
4045
4124
|
// src/providers/browserbase/rules/session-id-requires-ownership-check.ts
|
|
4046
4125
|
function isOwnershipCheckCall(node) {
|
|
@@ -4057,7 +4136,7 @@ function isSessionIdLikeArg(node) {
|
|
|
4057
4136
|
}
|
|
4058
4137
|
return false;
|
|
4059
4138
|
}
|
|
4060
|
-
var
|
|
4139
|
+
var rule55 = {
|
|
4061
4140
|
meta: {
|
|
4062
4141
|
type: "suggestion",
|
|
4063
4142
|
docs: {
|
|
@@ -4065,7 +4144,7 @@ var rule56 = {
|
|
|
4065
4144
|
category: "security",
|
|
4066
4145
|
cwe: "CWE-862",
|
|
4067
4146
|
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.",
|
|
4068
|
-
docsUrl: "https://docs.browserbase.com/
|
|
4147
|
+
docsUrl: "https://docs.browserbase.com/platform/browser/observability/session-live-view",
|
|
4069
4148
|
recommended: true
|
|
4070
4149
|
},
|
|
4071
4150
|
messages: {
|
|
@@ -4121,7 +4200,7 @@ var rule56 = {
|
|
|
4121
4200
|
};
|
|
4122
4201
|
}
|
|
4123
4202
|
};
|
|
4124
|
-
var browserbaseSessionIdRequiresOwnershipCheckRule =
|
|
4203
|
+
var browserbaseSessionIdRequiresOwnershipCheckRule = rule55;
|
|
4125
4204
|
|
|
4126
4205
|
// src/providers/browserbase/rules/no-concurrent-shared-context.ts
|
|
4127
4206
|
function isPromiseAllCall2(node) {
|
|
@@ -4178,14 +4257,14 @@ function findSharedContextCreateCall(callback) {
|
|
|
4178
4257
|
visit(body);
|
|
4179
4258
|
return found;
|
|
4180
4259
|
}
|
|
4181
|
-
var
|
|
4260
|
+
var rule56 = {
|
|
4182
4261
|
meta: {
|
|
4183
4262
|
type: "problem",
|
|
4184
4263
|
docs: {
|
|
4185
4264
|
description: "A Browserbase Context must not be attached to concurrent sessions",
|
|
4186
4265
|
category: "correctness",
|
|
4187
4266
|
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.`,
|
|
4188
|
-
docsUrl: "https://docs.browserbase.com/features/contexts",
|
|
4267
|
+
docsUrl: "https://docs.browserbase.com/platform/browser/core-features/contexts",
|
|
4189
4268
|
recommended: true
|
|
4190
4269
|
},
|
|
4191
4270
|
messages: {
|
|
@@ -4225,7 +4304,7 @@ var rule57 = {
|
|
|
4225
4304
|
};
|
|
4226
4305
|
}
|
|
4227
4306
|
};
|
|
4228
|
-
var browserbaseNoConcurrentSharedContextRule =
|
|
4307
|
+
var browserbaseNoConcurrentSharedContextRule = rule56;
|
|
4229
4308
|
|
|
4230
4309
|
// src/providers/browserbase/rules/mobile-device-requires-os-setting.ts
|
|
4231
4310
|
function isMobileLiteral(node) {
|
|
@@ -4258,14 +4337,14 @@ function isOsMobileSetting(node) {
|
|
|
4258
4337
|
}
|
|
4259
4338
|
return false;
|
|
4260
4339
|
}
|
|
4261
|
-
var
|
|
4340
|
+
var rule57 = {
|
|
4262
4341
|
meta: {
|
|
4263
4342
|
type: "problem",
|
|
4264
4343
|
docs: {
|
|
4265
4344
|
description: "Mobile device combos must set browserSettings.os, not just resize the viewport",
|
|
4266
4345
|
category: "correctness",
|
|
4267
4346
|
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.`,
|
|
4268
|
-
docsUrl: "https://docs.browserbase.com/
|
|
4347
|
+
docsUrl: "https://docs.browserbase.com/platform/identity/verified-customization",
|
|
4269
4348
|
recommended: true
|
|
4270
4349
|
},
|
|
4271
4350
|
messages: {
|
|
@@ -4297,7 +4376,7 @@ var rule58 = {
|
|
|
4297
4376
|
};
|
|
4298
4377
|
}
|
|
4299
4378
|
};
|
|
4300
|
-
var browserbaseMobileDeviceRequiresOsSettingRule =
|
|
4379
|
+
var browserbaseMobileDeviceRequiresOsSettingRule = rule57;
|
|
4301
4380
|
|
|
4302
4381
|
// src/providers/browserbase/rules/use-typed-exception-status-not-substring.ts
|
|
4303
4382
|
function isStringifiedErrorSource(node, errName) {
|
|
@@ -4359,7 +4438,7 @@ function collectStringifiedVars(body, errName) {
|
|
|
4359
4438
|
});
|
|
4360
4439
|
return names;
|
|
4361
4440
|
}
|
|
4362
|
-
var
|
|
4441
|
+
var rule58 = {
|
|
4363
4442
|
meta: {
|
|
4364
4443
|
type: "suggestion",
|
|
4365
4444
|
docs: {
|
|
@@ -4389,7 +4468,7 @@ var rule59 = {
|
|
|
4389
4468
|
};
|
|
4390
4469
|
}
|
|
4391
4470
|
};
|
|
4392
|
-
var browserbaseUseTypedExceptionStatusNotSubstringRule =
|
|
4471
|
+
var browserbaseUseTypedExceptionStatusNotSubstringRule = rule58;
|
|
4393
4472
|
|
|
4394
4473
|
// src/providers/browserbase/rules/release-session-on-connect-failure.ts
|
|
4395
4474
|
function isSessionsCreateAwait(node) {
|
|
@@ -4412,7 +4491,7 @@ function isReleaseCall(node) {
|
|
|
4412
4491
|
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
4413
4492
|
return !!name && /requeststop|releasesession|release_session/i.test(name);
|
|
4414
4493
|
}
|
|
4415
|
-
var
|
|
4494
|
+
var rule59 = {
|
|
4416
4495
|
meta: {
|
|
4417
4496
|
type: "problem",
|
|
4418
4497
|
docs: {
|
|
@@ -4454,10 +4533,10 @@ var rule60 = {
|
|
|
4454
4533
|
};
|
|
4455
4534
|
}
|
|
4456
4535
|
};
|
|
4457
|
-
var browserbaseReleaseSessionOnConnectFailureRule =
|
|
4536
|
+
var browserbaseReleaseSessionOnConnectFailureRule = rule59;
|
|
4458
4537
|
|
|
4459
4538
|
// src/providers/browserbase/rules/dont-stack-custom-retry-on-sdk-retry.ts
|
|
4460
|
-
var
|
|
4539
|
+
var rule60 = {
|
|
4461
4540
|
meta: {
|
|
4462
4541
|
type: "suggestion",
|
|
4463
4542
|
docs: {
|
|
@@ -4502,7 +4581,7 @@ var rule61 = {
|
|
|
4502
4581
|
};
|
|
4503
4582
|
}
|
|
4504
4583
|
};
|
|
4505
|
-
var browserbaseDontStackCustomRetryOnSdkRetryRule =
|
|
4584
|
+
var browserbaseDontStackCustomRetryOnSdkRetryRule = rule60;
|
|
4506
4585
|
|
|
4507
4586
|
// src/providers/browserbase/rules/no-overbroad-error-substring-match.ts
|
|
4508
4587
|
var OVERBROAD_TERMS = /* @__PURE__ */ new Set(["session", "context", "browser", "browserbase"]);
|
|
@@ -4524,7 +4603,7 @@ function isCleanupCall(node) {
|
|
|
4524
4603
|
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
4525
4604
|
return !!name && /release|remove|cleanup|stop|teardown/i.test(name);
|
|
4526
4605
|
}
|
|
4527
|
-
var
|
|
4606
|
+
var rule61 = {
|
|
4528
4607
|
meta: {
|
|
4529
4608
|
type: "problem",
|
|
4530
4609
|
docs: {
|
|
@@ -4554,7 +4633,7 @@ var rule62 = {
|
|
|
4554
4633
|
};
|
|
4555
4634
|
}
|
|
4556
4635
|
};
|
|
4557
|
-
var browserbaseNoOverbroadErrorSubstringMatchRule =
|
|
4636
|
+
var browserbaseNoOverbroadErrorSubstringMatchRule = rule61;
|
|
4558
4637
|
|
|
4559
4638
|
// src/providers/browserbase/rules/use-sdk-not-raw-requests.ts
|
|
4560
4639
|
var BROWSERBASE_HOST = "api.browserbase.com";
|
|
@@ -4594,7 +4673,7 @@ function isRawHttpCall(node) {
|
|
|
4594
4673
|
}
|
|
4595
4674
|
return { isCall: false, urlArg: void 0 };
|
|
4596
4675
|
}
|
|
4597
|
-
var
|
|
4676
|
+
var rule62 = {
|
|
4598
4677
|
meta: {
|
|
4599
4678
|
type: "suggestion",
|
|
4600
4679
|
docs: {
|
|
@@ -4628,10 +4707,10 @@ var rule63 = {
|
|
|
4628
4707
|
};
|
|
4629
4708
|
}
|
|
4630
4709
|
};
|
|
4631
|
-
var browserbaseUseSdkNotRawRequestsRule =
|
|
4710
|
+
var browserbaseUseSdkNotRawRequestsRule = rule62;
|
|
4632
4711
|
|
|
4633
4712
|
// src/providers/browserbase/rules/centralize-request-release.ts
|
|
4634
|
-
var
|
|
4713
|
+
var rule63 = {
|
|
4635
4714
|
meta: {
|
|
4636
4715
|
type: "suggestion",
|
|
4637
4716
|
docs: {
|
|
@@ -4664,11 +4743,11 @@ var rule64 = {
|
|
|
4664
4743
|
};
|
|
4665
4744
|
}
|
|
4666
4745
|
};
|
|
4667
|
-
var browserbaseCentralizeRequestReleaseRule =
|
|
4746
|
+
var browserbaseCentralizeRequestReleaseRule = rule63;
|
|
4668
4747
|
|
|
4669
4748
|
// src/providers/openai-cua/rules/no-domain-allowlist.ts
|
|
4670
4749
|
var ACTION_METHOD_NAMES = /* @__PURE__ */ new Set(["click", "type", "press", "move", "goto", "fill", "dblclick", "dragAndDrop"]);
|
|
4671
|
-
var
|
|
4750
|
+
var rule64 = {
|
|
4672
4751
|
meta: {
|
|
4673
4752
|
type: "problem",
|
|
4674
4753
|
docs: {
|
|
@@ -4794,11 +4873,11 @@ var rule65 = {
|
|
|
4794
4873
|
};
|
|
4795
4874
|
}
|
|
4796
4875
|
};
|
|
4797
|
-
var openaiCuaNoDomainAllowlistRule =
|
|
4876
|
+
var openaiCuaNoDomainAllowlistRule = rule64;
|
|
4798
4877
|
|
|
4799
4878
|
// src/providers/openai-cua/rules/scroll-delta-default-zero.ts
|
|
4800
4879
|
var VERTICAL_DELTA_NAME_PATTERN = /^(dy|delta_?y|scroll_?y)$/i;
|
|
4801
|
-
var
|
|
4880
|
+
var rule65 = {
|
|
4802
4881
|
meta: {
|
|
4803
4882
|
type: "problem",
|
|
4804
4883
|
docs: {
|
|
@@ -4874,12 +4953,12 @@ var rule66 = {
|
|
|
4874
4953
|
};
|
|
4875
4954
|
}
|
|
4876
4955
|
};
|
|
4877
|
-
var openaiCuaScrollDeltaDefaultZeroRule =
|
|
4956
|
+
var openaiCuaScrollDeltaDefaultZeroRule = rule65;
|
|
4878
4957
|
|
|
4879
4958
|
// src/providers/openai-cua/rules/structured-step-metadata-not-text-json.ts
|
|
4880
4959
|
var BRACE_SEARCH_METHODS = /* @__PURE__ */ new Set(["indexOf", "lastIndexOf"]);
|
|
4881
4960
|
var SLICE_METHODS = /* @__PURE__ */ new Set(["slice", "substring", "substr"]);
|
|
4882
|
-
var
|
|
4961
|
+
var rule66 = {
|
|
4883
4962
|
meta: {
|
|
4884
4963
|
type: "suggestion",
|
|
4885
4964
|
docs: {
|
|
@@ -4978,10 +5057,10 @@ var rule67 = {
|
|
|
4978
5057
|
};
|
|
4979
5058
|
}
|
|
4980
5059
|
};
|
|
4981
|
-
var openaiCuaStructuredStepMetadataNotTextJsonRule =
|
|
5060
|
+
var openaiCuaStructuredStepMetadataNotTextJsonRule = rule66;
|
|
4982
5061
|
|
|
4983
5062
|
// src/providers/openai-cua/rules/no-blind-safety-check-ack.ts
|
|
4984
|
-
var
|
|
5063
|
+
var rule67 = {
|
|
4985
5064
|
meta: {
|
|
4986
5065
|
type: "suggestion",
|
|
4987
5066
|
docs: {
|
|
@@ -5044,7 +5123,7 @@ var rule68 = {
|
|
|
5044
5123
|
};
|
|
5045
5124
|
}
|
|
5046
5125
|
};
|
|
5047
|
-
var openaiCuaNoBlindSafetyCheckAckRule =
|
|
5126
|
+
var openaiCuaNoBlindSafetyCheckAckRule = rule67;
|
|
5048
5127
|
|
|
5049
5128
|
// src/providers/openai-cua/utils.ts
|
|
5050
5129
|
function propName(node) {
|
|
@@ -5090,7 +5169,7 @@ function findResponsesCreateCall(node, depth = 0) {
|
|
|
5090
5169
|
}
|
|
5091
5170
|
|
|
5092
5171
|
// src/providers/openai-cua/rules/retry-transient-turn-errors.ts
|
|
5093
|
-
var
|
|
5172
|
+
var rule68 = {
|
|
5094
5173
|
meta: {
|
|
5095
5174
|
type: "problem",
|
|
5096
5175
|
docs: {
|
|
@@ -5172,10 +5251,10 @@ var rule69 = {
|
|
|
5172
5251
|
};
|
|
5173
5252
|
}
|
|
5174
5253
|
};
|
|
5175
|
-
var openaiCuaRetryTransientTurnErrorsRule =
|
|
5254
|
+
var openaiCuaRetryTransientTurnErrorsRule = rule68;
|
|
5176
5255
|
|
|
5177
5256
|
// src/providers/openai-cua/rules/check-response-status-incomplete.ts
|
|
5178
|
-
var
|
|
5257
|
+
var rule69 = {
|
|
5179
5258
|
meta: {
|
|
5180
5259
|
type: "problem",
|
|
5181
5260
|
docs: {
|
|
@@ -5284,17 +5363,17 @@ var rule70 = {
|
|
|
5284
5363
|
};
|
|
5285
5364
|
}
|
|
5286
5365
|
};
|
|
5287
|
-
var openaiCuaCheckResponseStatusIncompleteRule =
|
|
5366
|
+
var openaiCuaCheckResponseStatusIncompleteRule = rule69;
|
|
5288
5367
|
|
|
5289
5368
|
// src/providers/openai-cua/rules/set-safety-identifier.ts
|
|
5290
|
-
var
|
|
5369
|
+
var rule70 = {
|
|
5291
5370
|
meta: {
|
|
5292
5371
|
type: "problem",
|
|
5293
5372
|
docs: {
|
|
5294
5373
|
description: "responses.create() must set safety_identifier for per-end-user policy attribution",
|
|
5295
5374
|
category: "integration",
|
|
5296
5375
|
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.",
|
|
5297
|
-
docsUrl: "https://
|
|
5376
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/safety-best-practices",
|
|
5298
5377
|
recommended: true
|
|
5299
5378
|
},
|
|
5300
5379
|
messages: {
|
|
@@ -5325,10 +5404,10 @@ var rule71 = {
|
|
|
5325
5404
|
};
|
|
5326
5405
|
}
|
|
5327
5406
|
};
|
|
5328
|
-
var openaiCuaSetSafetyIdentifierRule =
|
|
5407
|
+
var openaiCuaSetSafetyIdentifierRule = rule70;
|
|
5329
5408
|
|
|
5330
5409
|
// src/providers/tiptap/rules/upload-validate-fn-void.ts
|
|
5331
|
-
var
|
|
5410
|
+
var rule71 = {
|
|
5332
5411
|
meta: {
|
|
5333
5412
|
type: "problem",
|
|
5334
5413
|
docs: {
|
|
@@ -5392,10 +5471,10 @@ var rule72 = {
|
|
|
5392
5471
|
};
|
|
5393
5472
|
}
|
|
5394
5473
|
};
|
|
5395
|
-
var tiptapUploadValidateFnVoidRule =
|
|
5474
|
+
var tiptapUploadValidateFnVoidRule = rule71;
|
|
5396
5475
|
|
|
5397
5476
|
// src/providers/tiptap/rules/script-src-hardcoded-api-key.ts
|
|
5398
|
-
var
|
|
5477
|
+
var rule72 = {
|
|
5399
5478
|
meta: {
|
|
5400
5479
|
type: "problem",
|
|
5401
5480
|
docs: {
|
|
@@ -5453,10 +5532,10 @@ var rule73 = {
|
|
|
5453
5532
|
};
|
|
5454
5533
|
}
|
|
5455
5534
|
};
|
|
5456
|
-
var tiptapScriptSrcHardcodedApiKeyRule =
|
|
5535
|
+
var tiptapScriptSrcHardcodedApiKeyRule = rule72;
|
|
5457
5536
|
|
|
5458
5537
|
// src/providers/tiptap/rules/dynamic-script-no-sri.ts
|
|
5459
|
-
var
|
|
5538
|
+
var rule73 = {
|
|
5460
5539
|
meta: {
|
|
5461
5540
|
type: "problem",
|
|
5462
5541
|
docs: {
|
|
@@ -5465,7 +5544,7 @@ var rule74 = {
|
|
|
5465
5544
|
cwe: "CWE-829",
|
|
5466
5545
|
owasp: "API8:2023 Security Misconfiguration",
|
|
5467
5546
|
rationale: 'A script element injected without an integrity attribute will execute whatever the CDN returns, including malicious content if the CDN is compromised. Adding a Subresource Integrity hash (script.setAttribute("integrity", "sha384-...")) lets the browser verify the fetched script matches the expected bytes before executing it.',
|
|
5468
|
-
docsUrl: "https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity",
|
|
5547
|
+
docsUrl: "https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity",
|
|
5469
5548
|
recommended: true
|
|
5470
5549
|
},
|
|
5471
5550
|
messages: {
|
|
@@ -5552,7 +5631,7 @@ var rule74 = {
|
|
|
5552
5631
|
};
|
|
5553
5632
|
}
|
|
5554
5633
|
};
|
|
5555
|
-
var tiptapDynamicScriptNoSriRule =
|
|
5634
|
+
var tiptapDynamicScriptNoSriRule = rule73;
|
|
5556
5635
|
|
|
5557
5636
|
// src/providers/tiptap/utils.ts
|
|
5558
5637
|
function findProperty3(objectExpression, name) {
|
|
@@ -5577,7 +5656,7 @@ function walk(node, visit) {
|
|
|
5577
5656
|
}
|
|
5578
5657
|
|
|
5579
5658
|
// src/providers/tiptap/rules/addAttributes-missing-renderHTML.ts
|
|
5580
|
-
var
|
|
5659
|
+
var rule74 = {
|
|
5581
5660
|
meta: {
|
|
5582
5661
|
type: "problem",
|
|
5583
5662
|
docs: {
|
|
@@ -5630,10 +5709,10 @@ var rule75 = {
|
|
|
5630
5709
|
};
|
|
5631
5710
|
}
|
|
5632
5711
|
};
|
|
5633
|
-
var tiptapAddAttributesMissingRenderHTMLRule =
|
|
5712
|
+
var tiptapAddAttributesMissingRenderHTMLRule = rule74;
|
|
5634
5713
|
|
|
5635
5714
|
// src/providers/tiptap/rules/appendTransaction-add-to-history.ts
|
|
5636
|
-
var
|
|
5715
|
+
var rule75 = {
|
|
5637
5716
|
meta: {
|
|
5638
5717
|
type: "suggestion",
|
|
5639
5718
|
docs: {
|
|
@@ -5712,10 +5791,10 @@ var rule76 = {
|
|
|
5712
5791
|
};
|
|
5713
5792
|
}
|
|
5714
5793
|
};
|
|
5715
|
-
var tiptapAppendTransactionAddToHistoryRule =
|
|
5794
|
+
var tiptapAppendTransactionAddToHistoryRule = rule75;
|
|
5716
5795
|
|
|
5717
5796
|
// src/providers/tiptap/rules/appendTransaction-full-scan.ts
|
|
5718
|
-
var
|
|
5797
|
+
var rule76 = {
|
|
5719
5798
|
meta: {
|
|
5720
5799
|
type: "suggestion",
|
|
5721
5800
|
docs: {
|
|
@@ -5775,10 +5854,10 @@ var rule77 = {
|
|
|
5775
5854
|
};
|
|
5776
5855
|
}
|
|
5777
5856
|
};
|
|
5778
|
-
var tiptapAppendTransactionFullScanRule =
|
|
5857
|
+
var tiptapAppendTransactionFullScanRule = rule76;
|
|
5779
5858
|
|
|
5780
5859
|
// src/providers/tiptap/rules/atom-node-wrap-in.ts
|
|
5781
|
-
var
|
|
5860
|
+
var rule77 = {
|
|
5782
5861
|
meta: {
|
|
5783
5862
|
type: "problem",
|
|
5784
5863
|
docs: {
|
|
@@ -5841,57 +5920,16 @@ var rule78 = {
|
|
|
5841
5920
|
};
|
|
5842
5921
|
}
|
|
5843
5922
|
};
|
|
5844
|
-
var tiptapAtomNodeWrapInRule =
|
|
5845
|
-
|
|
5846
|
-
// src/providers/tiptap/rules/twitter-url-regex.ts
|
|
5847
|
-
var rule79 = {
|
|
5848
|
-
meta: {
|
|
5849
|
-
type: "suggestion",
|
|
5850
|
-
docs: {
|
|
5851
|
-
description: "Twitter/X URL regex must match both x.com and twitter.com",
|
|
5852
|
-
category: "integration",
|
|
5853
|
-
rationale: "Twitter rebranded to X but twitter.com URLs still resolve and embed correctly. A regex that only matches x.com silently rejects all legacy twitter.com links pasted by users. Update the pattern to (x\\.com|twitter\\.com) so both domains are handled.",
|
|
5854
|
-
docsUrl: "https://tiptap.dev/docs/editor/extensions/nodes",
|
|
5855
|
-
recommended: true
|
|
5856
|
-
},
|
|
5857
|
-
messages: {
|
|
5858
|
-
twitterRegexMissingLegacyDomain: "This regex matches x.com but not twitter.com. Legacy twitter.com URLs are still valid and should be supported. Add (x\\.com|twitter\\.com) to the pattern."
|
|
5859
|
-
},
|
|
5860
|
-
schema: []
|
|
5861
|
-
},
|
|
5862
|
-
create(context) {
|
|
5863
|
-
function checkPattern(pattern, node) {
|
|
5864
|
-
if (!pattern.includes("x\\.com") && !pattern.includes("x.com")) return;
|
|
5865
|
-
if (pattern.includes("twitter\\.com") || pattern.includes("twitter.com")) return;
|
|
5866
|
-
context.report({ node, messageId: "twitterRegexMissingLegacyDomain" });
|
|
5867
|
-
}
|
|
5868
|
-
return {
|
|
5869
|
-
Literal(node) {
|
|
5870
|
-
if (node.regex?.pattern) {
|
|
5871
|
-
checkPattern(node.regex.pattern, node);
|
|
5872
|
-
}
|
|
5873
|
-
},
|
|
5874
|
-
NewExpression(node) {
|
|
5875
|
-
if (node.callee?.type === "Identifier" && node.callee.name === "RegExp") {
|
|
5876
|
-
const firstArg = node.arguments?.[0];
|
|
5877
|
-
if (firstArg?.type === "Literal" && typeof firstArg.value === "string") {
|
|
5878
|
-
checkPattern(firstArg.value, node);
|
|
5879
|
-
}
|
|
5880
|
-
}
|
|
5881
|
-
}
|
|
5882
|
-
};
|
|
5883
|
-
}
|
|
5884
|
-
};
|
|
5885
|
-
var tiptapTwitterUrlRegexRule = rule79;
|
|
5923
|
+
var tiptapAtomNodeWrapInRule = rule77;
|
|
5886
5924
|
|
|
5887
5925
|
// src/providers/tiptap/rules/drop-handler-pos-precedence.ts
|
|
5888
|
-
var
|
|
5926
|
+
var rule78 = {
|
|
5889
5927
|
meta: {
|
|
5890
5928
|
type: "problem",
|
|
5891
5929
|
docs: {
|
|
5892
5930
|
description: "x ?? 0 - 1 is parsed as x ?? (0 - 1) = x ?? -1 due to operator precedence",
|
|
5893
5931
|
category: "correctness",
|
|
5894
|
-
rationale: "The nullish coalescing operator (??) has lower precedence than arithmetic operators. Writing `pos ?? 0 - 1` evaluates as `pos ?? -1`, not `(pos ?? 0) - 1`.
|
|
5932
|
+
rationale: "The nullish coalescing operator (??) has lower precedence than arithmetic operators. Writing `pos ?? 0 - 1` evaluates as `pos ?? -1`, not `(pos ?? 0) - 1`. Whenever pos is defined, the expression yields pos instead of the intended pos - 1 \u2014 an off-by-one on every real drop. In ProseMirror drop handlers this misplaces the decoration/insert position, and the -1 fallback (when pos is nullish) is an invalid document position.",
|
|
5895
5933
|
docsUrl: "https://prosemirror.net/docs/ref/#view.EditorView.posAtCoords",
|
|
5896
5934
|
recommended: true
|
|
5897
5935
|
},
|
|
@@ -5912,7 +5950,7 @@ var rule80 = {
|
|
|
5912
5950
|
};
|
|
5913
5951
|
}
|
|
5914
5952
|
};
|
|
5915
|
-
var tiptapDropHandlerPosPrecedenceRule =
|
|
5953
|
+
var tiptapDropHandlerPosPrecedenceRule = rule78;
|
|
5916
5954
|
|
|
5917
5955
|
// src/providers/tiptap/rules/prefer-table-kit.ts
|
|
5918
5956
|
var INDIVIDUAL_TABLE_PACKAGES = /* @__PURE__ */ new Set([
|
|
@@ -5920,18 +5958,18 @@ var INDIVIDUAL_TABLE_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
5920
5958
|
"@tiptap/extension-table-cell",
|
|
5921
5959
|
"@tiptap/extension-table-header"
|
|
5922
5960
|
]);
|
|
5923
|
-
var
|
|
5961
|
+
var rule79 = {
|
|
5924
5962
|
meta: {
|
|
5925
5963
|
type: "suggestion",
|
|
5926
5964
|
docs: {
|
|
5927
5965
|
description: "Use TableKit from @tiptap/extension-table instead of individual table packages",
|
|
5928
5966
|
category: "integration",
|
|
5929
|
-
rationale: "Importing table extensions individually from their separate packages bypasses the coordinated wiring that TableKit provides: shared configuration
|
|
5967
|
+
rationale: "Importing table extensions individually from their separate packages bypasses the coordinated wiring that TableKit provides: shared configuration and consistent HTMLAttributes across all table elements, configured in one place. TableKit from @tiptap/extension-table is the documented way to register the full table feature set.",
|
|
5930
5968
|
docsUrl: "https://tiptap.dev/docs/editor/extensions/functionality/table-kit",
|
|
5931
5969
|
recommended: true
|
|
5932
5970
|
},
|
|
5933
5971
|
messages: {
|
|
5934
|
-
preferTableKit: "Individual TipTap table extension imported. Use TableKit from @tiptap/extension-table
|
|
5972
|
+
preferTableKit: "Individual TipTap table extension imported. Use TableKit from @tiptap/extension-table to configure all table elements together."
|
|
5935
5973
|
},
|
|
5936
5974
|
schema: []
|
|
5937
5975
|
},
|
|
@@ -5954,21 +5992,21 @@ var rule81 = {
|
|
|
5954
5992
|
};
|
|
5955
5993
|
}
|
|
5956
5994
|
};
|
|
5957
|
-
var tiptapPreferTableKitRule =
|
|
5995
|
+
var tiptapPreferTableKitRule = rule79;
|
|
5958
5996
|
|
|
5959
5997
|
// src/providers/tiptap/rules/tiptap-markdown-missing-node-spec.ts
|
|
5960
|
-
var
|
|
5998
|
+
var rule80 = {
|
|
5961
5999
|
meta: {
|
|
5962
6000
|
type: "suggestion",
|
|
5963
6001
|
docs: {
|
|
5964
6002
|
description: "TipTap nodes used with tiptap-markdown must define a markdown serialization spec",
|
|
5965
6003
|
category: "integration",
|
|
5966
|
-
rationale: "When tiptap-markdown serializes a document, nodes without a markdown spec are silently dropped or emitted as empty strings. Custom node types must register a serialize/parse pair
|
|
5967
|
-
docsUrl: "https://github.com/
|
|
6004
|
+
rationale: "When tiptap-markdown serializes a document, nodes without a markdown spec are silently dropped or emitted as empty strings. Custom node types must register a serialize/parse pair (MarkdownNodeSpec) under a `markdown` key returned from addStorage \u2014 the library reads only extension.storage.markdown \u2014 so content survives markdown export/import cycles.",
|
|
6005
|
+
docsUrl: "https://github.com/aguingand/tiptap-markdown",
|
|
5968
6006
|
recommended: true
|
|
5969
6007
|
},
|
|
5970
6008
|
messages: {
|
|
5971
|
-
missingMarkdownNodeSpec: "This TipTap node is used alongside tiptap-markdown but defines no markdown serialization. Nodes without a markdown spec are silently dropped on export.
|
|
6009
|
+
missingMarkdownNodeSpec: "This TipTap node is used alongside tiptap-markdown but defines no markdown serialization. Nodes without a markdown spec are silently dropped on export. Return a markdown spec (MarkdownNodeSpec) from addStorage."
|
|
5972
6010
|
},
|
|
5973
6011
|
schema: []
|
|
5974
6012
|
},
|
|
@@ -6023,7 +6061,2093 @@ var rule82 = {
|
|
|
6023
6061
|
};
|
|
6024
6062
|
}
|
|
6025
6063
|
};
|
|
6026
|
-
var tiptapTiptapMarkdownMissingNodeSpecRule =
|
|
6064
|
+
var tiptapTiptapMarkdownMissingNodeSpecRule = rule80;
|
|
6065
|
+
|
|
6066
|
+
// src/providers/elevenlabs/utils.ts
|
|
6067
|
+
function isElevenLabsUrlArg(node) {
|
|
6068
|
+
if (!node) return false;
|
|
6069
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
6070
|
+
return node.value.includes("elevenlabs.io");
|
|
6071
|
+
}
|
|
6072
|
+
if (node.type === "TemplateLiteral") {
|
|
6073
|
+
return (node.quasis ?? []).some(
|
|
6074
|
+
(q) => typeof q?.value?.raw === "string" && q.value.raw.includes("elevenlabs.io")
|
|
6075
|
+
);
|
|
6076
|
+
}
|
|
6077
|
+
return false;
|
|
6078
|
+
}
|
|
6079
|
+
function findElevenLabsFetchCall(node, depth = 0) {
|
|
6080
|
+
if (!node || typeof node !== "object" || depth > 20) return null;
|
|
6081
|
+
if (Array.isArray(node)) {
|
|
6082
|
+
for (const n of node) {
|
|
6083
|
+
const found = findElevenLabsFetchCall(n, depth + 1);
|
|
6084
|
+
if (found) return found;
|
|
6085
|
+
}
|
|
6086
|
+
return null;
|
|
6087
|
+
}
|
|
6088
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
6089
|
+
if (isElevenLabsUrlArg(node.arguments?.[0])) return node;
|
|
6090
|
+
}
|
|
6091
|
+
for (const key of Object.keys(node)) {
|
|
6092
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6093
|
+
const val = node[key];
|
|
6094
|
+
if (val && typeof val === "object") {
|
|
6095
|
+
const found = findElevenLabsFetchCall(val, depth + 1);
|
|
6096
|
+
if (found) return found;
|
|
6097
|
+
}
|
|
6098
|
+
}
|
|
6099
|
+
return null;
|
|
6100
|
+
}
|
|
6101
|
+
function unwrapAwait(node) {
|
|
6102
|
+
return node?.type === "AwaitExpression" ? node.argument : node;
|
|
6103
|
+
}
|
|
6104
|
+
function collectVarDeclarators(node, out, depth = 0) {
|
|
6105
|
+
if (!node || typeof node !== "object" || depth > 24) return;
|
|
6106
|
+
if (Array.isArray(node)) {
|
|
6107
|
+
for (const n of node) collectVarDeclarators(n, out, depth + 1);
|
|
6108
|
+
return;
|
|
6109
|
+
}
|
|
6110
|
+
if (node.type === "VariableDeclarator") out.push(node);
|
|
6111
|
+
for (const key of Object.keys(node)) {
|
|
6112
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6113
|
+
const val = node[key];
|
|
6114
|
+
if (val && typeof val === "object") collectVarDeclarators(val, out, depth + 1);
|
|
6115
|
+
}
|
|
6116
|
+
}
|
|
6117
|
+
function posOf(n) {
|
|
6118
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
6119
|
+
const line = n?.loc?.start?.line ?? 0;
|
|
6120
|
+
const column = n?.loc?.start?.column ?? 0;
|
|
6121
|
+
return line * 1e6 + column;
|
|
6122
|
+
}
|
|
6123
|
+
|
|
6124
|
+
// src/providers/elevenlabs/rules/validate-signed-url-response.ts
|
|
6125
|
+
var rule81 = {
|
|
6126
|
+
meta: {
|
|
6127
|
+
type: "problem",
|
|
6128
|
+
docs: {
|
|
6129
|
+
description: "ElevenLabs signed URL responses must be validated before the signed_url field is used",
|
|
6130
|
+
category: "correctness",
|
|
6131
|
+
cwe: "CWE-252",
|
|
6132
|
+
rationale: "The signed-url endpoint assumes the ElevenLabs API always returns a well-formed body. If the API ever returns an unexpected shape (error payload, empty body, schema change), `data.signed_url` is silently `undefined` and the failure only surfaces downstream when the client tries to connect with an invalid URL.",
|
|
6133
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-agents/api-reference/conversations/get-signed-url",
|
|
6134
|
+
recommended: true
|
|
6135
|
+
},
|
|
6136
|
+
messages: {
|
|
6137
|
+
missingValidation: "This code reads signed_url from the ElevenLabs API response without checking that the field exists. A malformed or unexpected response will silently produce an undefined signed URL."
|
|
6138
|
+
}
|
|
6139
|
+
},
|
|
6140
|
+
create(context) {
|
|
6141
|
+
function analyzeFunction(fnNode) {
|
|
6142
|
+
const declarators = [];
|
|
6143
|
+
collectVarDeclarators(fnNode.body, declarators);
|
|
6144
|
+
const responseVarNames = [];
|
|
6145
|
+
for (const d of declarators) {
|
|
6146
|
+
const init = unwrapAwait(d.init);
|
|
6147
|
+
if (init?.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "fetch" && isElevenLabsUrlArg(init.arguments?.[0]) && d.id?.type === "Identifier") {
|
|
6148
|
+
responseVarNames.push(d.id.name);
|
|
6149
|
+
}
|
|
6150
|
+
}
|
|
6151
|
+
if (responseVarNames.length === 0) return;
|
|
6152
|
+
for (const responseVarName of responseVarNames) {
|
|
6153
|
+
for (const d of declarators) {
|
|
6154
|
+
const init = unwrapAwait(d.init);
|
|
6155
|
+
if (init?.type === "CallExpression" && init.callee?.type === "MemberExpression" && init.callee.property?.type === "Identifier" && init.callee.property.name === "json" && init.callee.object?.type === "Identifier" && init.callee.object.name === responseVarName && d.id?.type === "Identifier") {
|
|
6156
|
+
analyzeDataVar(fnNode, d.id.name, d);
|
|
6157
|
+
}
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
function analyzeDataVar(fnNode, dataVarName, dataDeclaratorNode) {
|
|
6162
|
+
function isSignedUrlMember(n) {
|
|
6163
|
+
if (n?.type !== "MemberExpression") return false;
|
|
6164
|
+
if (n.object?.type !== "Identifier" || n.object.name !== dataVarName) return false;
|
|
6165
|
+
if (!n.computed) return n.property?.type === "Identifier" && n.property.name === "signed_url";
|
|
6166
|
+
return n.property?.type === "Literal" && n.property.value === "signed_url";
|
|
6167
|
+
}
|
|
6168
|
+
function isNullishLiteral(n) {
|
|
6169
|
+
return n?.type === "Identifier" && n.name === "undefined" || n?.type === "Literal" && n.value === null;
|
|
6170
|
+
}
|
|
6171
|
+
function isFalsyGuardOnSignedUrl(test) {
|
|
6172
|
+
if (!test) return false;
|
|
6173
|
+
if (test.type === "UnaryExpression" && test.operator === "!") {
|
|
6174
|
+
return isSignedUrlMember(test.argument);
|
|
6175
|
+
}
|
|
6176
|
+
if (test.type === "BinaryExpression" && (test.operator === "==" || test.operator === "===")) {
|
|
6177
|
+
return isSignedUrlMember(test.left) && isNullishLiteral(test.right) || isSignedUrlMember(test.right) && isNullishLiteral(test.left);
|
|
6178
|
+
}
|
|
6179
|
+
if (test.type === "LogicalExpression") {
|
|
6180
|
+
return isFalsyGuardOnSignedUrl(test.left) || isFalsyGuardOnSignedUrl(test.right);
|
|
6181
|
+
}
|
|
6182
|
+
return false;
|
|
6183
|
+
}
|
|
6184
|
+
let guardPos = null;
|
|
6185
|
+
let usagePos = null;
|
|
6186
|
+
function walk2(n, depth = 0) {
|
|
6187
|
+
if (!n || typeof n !== "object" || depth > 40) return;
|
|
6188
|
+
if (Array.isArray(n)) {
|
|
6189
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6190
|
+
return;
|
|
6191
|
+
}
|
|
6192
|
+
if (n.type === "IfStatement" && isFalsyGuardOnSignedUrl(n.test)) {
|
|
6193
|
+
const p = posOf(n);
|
|
6194
|
+
if (guardPos === null || p < guardPos) guardPos = p;
|
|
6195
|
+
}
|
|
6196
|
+
if (isSignedUrlMember(n) && n !== dataDeclaratorNode) {
|
|
6197
|
+
const p = posOf(n);
|
|
6198
|
+
if (usagePos === null || p < usagePos) usagePos = p;
|
|
6199
|
+
}
|
|
6200
|
+
for (const key of Object.keys(n)) {
|
|
6201
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6202
|
+
const val = n[key];
|
|
6203
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6204
|
+
}
|
|
6205
|
+
}
|
|
6206
|
+
walk2(fnNode.body);
|
|
6207
|
+
if (usagePos === null) return;
|
|
6208
|
+
if (guardPos !== null && guardPos < usagePos) return;
|
|
6209
|
+
context.report({ node: dataDeclaratorNode, messageId: "missingValidation" });
|
|
6210
|
+
}
|
|
6211
|
+
return {
|
|
6212
|
+
FunctionDeclaration(node) {
|
|
6213
|
+
analyzeFunction(node);
|
|
6214
|
+
},
|
|
6215
|
+
FunctionExpression(node) {
|
|
6216
|
+
analyzeFunction(node);
|
|
6217
|
+
},
|
|
6218
|
+
ArrowFunctionExpression(node) {
|
|
6219
|
+
analyzeFunction(node);
|
|
6220
|
+
}
|
|
6221
|
+
};
|
|
6222
|
+
}
|
|
6223
|
+
};
|
|
6224
|
+
var elevenlabsValidateSignedUrlResponseRule = rule81;
|
|
6225
|
+
|
|
6226
|
+
// src/providers/elevenlabs/rules/no-error-object-logging.ts
|
|
6227
|
+
var LOGGING_METHODS = /* @__PURE__ */ new Set(["error", "warn", "log"]);
|
|
6228
|
+
var rule82 = {
|
|
6229
|
+
meta: {
|
|
6230
|
+
type: "problem",
|
|
6231
|
+
docs: {
|
|
6232
|
+
description: "Catch blocks must not log the raw caught error object",
|
|
6233
|
+
category: "security",
|
|
6234
|
+
cwe: "CWE-532",
|
|
6235
|
+
rationale: "Error objects thrown by fetch/SDK calls can carry response bodies, headers, or internal state. Logging them directly with console.error(error) writes that data verbatim into server logs, which may be shipped to third-party log aggregators or be readable by anyone with log access.",
|
|
6236
|
+
docsUrl: "https://elevenlabs.io/docs/api-reference/authentication",
|
|
6237
|
+
recommended: true
|
|
6238
|
+
},
|
|
6239
|
+
messages: {
|
|
6240
|
+
rawErrorLogged: "This catch block logs the raw error object instead of a sanitized message \u2014 error responses, headers, or internal state may leak into server logs."
|
|
6241
|
+
}
|
|
6242
|
+
},
|
|
6243
|
+
create(context) {
|
|
6244
|
+
function isConsoleLogCall(node) {
|
|
6245
|
+
if (node?.type !== "CallExpression") return false;
|
|
6246
|
+
const callee = node.callee;
|
|
6247
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
6248
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "console") return false;
|
|
6249
|
+
return callee.property?.type === "Identifier" && LOGGING_METHODS.has(callee.property.name);
|
|
6250
|
+
}
|
|
6251
|
+
function referencesRawIdentifier(node, paramName, depth = 0) {
|
|
6252
|
+
if (!node || typeof node !== "object" || depth > 10) return false;
|
|
6253
|
+
if (Array.isArray(node)) return node.some((n) => referencesRawIdentifier(n, paramName, depth + 1));
|
|
6254
|
+
if (node.type === "Identifier" && node.name === paramName) return true;
|
|
6255
|
+
if (node.type === "ObjectExpression") {
|
|
6256
|
+
return (node.properties ?? []).some((p) => {
|
|
6257
|
+
if (p.type !== "Property") return false;
|
|
6258
|
+
return referencesRawIdentifier(p.value, paramName, depth + 1);
|
|
6259
|
+
});
|
|
6260
|
+
}
|
|
6261
|
+
if (node.type === "ArrayExpression") {
|
|
6262
|
+
return (node.elements ?? []).some((el) => referencesRawIdentifier(el, paramName, depth + 1));
|
|
6263
|
+
}
|
|
6264
|
+
return false;
|
|
6265
|
+
}
|
|
6266
|
+
return {
|
|
6267
|
+
TryStatement(node) {
|
|
6268
|
+
if (!findElevenLabsFetchCall(node.block)) return;
|
|
6269
|
+
const handler = node.handler;
|
|
6270
|
+
const paramName = handler?.param?.type === "Identifier" ? handler.param.name : null;
|
|
6271
|
+
if (!paramName) return;
|
|
6272
|
+
function walk2(n, depth = 0) {
|
|
6273
|
+
if (!n || typeof n !== "object" || depth > 30) return;
|
|
6274
|
+
if (Array.isArray(n)) {
|
|
6275
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6276
|
+
return;
|
|
6277
|
+
}
|
|
6278
|
+
if (isConsoleLogCall(n)) {
|
|
6279
|
+
const args = n.arguments ?? [];
|
|
6280
|
+
const loggedRaw = args.some((a) => referencesRawIdentifier(a, paramName));
|
|
6281
|
+
if (loggedRaw) {
|
|
6282
|
+
context.report({ node: n, messageId: "rawErrorLogged" });
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
for (const key of Object.keys(n)) {
|
|
6286
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6287
|
+
const val = n[key];
|
|
6288
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6289
|
+
}
|
|
6290
|
+
}
|
|
6291
|
+
walk2(handler.body);
|
|
6292
|
+
}
|
|
6293
|
+
};
|
|
6294
|
+
}
|
|
6295
|
+
};
|
|
6296
|
+
var elevenlabsNoErrorObjectLoggingRule = rule82;
|
|
6297
|
+
|
|
6298
|
+
// src/providers/elevenlabs/rules/fetch-timeout-required.ts
|
|
6299
|
+
var rule83 = {
|
|
6300
|
+
meta: {
|
|
6301
|
+
type: "suggestion",
|
|
6302
|
+
docs: {
|
|
6303
|
+
description: "Fetch calls to the ElevenLabs API must set an abort timeout",
|
|
6304
|
+
category: "reliability",
|
|
6305
|
+
rationale: "Native fetch has no default timeout. If the ElevenLabs API becomes slow or hangs, a request with no AbortController/signal will wait indefinitely, tying up the request handler and starving the server of available connections.",
|
|
6306
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-agents/api-reference/conversations/get-signed-url",
|
|
6307
|
+
recommended: true
|
|
6308
|
+
},
|
|
6309
|
+
messages: {
|
|
6310
|
+
missingTimeout: "This fetch call to the ElevenLabs API has no abort signal/timeout \u2014 a slow or unresponsive API will hang the request indefinitely."
|
|
6311
|
+
}
|
|
6312
|
+
},
|
|
6313
|
+
create(context) {
|
|
6314
|
+
function hasSignalOption(optionsArg) {
|
|
6315
|
+
if (optionsArg?.type !== "ObjectExpression") return false;
|
|
6316
|
+
return (optionsArg.properties ?? []).some((p) => {
|
|
6317
|
+
if (p.type === "SpreadElement") return true;
|
|
6318
|
+
return p.type === "Property" && p.key?.type === "Identifier" && p.key.name === "signal";
|
|
6319
|
+
});
|
|
6320
|
+
}
|
|
6321
|
+
return {
|
|
6322
|
+
CallExpression(node) {
|
|
6323
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return;
|
|
6324
|
+
if (!isElevenLabsUrlArg(node.arguments?.[0])) return;
|
|
6325
|
+
const optionsArg = node.arguments?.[1];
|
|
6326
|
+
if (hasSignalOption(optionsArg)) return;
|
|
6327
|
+
context.report({ node, messageId: "missingTimeout" });
|
|
6328
|
+
}
|
|
6329
|
+
};
|
|
6330
|
+
}
|
|
6331
|
+
};
|
|
6332
|
+
var elevenlabsFetchTimeoutRequiredRule = rule83;
|
|
6333
|
+
|
|
6334
|
+
// src/providers/elevenlabs/rules/validate-agent-id-format.ts
|
|
6335
|
+
var rule84 = {
|
|
6336
|
+
meta: {
|
|
6337
|
+
type: "problem",
|
|
6338
|
+
docs: {
|
|
6339
|
+
description: "agentId must be format-validated, not just checked for existence, before use",
|
|
6340
|
+
category: "correctness",
|
|
6341
|
+
cwe: "CWE-20",
|
|
6342
|
+
rationale: "A query-param agentId that is only checked with `if (!agentId)` accepts any non-empty string. An attacker can pass arbitrary values \u2014 oversized strings, path-traversal-like sequences, or characters the ElevenLabs API was never designed to receive \u2014 directly into the request URL, producing undefined behavior instead of a clean 400.",
|
|
6343
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-agents/customization/authentication",
|
|
6344
|
+
recommended: true
|
|
6345
|
+
},
|
|
6346
|
+
messages: {
|
|
6347
|
+
missingFormatValidation: "agentId is checked for existence but never validated against an expected format before being used in an ElevenLabs API request."
|
|
6348
|
+
}
|
|
6349
|
+
},
|
|
6350
|
+
create(context) {
|
|
6351
|
+
function isSearchParamsGetAgentId(node) {
|
|
6352
|
+
if (node?.type !== "CallExpression") return false;
|
|
6353
|
+
const callee = node.callee;
|
|
6354
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
6355
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "get") return false;
|
|
6356
|
+
const arg = node.arguments?.[0];
|
|
6357
|
+
return arg?.type === "Literal" && arg.value === "agentId";
|
|
6358
|
+
}
|
|
6359
|
+
function analyzeFunction(fnNode) {
|
|
6360
|
+
const declarators = [];
|
|
6361
|
+
collectVarDeclarators(fnNode.body, declarators);
|
|
6362
|
+
let agentIdVarName = null;
|
|
6363
|
+
let agentIdDeclaratorNode = null;
|
|
6364
|
+
for (const d of declarators) {
|
|
6365
|
+
const init = unwrapAwait(d.init);
|
|
6366
|
+
if (isSearchParamsGetAgentId(init) && d.id?.type === "Identifier") {
|
|
6367
|
+
agentIdVarName = d.id.name;
|
|
6368
|
+
agentIdDeclaratorNode = d;
|
|
6369
|
+
}
|
|
6370
|
+
}
|
|
6371
|
+
if (!agentIdVarName) {
|
|
6372
|
+
for (const param of fnNode.params ?? []) {
|
|
6373
|
+
if (param?.type === "Identifier" && param.name === "agentId") {
|
|
6374
|
+
agentIdVarName = param.name;
|
|
6375
|
+
agentIdDeclaratorNode = param;
|
|
6376
|
+
}
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
if (!agentIdVarName) return;
|
|
6380
|
+
function isAgentIdMember(n) {
|
|
6381
|
+
return n?.type === "Identifier" && n.name === agentIdVarName;
|
|
6382
|
+
}
|
|
6383
|
+
function isFormatCheck(n, depth = 0) {
|
|
6384
|
+
if (!n || typeof n !== "object" || depth > 10) return false;
|
|
6385
|
+
if (Array.isArray(n)) return n.some((x) => isFormatCheck(x, depth + 1));
|
|
6386
|
+
if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
|
|
6387
|
+
const propName2 = n.callee.property?.type === "Identifier" ? n.callee.property.name : null;
|
|
6388
|
+
if (propName2 === "test" && isAgentIdMember(n.arguments?.[0])) return true;
|
|
6389
|
+
if ((propName2 === "match" || propName2 === "matchAll") && isAgentIdMember(n.callee.object)) return true;
|
|
6390
|
+
}
|
|
6391
|
+
if (n.type === "LogicalExpression") {
|
|
6392
|
+
return isFormatCheck(n.left, depth + 1) || isFormatCheck(n.right, depth + 1);
|
|
6393
|
+
}
|
|
6394
|
+
if (n.type === "UnaryExpression") return isFormatCheck(n.argument, depth + 1);
|
|
6395
|
+
return false;
|
|
6396
|
+
}
|
|
6397
|
+
let formatGuardPos = null;
|
|
6398
|
+
let usagePos = null;
|
|
6399
|
+
function walk2(n, depth = 0) {
|
|
6400
|
+
if (!n || typeof n !== "object" || depth > 40) return;
|
|
6401
|
+
if (Array.isArray(n)) {
|
|
6402
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6403
|
+
return;
|
|
6404
|
+
}
|
|
6405
|
+
if (n.type === "IfStatement" && isFormatCheck(n.test)) {
|
|
6406
|
+
const p = posOf(n);
|
|
6407
|
+
if (formatGuardPos === null || p < formatGuardPos) formatGuardPos = p;
|
|
6408
|
+
}
|
|
6409
|
+
if (n.type === "CallExpression" && n.callee?.type === "Identifier" && n.callee.name === "fetch" && isElevenLabsUrlArg(n.arguments?.[0])) {
|
|
6410
|
+
const urlArg = n.arguments[0];
|
|
6411
|
+
const containsAgentId = urlArg.type === "TemplateLiteral" && (urlArg.expressions ?? []).some((e) => isAgentIdMember(e));
|
|
6412
|
+
if (containsAgentId) {
|
|
6413
|
+
const p = posOf(n);
|
|
6414
|
+
if (usagePos === null || p < usagePos) usagePos = p;
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
for (const key of Object.keys(n)) {
|
|
6418
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6419
|
+
const val = n[key];
|
|
6420
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6421
|
+
}
|
|
6422
|
+
}
|
|
6423
|
+
walk2(fnNode.body);
|
|
6424
|
+
if (usagePos === null) return;
|
|
6425
|
+
if (formatGuardPos !== null && formatGuardPos < usagePos) return;
|
|
6426
|
+
context.report({ node: agentIdDeclaratorNode, messageId: "missingFormatValidation" });
|
|
6427
|
+
}
|
|
6428
|
+
return {
|
|
6429
|
+
FunctionDeclaration(node) {
|
|
6430
|
+
analyzeFunction(node);
|
|
6431
|
+
},
|
|
6432
|
+
FunctionExpression(node) {
|
|
6433
|
+
analyzeFunction(node);
|
|
6434
|
+
},
|
|
6435
|
+
ArrowFunctionExpression(node) {
|
|
6436
|
+
analyzeFunction(node);
|
|
6437
|
+
}
|
|
6438
|
+
};
|
|
6439
|
+
}
|
|
6440
|
+
};
|
|
6441
|
+
var elevenlabsValidateAgentIdFormatRule = rule84;
|
|
6442
|
+
|
|
6443
|
+
// src/providers/elevenlabs/rules/secure-session-id-generation.ts
|
|
6444
|
+
var rule85 = {
|
|
6445
|
+
meta: {
|
|
6446
|
+
type: "problem",
|
|
6447
|
+
docs: {
|
|
6448
|
+
description: "Session ids must be generated with a cryptographically secure RNG",
|
|
6449
|
+
category: "security",
|
|
6450
|
+
cwe: "CWE-338",
|
|
6451
|
+
rationale: 'Math.random() is a non-cryptographic PRNG \u2014 its output can be predicted by an attacker who observes enough samples or knows the engine implementation. If a "session_*" value derived from it is later trusted for routing, deduplication, or any access-control-adjacent decision, that predictability can be exploited.',
|
|
6452
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-api/guides/how-to/best-practices/security",
|
|
6453
|
+
recommended: true
|
|
6454
|
+
},
|
|
6455
|
+
messages: {
|
|
6456
|
+
insecureSessionId: "This session id is generated with Math.random(), which is not cryptographically secure and can be predicted. Use crypto.getRandomValues() instead."
|
|
6457
|
+
}
|
|
6458
|
+
},
|
|
6459
|
+
create(context) {
|
|
6460
|
+
function containsMathRandomCall(node, depth = 0) {
|
|
6461
|
+
if (!node || typeof node !== "object" || depth > 20) return false;
|
|
6462
|
+
if (Array.isArray(node)) return node.some((n) => containsMathRandomCall(n, depth + 1));
|
|
6463
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Math" && node.callee.property?.type === "Identifier" && node.callee.property.name === "random") {
|
|
6464
|
+
return true;
|
|
6465
|
+
}
|
|
6466
|
+
for (const key of Object.keys(node)) {
|
|
6467
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6468
|
+
const val = node[key];
|
|
6469
|
+
if (val && typeof val === "object" && containsMathRandomCall(val, depth + 1)) return true;
|
|
6470
|
+
}
|
|
6471
|
+
return false;
|
|
6472
|
+
}
|
|
6473
|
+
function looksLikeSessionId(name) {
|
|
6474
|
+
return typeof name === "string" && /session/i.test(name);
|
|
6475
|
+
}
|
|
6476
|
+
function isSessionIdValue(init) {
|
|
6477
|
+
if (init?.type === "TemplateLiteral") {
|
|
6478
|
+
const firstQuasi = init.quasis?.[0]?.value?.raw ?? "";
|
|
6479
|
+
if (/session[-_]?/i.test(firstQuasi) && containsMathRandomCall(init)) return true;
|
|
6480
|
+
}
|
|
6481
|
+
if (init?.type === "BinaryExpression" && init.operator === "+") {
|
|
6482
|
+
const leftLiteral = init.left?.type === "Literal" && typeof init.left.value === "string";
|
|
6483
|
+
if (leftLiteral && /session[-_]?/i.test(init.left.value) && containsMathRandomCall(init)) return true;
|
|
6484
|
+
}
|
|
6485
|
+
return false;
|
|
6486
|
+
}
|
|
6487
|
+
return {
|
|
6488
|
+
VariableDeclarator(node) {
|
|
6489
|
+
const init = node.init;
|
|
6490
|
+
if (!init) return;
|
|
6491
|
+
const declaredName = node.id?.type === "Identifier" ? node.id.name : node.id?.type === "ArrayPattern" && node.id.elements?.[0]?.type === "Identifier" ? node.id.elements[0].name : void 0;
|
|
6492
|
+
let valueNode = init;
|
|
6493
|
+
if (init.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "useState" && init.arguments?.[0]?.type === "ArrowFunctionExpression") {
|
|
6494
|
+
const body = init.arguments[0].body;
|
|
6495
|
+
valueNode = body?.type === "BlockStatement" ? null : body;
|
|
6496
|
+
if (body?.type === "BlockStatement") {
|
|
6497
|
+
const returnStmt = (body.body ?? []).find((s) => s.type === "ReturnStatement");
|
|
6498
|
+
valueNode = returnStmt?.argument ?? null;
|
|
6499
|
+
}
|
|
6500
|
+
}
|
|
6501
|
+
if (!valueNode) return;
|
|
6502
|
+
if (!looksLikeSessionId(declaredName) && !isSessionIdValue(valueNode)) return;
|
|
6503
|
+
if (!containsMathRandomCall(valueNode)) return;
|
|
6504
|
+
context.report({ node, messageId: "insecureSessionId" });
|
|
6505
|
+
}
|
|
6506
|
+
};
|
|
6507
|
+
}
|
|
6508
|
+
};
|
|
6509
|
+
var elevenlabsSecureSessionIdGenerationRule = rule85;
|
|
6510
|
+
|
|
6511
|
+
// src/providers/elevenlabs/rules/conversation-error-recovery.ts
|
|
6512
|
+
var rule86 = {
|
|
6513
|
+
meta: {
|
|
6514
|
+
type: "suggestion",
|
|
6515
|
+
docs: {
|
|
6516
|
+
description: "A loading flag set before starting a conversation must be reset on failure",
|
|
6517
|
+
category: "reliability",
|
|
6518
|
+
rationale: 'If startConversation() sets isLoading true and getSignedUrl()/Conversation.startSession() throws, the loading flag must be reset in the catch or finally block. Otherwise the UI is stuck "loading" forever and the user can trigger overlapping conversation attempts by clicking again.',
|
|
6519
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-agents/libraries/java-script",
|
|
6520
|
+
recommended: true
|
|
6521
|
+
},
|
|
6522
|
+
messages: {
|
|
6523
|
+
missingLoadingReset: "This function sets a loading flag true before a try block, but neither the catch nor a finally block resets it to false on failure."
|
|
6524
|
+
}
|
|
6525
|
+
},
|
|
6526
|
+
create(context) {
|
|
6527
|
+
function isSetterCallWithBoolean(n, setterName, expected) {
|
|
6528
|
+
if (n?.type !== "CallExpression") return false;
|
|
6529
|
+
if (n.callee?.type !== "Identifier" || n.callee.name !== setterName) return false;
|
|
6530
|
+
const arg = n.arguments?.[0];
|
|
6531
|
+
return arg?.type === "Literal" && arg.value === expected;
|
|
6532
|
+
}
|
|
6533
|
+
function containsCall(node, predicate, depth = 0) {
|
|
6534
|
+
if (!node || typeof node !== "object" || depth > 20) return false;
|
|
6535
|
+
if (Array.isArray(node)) return node.some((n) => containsCall(n, predicate, depth + 1));
|
|
6536
|
+
if (predicate(node)) return true;
|
|
6537
|
+
for (const key of Object.keys(node)) {
|
|
6538
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6539
|
+
const val = node[key];
|
|
6540
|
+
if (val && typeof val === "object" && containsCall(val, predicate, depth + 1)) return true;
|
|
6541
|
+
}
|
|
6542
|
+
return false;
|
|
6543
|
+
}
|
|
6544
|
+
function findLoadingSetterFromUseState(fnNode) {
|
|
6545
|
+
let setterName = null;
|
|
6546
|
+
function collect(n, depth = 0) {
|
|
6547
|
+
if (setterName || !n || typeof n !== "object" || depth > 20) return;
|
|
6548
|
+
if (Array.isArray(n)) {
|
|
6549
|
+
for (const item of n) collect(item, depth + 1);
|
|
6550
|
+
return;
|
|
6551
|
+
}
|
|
6552
|
+
if (n.type === "VariableDeclarator" && n.id?.type === "ArrayPattern" && n.id.elements?.length === 2 && n.id.elements[0]?.type === "Identifier" && /loading/i.test(n.id.elements[0].name) && n.id.elements[1]?.type === "Identifier" && n.init?.type === "CallExpression" && n.init.callee?.type === "Identifier" && n.init.callee.name === "useState") {
|
|
6553
|
+
setterName = n.id.elements[1].name;
|
|
6554
|
+
return;
|
|
6555
|
+
}
|
|
6556
|
+
for (const key of Object.keys(n)) {
|
|
6557
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6558
|
+
const val = n[key];
|
|
6559
|
+
if (val && typeof val === "object") collect(val, depth + 1);
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
collect(fnNode);
|
|
6563
|
+
return setterName;
|
|
6564
|
+
}
|
|
6565
|
+
function isLoadingTrueStatement(stmt, setterName) {
|
|
6566
|
+
return stmt?.type === "ExpressionStatement" && isSetterCallWithBoolean(stmt.expression, setterName, true);
|
|
6567
|
+
}
|
|
6568
|
+
function analyzeFunction(fnNode) {
|
|
6569
|
+
const setterName = findLoadingSetterFromUseState(fnNode);
|
|
6570
|
+
if (!setterName) return;
|
|
6571
|
+
function walk2(n, depth = 0) {
|
|
6572
|
+
if (!n || typeof n !== "object" || depth > 30) return;
|
|
6573
|
+
if (Array.isArray(n)) {
|
|
6574
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6575
|
+
return;
|
|
6576
|
+
}
|
|
6577
|
+
if (n.type === "BlockStatement" && Array.isArray(n.body)) {
|
|
6578
|
+
for (let i = 0; i < n.body.length - 1; i++) {
|
|
6579
|
+
if (!isLoadingTrueStatement(n.body[i], setterName)) continue;
|
|
6580
|
+
const tryNode = n.body[i + 1];
|
|
6581
|
+
if (tryNode?.type !== "TryStatement") continue;
|
|
6582
|
+
const handler = tryNode.handler;
|
|
6583
|
+
const finalizer = tryNode.finalizer;
|
|
6584
|
+
const finallyResets = finalizer && containsCall(finalizer, (x) => isSetterCallWithBoolean(x, setterName, false));
|
|
6585
|
+
const catchResets = handler && containsCall(handler.body, (x) => isSetterCallWithBoolean(x, setterName, false));
|
|
6586
|
+
if (!finallyResets && !catchResets) {
|
|
6587
|
+
context.report({ node: tryNode, messageId: "missingLoadingReset" });
|
|
6588
|
+
}
|
|
6589
|
+
}
|
|
6590
|
+
}
|
|
6591
|
+
for (const key of Object.keys(n)) {
|
|
6592
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6593
|
+
const val = n[key];
|
|
6594
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
walk2(fnNode.body);
|
|
6598
|
+
}
|
|
6599
|
+
return {
|
|
6600
|
+
FunctionDeclaration(node) {
|
|
6601
|
+
analyzeFunction(node);
|
|
6602
|
+
},
|
|
6603
|
+
FunctionExpression(node) {
|
|
6604
|
+
analyzeFunction(node);
|
|
6605
|
+
},
|
|
6606
|
+
ArrowFunctionExpression(node) {
|
|
6607
|
+
analyzeFunction(node);
|
|
6608
|
+
}
|
|
6609
|
+
};
|
|
6610
|
+
}
|
|
6611
|
+
};
|
|
6612
|
+
var elevenlabsConversationErrorRecoveryRule = rule86;
|
|
6613
|
+
|
|
6614
|
+
// src/providers/elevenlabs/rules/api-version-pinning.ts
|
|
6615
|
+
var rule87 = {
|
|
6616
|
+
meta: {
|
|
6617
|
+
type: "suggestion",
|
|
6618
|
+
docs: {
|
|
6619
|
+
description: "ElevenLabs API requests should pin an explicit API version header",
|
|
6620
|
+
category: "reliability",
|
|
6621
|
+
rationale: "The endpoint is hardcoded to /v1 in the URL path with no explicit version header. If ElevenLabs ships a v2 API and changes default response behavior for unversioned clients, requests with no version header silently pick up the new behavior instead of failing loudly or staying pinned.",
|
|
6622
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-api/resources/breaking-changes-policy",
|
|
6623
|
+
recommended: true
|
|
6624
|
+
},
|
|
6625
|
+
messages: {
|
|
6626
|
+
missingVersionHeader: "This fetch call to the ElevenLabs API has no explicit API version header \u2014 a future API change could silently alter behavior."
|
|
6627
|
+
}
|
|
6628
|
+
},
|
|
6629
|
+
create(context) {
|
|
6630
|
+
function hasVersionHeader(optionsArg) {
|
|
6631
|
+
if (optionsArg?.type !== "ObjectExpression") return false;
|
|
6632
|
+
const headersProp = (optionsArg.properties ?? []).find(
|
|
6633
|
+
(p) => p.type === "Property" && p.key?.type === "Identifier" && p.key.name === "headers"
|
|
6634
|
+
);
|
|
6635
|
+
if (!headersProp) return false;
|
|
6636
|
+
if (headersProp.value?.type !== "ObjectExpression") return true;
|
|
6637
|
+
return (headersProp.value.properties ?? []).some((p) => {
|
|
6638
|
+
if (p.type === "SpreadElement") return true;
|
|
6639
|
+
if (p.type !== "Property") return false;
|
|
6640
|
+
const keyName = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : null;
|
|
6641
|
+
return typeof keyName === "string" && /version/i.test(keyName);
|
|
6642
|
+
});
|
|
6643
|
+
}
|
|
6644
|
+
return {
|
|
6645
|
+
CallExpression(node) {
|
|
6646
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return;
|
|
6647
|
+
if (!isElevenLabsUrlArg(node.arguments?.[0])) return;
|
|
6648
|
+
const optionsArg = node.arguments?.[1];
|
|
6649
|
+
if (hasVersionHeader(optionsArg)) return;
|
|
6650
|
+
context.report({ node, messageId: "missingVersionHeader" });
|
|
6651
|
+
}
|
|
6652
|
+
};
|
|
6653
|
+
}
|
|
6654
|
+
};
|
|
6655
|
+
var elevenlabsApiVersionPinningRule = rule87;
|
|
6656
|
+
|
|
6657
|
+
// src/providers/elevenlabs/rules/check-http-status-before-json.ts
|
|
6658
|
+
var rule88 = {
|
|
6659
|
+
meta: {
|
|
6660
|
+
type: "problem",
|
|
6661
|
+
docs: {
|
|
6662
|
+
description: "response.json() must not be called before checking the HTTP status of an ElevenLabs response",
|
|
6663
|
+
category: "correctness",
|
|
6664
|
+
rationale: "Calling response.json() unconditionally parses whatever body the server returned, including error payloads, as if it were a successful response. Without checking response.ok (or response.status) first, a 4xx/5xx error from the ElevenLabs API is silently treated as valid data.",
|
|
6665
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-api/resources/errors",
|
|
6666
|
+
recommended: true
|
|
6667
|
+
},
|
|
6668
|
+
messages: {
|
|
6669
|
+
missingStatusCheck: "response.json() is called on this ElevenLabs API response without first checking response.ok/status \u2014 an error response will be parsed as if it were valid data."
|
|
6670
|
+
}
|
|
6671
|
+
},
|
|
6672
|
+
create(context) {
|
|
6673
|
+
function analyzeFunction(fnNode) {
|
|
6674
|
+
const declarators = [];
|
|
6675
|
+
collectVarDeclarators(fnNode.body, declarators);
|
|
6676
|
+
const responseVarNames = [];
|
|
6677
|
+
for (const d of declarators) {
|
|
6678
|
+
const init = unwrapAwait(d.init);
|
|
6679
|
+
if (init?.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "fetch" && isElevenLabsUrlArg(init.arguments?.[0]) && d.id?.type === "Identifier") {
|
|
6680
|
+
responseVarNames.push(d.id.name);
|
|
6681
|
+
}
|
|
6682
|
+
}
|
|
6683
|
+
for (const responseVarName of responseVarNames) {
|
|
6684
|
+
analyzeResponseVar(fnNode, responseVarName);
|
|
6685
|
+
}
|
|
6686
|
+
}
|
|
6687
|
+
function analyzeResponseVar(fnNode, responseVarName) {
|
|
6688
|
+
function isResponseStatusCheck(test) {
|
|
6689
|
+
if (!test) return false;
|
|
6690
|
+
if (test.type === "UnaryExpression" && test.operator === "!") return isResponseStatusCheck(test.argument);
|
|
6691
|
+
if (test.type === "MemberExpression" && test.object?.type === "Identifier" && test.object.name === responseVarName && test.property?.type === "Identifier" && test.property.name === "ok") {
|
|
6692
|
+
return true;
|
|
6693
|
+
}
|
|
6694
|
+
if (test.type === "BinaryExpression") {
|
|
6695
|
+
const sideIsStatus = (n) => n?.type === "MemberExpression" && n.object?.type === "Identifier" && n.object.name === responseVarName && n.property?.type === "Identifier" && n.property.name === "status";
|
|
6696
|
+
if (sideIsStatus(test.left) || sideIsStatus(test.right)) return true;
|
|
6697
|
+
}
|
|
6698
|
+
if (test.type === "LogicalExpression") {
|
|
6699
|
+
return isResponseStatusCheck(test.left) || isResponseStatusCheck(test.right);
|
|
6700
|
+
}
|
|
6701
|
+
return false;
|
|
6702
|
+
}
|
|
6703
|
+
function isResponseJsonCall2(n) {
|
|
6704
|
+
if (n?.type !== "CallExpression") return false;
|
|
6705
|
+
const callee = n.callee;
|
|
6706
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
6707
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "json") return false;
|
|
6708
|
+
return callee.object?.type === "Identifier" && callee.object.name === responseVarName;
|
|
6709
|
+
}
|
|
6710
|
+
let guardPos = null;
|
|
6711
|
+
let jsonCallNode = null;
|
|
6712
|
+
let jsonCallPos = null;
|
|
6713
|
+
function walk2(n, depth = 0) {
|
|
6714
|
+
if (!n || typeof n !== "object" || depth > 40) return;
|
|
6715
|
+
if (Array.isArray(n)) {
|
|
6716
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6717
|
+
return;
|
|
6718
|
+
}
|
|
6719
|
+
if (n.type === "IfStatement" && isResponseStatusCheck(n.test)) {
|
|
6720
|
+
const p = posOf(n);
|
|
6721
|
+
if (guardPos === null || p < guardPos) guardPos = p;
|
|
6722
|
+
}
|
|
6723
|
+
if (isResponseJsonCall2(n)) {
|
|
6724
|
+
const p = posOf(n);
|
|
6725
|
+
if (jsonCallPos === null || p < jsonCallPos) {
|
|
6726
|
+
jsonCallPos = p;
|
|
6727
|
+
jsonCallNode = n;
|
|
6728
|
+
}
|
|
6729
|
+
}
|
|
6730
|
+
for (const key of Object.keys(n)) {
|
|
6731
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6732
|
+
const val = n[key];
|
|
6733
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6734
|
+
}
|
|
6735
|
+
}
|
|
6736
|
+
walk2(fnNode.body);
|
|
6737
|
+
if (jsonCallPos === null) return;
|
|
6738
|
+
if (guardPos !== null && guardPos < jsonCallPos) return;
|
|
6739
|
+
context.report({ node: jsonCallNode, messageId: "missingStatusCheck" });
|
|
6740
|
+
}
|
|
6741
|
+
return {
|
|
6742
|
+
FunctionDeclaration(node) {
|
|
6743
|
+
analyzeFunction(node);
|
|
6744
|
+
},
|
|
6745
|
+
FunctionExpression(node) {
|
|
6746
|
+
analyzeFunction(node);
|
|
6747
|
+
},
|
|
6748
|
+
ArrowFunctionExpression(node) {
|
|
6749
|
+
analyzeFunction(node);
|
|
6750
|
+
}
|
|
6751
|
+
};
|
|
6752
|
+
}
|
|
6753
|
+
};
|
|
6754
|
+
var elevenlabsCheckHttpStatusBeforeJsonRule = rule88;
|
|
6755
|
+
|
|
6756
|
+
// src/providers/elevenlabs/rules/conversation-cleanup-on-error.ts
|
|
6757
|
+
var END_METHODS = /* @__PURE__ */ new Set(["endSession", "endConversation"]);
|
|
6758
|
+
var rule89 = {
|
|
6759
|
+
meta: {
|
|
6760
|
+
type: "suggestion",
|
|
6761
|
+
docs: {
|
|
6762
|
+
description: "Ending a conversation must be wrapped in try/catch so a rejected call is handled",
|
|
6763
|
+
category: "reliability",
|
|
6764
|
+
rationale: "If conversation.endSession()/endConversation() rejects (e.g. during a GL-mode transition) and the call site has no try/catch, the rejection becomes an unhandled promise rejection and the conversation state is never cleaned up, leaving stale connections in memory.",
|
|
6765
|
+
docsUrl: "https://elevenlabs.io/docs/eleven-agents/libraries/java-script",
|
|
6766
|
+
recommended: true
|
|
6767
|
+
},
|
|
6768
|
+
messages: {
|
|
6769
|
+
missingTryCatch: "This call to end the conversation has no surrounding try/catch \u2014 a rejection will go unhandled and the conversation state may never be cleaned up."
|
|
6770
|
+
}
|
|
6771
|
+
},
|
|
6772
|
+
create(context) {
|
|
6773
|
+
function posEnd(n) {
|
|
6774
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
6775
|
+
const line = n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0;
|
|
6776
|
+
const column = n?.loc?.end?.column ?? n?.loc?.start?.column ?? 0;
|
|
6777
|
+
return line * 1e6 + column;
|
|
6778
|
+
}
|
|
6779
|
+
function isConversationEndCall(node) {
|
|
6780
|
+
if (node?.type !== "CallExpression") return false;
|
|
6781
|
+
const callee = node.callee;
|
|
6782
|
+
if (callee?.type === "MemberExpression") {
|
|
6783
|
+
return callee.property?.type === "Identifier" && END_METHODS.has(callee.property.name);
|
|
6784
|
+
}
|
|
6785
|
+
return callee?.type === "Identifier" && END_METHODS.has(callee.name);
|
|
6786
|
+
}
|
|
6787
|
+
return {
|
|
6788
|
+
"Program:exit"(program) {
|
|
6789
|
+
const tryRanges = [];
|
|
6790
|
+
const endCalls = [];
|
|
6791
|
+
function walk2(n, depth = 0) {
|
|
6792
|
+
if (!n || typeof n !== "object" || depth > 60) return;
|
|
6793
|
+
if (Array.isArray(n)) {
|
|
6794
|
+
for (const item of n) walk2(item, depth + 1);
|
|
6795
|
+
return;
|
|
6796
|
+
}
|
|
6797
|
+
if (n.type === "TryStatement" && n.block) {
|
|
6798
|
+
tryRanges.push([posOf(n.block), posEnd(n.block)]);
|
|
6799
|
+
}
|
|
6800
|
+
if (isConversationEndCall(n)) endCalls.push(n);
|
|
6801
|
+
for (const key of Object.keys(n)) {
|
|
6802
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6803
|
+
const val = n[key];
|
|
6804
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
walk2(program);
|
|
6808
|
+
for (const call of endCalls) {
|
|
6809
|
+
const p = posOf(call);
|
|
6810
|
+
const covered = tryRanges.some(([start, end]) => p >= start && p <= end);
|
|
6811
|
+
if (!covered) {
|
|
6812
|
+
context.report({ node: call, messageId: "missingTryCatch" });
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
};
|
|
6817
|
+
}
|
|
6818
|
+
};
|
|
6819
|
+
var elevenlabsConversationCleanupOnErrorRule = rule89;
|
|
6820
|
+
|
|
6821
|
+
// src/providers/elevenlabs/rules/env-var-validation.ts
|
|
6822
|
+
var ELEVENLABS_ENV_VAR_PATTERN = /^(XI_API_KEY|ELEVENLABS_API_KEY)$/;
|
|
6823
|
+
var rule90 = {
|
|
6824
|
+
meta: {
|
|
6825
|
+
type: "suggestion",
|
|
6826
|
+
docs: {
|
|
6827
|
+
description: "ElevenLabs API key environment variables should be validated at module load, not only at request time",
|
|
6828
|
+
category: "correctness",
|
|
6829
|
+
rationale: "Checking `if (!apiKey)` inside a request handler means a misconfigured deployment only fails the first time a user exercises the feature, instead of failing fast at startup where it would show up in deploy logs or a health check.",
|
|
6830
|
+
docsUrl: "https://elevenlabs.io/docs/api-reference/authentication",
|
|
6831
|
+
recommended: true
|
|
6832
|
+
},
|
|
6833
|
+
messages: {
|
|
6834
|
+
missingStartupValidation: "This module reads an ElevenLabs API key from process.env but only validates it inside a request handler \u2014 validate it at module scope so a missing key fails at startup, not at first use."
|
|
6835
|
+
}
|
|
6836
|
+
},
|
|
6837
|
+
create(context) {
|
|
6838
|
+
function isElevenLabsEnvAccess(node) {
|
|
6839
|
+
if (node?.type !== "MemberExpression") return false;
|
|
6840
|
+
if (node.object?.type !== "MemberExpression") return false;
|
|
6841
|
+
const inner = node.object;
|
|
6842
|
+
if (inner.object?.type !== "Identifier" || inner.object.name !== "process") return false;
|
|
6843
|
+
if (inner.property?.type !== "Identifier" || inner.property.name !== "env") return false;
|
|
6844
|
+
const propName2 = node.property?.type === "Identifier" ? node.property.name : node.property?.value;
|
|
6845
|
+
return typeof propName2 === "string" && ELEVENLABS_ENV_VAR_PATTERN.test(propName2);
|
|
6846
|
+
}
|
|
6847
|
+
function isInsideFunction(node, ancestors) {
|
|
6848
|
+
return ancestors.some(
|
|
6849
|
+
(a) => a?.type === "FunctionDeclaration" || a?.type === "FunctionExpression" || a?.type === "ArrowFunctionExpression"
|
|
6850
|
+
);
|
|
6851
|
+
}
|
|
6852
|
+
return {
|
|
6853
|
+
"Program:exit"(program) {
|
|
6854
|
+
let moduleScopeAccess = false;
|
|
6855
|
+
let handlerScopeAccess = false;
|
|
6856
|
+
function walk2(n, ancestors, depth = 0) {
|
|
6857
|
+
if (!n || typeof n !== "object" || depth > 60) return;
|
|
6858
|
+
if (Array.isArray(n)) {
|
|
6859
|
+
for (const item of n) walk2(item, ancestors, depth + 1);
|
|
6860
|
+
return;
|
|
6861
|
+
}
|
|
6862
|
+
if (isElevenLabsEnvAccess(n)) {
|
|
6863
|
+
if (isInsideFunction(n, ancestors)) {
|
|
6864
|
+
handlerScopeAccess = true;
|
|
6865
|
+
} else {
|
|
6866
|
+
moduleScopeAccess = true;
|
|
6867
|
+
}
|
|
6868
|
+
}
|
|
6869
|
+
const nextAncestors = n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression" ? [...ancestors, n] : ancestors;
|
|
6870
|
+
for (const key of Object.keys(n)) {
|
|
6871
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6872
|
+
const val = n[key];
|
|
6873
|
+
if (val && typeof val === "object") walk2(val, nextAncestors, depth + 1);
|
|
6874
|
+
}
|
|
6875
|
+
}
|
|
6876
|
+
walk2(program, []);
|
|
6877
|
+
if (handlerScopeAccess && !moduleScopeAccess) {
|
|
6878
|
+
context.report({ node: program, messageId: "missingStartupValidation" });
|
|
6879
|
+
}
|
|
6880
|
+
}
|
|
6881
|
+
};
|
|
6882
|
+
}
|
|
6883
|
+
};
|
|
6884
|
+
var elevenlabsEnvVarValidationRule = rule90;
|
|
6885
|
+
|
|
6886
|
+
// src/providers/twilio/utils.ts
|
|
6887
|
+
var POST_METHOD_NAMES = /* @__PURE__ */ new Set(["post"]);
|
|
6888
|
+
var ROUTER_OBJECT_NAMES = /* @__PURE__ */ new Set(["server", "app", "router", "fastify"]);
|
|
6889
|
+
function isPostRouteRegistration(node) {
|
|
6890
|
+
if (node?.type !== "CallExpression") return false;
|
|
6891
|
+
const callee = node.callee;
|
|
6892
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
6893
|
+
if (callee.property?.type !== "Identifier" || !POST_METHOD_NAMES.has(callee.property.name)) return false;
|
|
6894
|
+
return callee.object?.type === "Identifier" && ROUTER_OBJECT_NAMES.has(callee.object.name);
|
|
6895
|
+
}
|
|
6896
|
+
function findInSubtree(node, predicate, depth = 0) {
|
|
6897
|
+
if (!node || typeof node !== "object" || depth > 40) return null;
|
|
6898
|
+
if (Array.isArray(node)) {
|
|
6899
|
+
for (const n of node) {
|
|
6900
|
+
const found = findInSubtree(n, predicate, depth + 1);
|
|
6901
|
+
if (found) return found;
|
|
6902
|
+
}
|
|
6903
|
+
return null;
|
|
6904
|
+
}
|
|
6905
|
+
if (predicate(node)) return node;
|
|
6906
|
+
for (const key of Object.keys(node)) {
|
|
6907
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6908
|
+
const val = node[key];
|
|
6909
|
+
if (val && typeof val === "object") {
|
|
6910
|
+
const found = findInSubtree(val, predicate, depth + 1);
|
|
6911
|
+
if (found) return found;
|
|
6912
|
+
}
|
|
6913
|
+
}
|
|
6914
|
+
return null;
|
|
6915
|
+
}
|
|
6916
|
+
function referencesRequestBody(node) {
|
|
6917
|
+
return !!findInSubtree(node, (n) => {
|
|
6918
|
+
if (n?.type !== "MemberExpression") return false;
|
|
6919
|
+
if (n.property?.type !== "Identifier" || n.property.name !== "body") return false;
|
|
6920
|
+
return n.object?.type === "Identifier" && (n.object.name === "req" || n.object.name === "request");
|
|
6921
|
+
});
|
|
6922
|
+
}
|
|
6923
|
+
function collectVarDeclarators2(node, out, depth = 0) {
|
|
6924
|
+
if (!node || typeof node !== "object" || depth > 24) return;
|
|
6925
|
+
if (Array.isArray(node)) {
|
|
6926
|
+
for (const n of node) collectVarDeclarators2(n, out, depth + 1);
|
|
6927
|
+
return;
|
|
6928
|
+
}
|
|
6929
|
+
if (node.type === "VariableDeclarator") out.push(node);
|
|
6930
|
+
for (const key of Object.keys(node)) {
|
|
6931
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
6932
|
+
const val = node[key];
|
|
6933
|
+
if (val && typeof val === "object") collectVarDeclarators2(val, out, depth + 1);
|
|
6934
|
+
}
|
|
6935
|
+
}
|
|
6936
|
+
|
|
6937
|
+
// src/providers/twilio/rules/validate-webhook-signature.ts
|
|
6938
|
+
var rule91 = {
|
|
6939
|
+
meta: {
|
|
6940
|
+
type: "problem",
|
|
6941
|
+
docs: {
|
|
6942
|
+
description: "Twilio webhook routes must validate the X-Twilio-Signature header before trusting the body",
|
|
6943
|
+
category: "security",
|
|
6944
|
+
cwe: "CWE-345",
|
|
6945
|
+
owasp: "A07:2021 Identification and Authentication Failures",
|
|
6946
|
+
rationale: "Webhook URLs are public. Anyone who learns or guesses the path can POST a forged body \u2014 fake CallSid/From/TaskAttributes values \u2014 and the handler has no way to tell it apart from a real Twilio request. Without validating the signature first, an attacker can trigger outbound calls (toll/billing impact) or falsely invoke reservation/task callbacks to manipulate call state for an arbitrary caller.",
|
|
6947
|
+
docsUrl: "https://www.twilio.com/docs/usage/webhooks/webhooks-security",
|
|
6948
|
+
recommended: true
|
|
6949
|
+
},
|
|
6950
|
+
messages: {
|
|
6951
|
+
missingSignatureValidation: "This POST webhook route reads req.body but the file never validates the X-Twilio-Signature header via twilio.validateRequest()/RequestValidator \u2014 a forged request body is indistinguishable from a real Twilio callback."
|
|
6952
|
+
}
|
|
6953
|
+
},
|
|
6954
|
+
create(context) {
|
|
6955
|
+
function isValidateRequestCall(n) {
|
|
6956
|
+
if (n?.type !== "CallExpression") return false;
|
|
6957
|
+
const callee = n.callee;
|
|
6958
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
6959
|
+
return callee.property?.type === "Identifier" && callee.property.name === "validateRequest";
|
|
6960
|
+
}
|
|
6961
|
+
function isRequestValidatorConstruction(n) {
|
|
6962
|
+
return n?.type === "NewExpression" && n.callee?.type === "Identifier" && n.callee.name === "RequestValidator";
|
|
6963
|
+
}
|
|
6964
|
+
return {
|
|
6965
|
+
"Program:exit"(program) {
|
|
6966
|
+
const hasValidation = !!findInSubtree(program, isValidateRequestCall) || !!findInSubtree(program, isRequestValidatorConstruction);
|
|
6967
|
+
if (hasValidation) return;
|
|
6968
|
+
const postRoutes = [];
|
|
6969
|
+
findInSubtree(program, (n) => {
|
|
6970
|
+
if (isPostRouteRegistration(n) && referencesRequestBody(n)) postRoutes.push(n);
|
|
6971
|
+
return false;
|
|
6972
|
+
});
|
|
6973
|
+
for (const route of postRoutes) {
|
|
6974
|
+
context.report({ node: route, messageId: "missingSignatureValidation" });
|
|
6975
|
+
}
|
|
6976
|
+
}
|
|
6977
|
+
};
|
|
6978
|
+
}
|
|
6979
|
+
};
|
|
6980
|
+
var twilioValidateWebhookSignatureRule = rule91;
|
|
6981
|
+
|
|
6982
|
+
// src/providers/twilio/rules/taskrouter-attributes-match-consumer.ts
|
|
6983
|
+
var rule92 = {
|
|
6984
|
+
meta: {
|
|
6985
|
+
type: "problem",
|
|
6986
|
+
docs: {
|
|
6987
|
+
description: "TaskRouter Task attributes must include every field the reservation handler reads back out",
|
|
6988
|
+
category: "correctness",
|
|
6989
|
+
rationale: "A Task is created with attributes like `{ name, type }`, and later a reservation-accepted handler does `const { from } = JSON.parse(req.body.TaskAttributes)` to look up state by `from`. If the producer never set `from` on the Task attributes, that destructure is always `undefined`, the lookup always misses, and the handler returns 404 \u2014 even though every other part of the flow worked correctly.",
|
|
6990
|
+
docsUrl: "https://www.twilio.com/docs/taskrouter/twiml-queue-calls",
|
|
6991
|
+
recommended: true
|
|
6992
|
+
},
|
|
6993
|
+
messages: {
|
|
6994
|
+
attributeMismatch: 'This .task() attributes object does not set "{{field}}", but a reservation handler in this codebase destructures "{{field}}" from JSON.parse(TaskAttributes) \u2014 that lookup will always be undefined.'
|
|
6995
|
+
}
|
|
6996
|
+
},
|
|
6997
|
+
create(context) {
|
|
6998
|
+
function isTaskCall(n) {
|
|
6999
|
+
if (n?.type !== "CallExpression") return false;
|
|
7000
|
+
const callee = n.callee;
|
|
7001
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7002
|
+
return callee.property?.type === "Identifier" && callee.property.name === "task";
|
|
7003
|
+
}
|
|
7004
|
+
function extractAttributeKeys(taskCallNode) {
|
|
7005
|
+
const arg = taskCallNode.arguments?.[0];
|
|
7006
|
+
if (!arg) return null;
|
|
7007
|
+
if (arg.type === "CallExpression" && arg.callee?.type === "MemberExpression" && arg.callee.object?.type === "Identifier" && arg.callee.object.name === "JSON" && arg.callee.property?.type === "Identifier" && arg.callee.property.name === "stringify" && arg.arguments?.[0]?.type === "ObjectExpression") {
|
|
7008
|
+
return objectKeys(arg.arguments[0]);
|
|
7009
|
+
}
|
|
7010
|
+
if (arg.type === "TemplateLiteral") {
|
|
7011
|
+
const raw = arg.quasis.map((q) => q.value?.raw ?? "").join("");
|
|
7012
|
+
const keys = /* @__PURE__ */ new Set();
|
|
7013
|
+
for (const m of raw.matchAll(/"([a-zA-Z0-9_]+)"\s*:/g)) keys.add(m[1]);
|
|
7014
|
+
return keys;
|
|
7015
|
+
}
|
|
7016
|
+
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
7017
|
+
const keys = /* @__PURE__ */ new Set();
|
|
7018
|
+
for (const m of arg.value.matchAll(/"([a-zA-Z0-9_]+)"\s*:/g)) keys.add(m[1]);
|
|
7019
|
+
return keys;
|
|
7020
|
+
}
|
|
7021
|
+
return null;
|
|
7022
|
+
}
|
|
7023
|
+
function objectKeys(objExpr) {
|
|
7024
|
+
const keys = /* @__PURE__ */ new Set();
|
|
7025
|
+
for (const p of objExpr.properties ?? []) {
|
|
7026
|
+
if (p.type !== "Property") continue;
|
|
7027
|
+
const name = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : null;
|
|
7028
|
+
if (typeof name === "string") keys.add(name);
|
|
7029
|
+
}
|
|
7030
|
+
return keys;
|
|
7031
|
+
}
|
|
7032
|
+
function isTaskAttributesJsonParse(n) {
|
|
7033
|
+
if (n?.type !== "CallExpression") return false;
|
|
7034
|
+
const callee = n.callee;
|
|
7035
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7036
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "JSON") return false;
|
|
7037
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "parse") return false;
|
|
7038
|
+
const arg = n.arguments?.[0];
|
|
7039
|
+
if (arg?.type !== "MemberExpression") return false;
|
|
7040
|
+
return arg.property?.type === "Identifier" && arg.property.name === "TaskAttributes";
|
|
7041
|
+
}
|
|
7042
|
+
function findConsumedFields(program) {
|
|
7043
|
+
const fields = /* @__PURE__ */ new Set();
|
|
7044
|
+
const declarators = [];
|
|
7045
|
+
collectVarDeclarators2(program, declarators);
|
|
7046
|
+
for (const d of declarators) {
|
|
7047
|
+
if (!isTaskAttributesJsonParse(d.init)) continue;
|
|
7048
|
+
if (d.id?.type === "ObjectPattern") {
|
|
7049
|
+
for (const p of d.id.properties ?? []) {
|
|
7050
|
+
if (p.type !== "Property") continue;
|
|
7051
|
+
const name = p.key?.type === "Identifier" ? p.key.name : null;
|
|
7052
|
+
if (name) fields.add(name);
|
|
7053
|
+
}
|
|
7054
|
+
}
|
|
7055
|
+
}
|
|
7056
|
+
return fields;
|
|
7057
|
+
}
|
|
7058
|
+
return {
|
|
7059
|
+
"Program:exit"(program) {
|
|
7060
|
+
const consumedFields = findConsumedFields(program);
|
|
7061
|
+
if (consumedFields.size === 0) return;
|
|
7062
|
+
const taskCalls = [];
|
|
7063
|
+
findInSubtree(program, (n) => {
|
|
7064
|
+
if (isTaskCall(n)) taskCalls.push(n);
|
|
7065
|
+
return false;
|
|
7066
|
+
});
|
|
7067
|
+
for (const taskCall of taskCalls) {
|
|
7068
|
+
const producedKeys = extractAttributeKeys(taskCall);
|
|
7069
|
+
if (!producedKeys) continue;
|
|
7070
|
+
for (const field of consumedFields) {
|
|
7071
|
+
if (!producedKeys.has(field)) {
|
|
7072
|
+
context.report({ node: taskCall, messageId: "attributeMismatch", data: { field } });
|
|
7073
|
+
}
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
}
|
|
7077
|
+
};
|
|
7078
|
+
}
|
|
7079
|
+
};
|
|
7080
|
+
var twilioTaskrouterAttributesMatchConsumerRule = rule92;
|
|
7081
|
+
|
|
7082
|
+
// src/providers/twilio/rules/enqueue-task-json-stringify.ts
|
|
7083
|
+
var rule93 = {
|
|
7084
|
+
meta: {
|
|
7085
|
+
type: "problem",
|
|
7086
|
+
docs: {
|
|
7087
|
+
description: "TaskRouter .task() attributes must be built with JSON.stringify(), not raw string interpolation",
|
|
7088
|
+
category: "security",
|
|
7089
|
+
cwe: "CWE-116",
|
|
7090
|
+
owasp: "CWE-91 XML/JSON Injection",
|
|
7091
|
+
rationale: 'Hand-building the Task attributes JSON by interpolating an unvalidated request field directly into a string literal breaks the moment that field contains a `"` or `\\`. The resulting body is invalid JSON, and Twilio rejects the Enqueue with error 14221 ("Provided Attributes JSON was not valid") \u2014 turning a single unexpected character in a caller-controlled field (like a name with a quote in it) into a hard failure of the call flow.',
|
|
7092
|
+
docsUrl: "https://www.twilio.com/docs/api/errors/14221",
|
|
7093
|
+
recommended: true
|
|
7094
|
+
},
|
|
7095
|
+
messages: {
|
|
7096
|
+
rawJsonInterpolation: 'This .task() call builds JSON attributes via raw string interpolation instead of JSON.stringify() \u2014 a "/\\\\ character in the interpolated value produces invalid JSON and Twilio error 14221.'
|
|
7097
|
+
}
|
|
7098
|
+
},
|
|
7099
|
+
create(context) {
|
|
7100
|
+
function isTaskCall(n) {
|
|
7101
|
+
if (n?.type !== "CallExpression") return false;
|
|
7102
|
+
const callee = n.callee;
|
|
7103
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7104
|
+
return callee.property?.type === "Identifier" && callee.property.name === "task";
|
|
7105
|
+
}
|
|
7106
|
+
function hasInterpolatedExpression(templateLiteral) {
|
|
7107
|
+
return (templateLiteral.expressions ?? []).length > 0;
|
|
7108
|
+
}
|
|
7109
|
+
return {
|
|
7110
|
+
CallExpression(node) {
|
|
7111
|
+
if (!isTaskCall(node)) return;
|
|
7112
|
+
const arg = node.arguments?.[0];
|
|
7113
|
+
if (!arg) return;
|
|
7114
|
+
if (arg.type === "TemplateLiteral" && hasInterpolatedExpression(arg)) {
|
|
7115
|
+
context.report({ node, messageId: "rawJsonInterpolation" });
|
|
7116
|
+
return;
|
|
7117
|
+
}
|
|
7118
|
+
if (arg.type === "BinaryExpression" && arg.operator === "+") {
|
|
7119
|
+
context.report({ node, messageId: "rawJsonInterpolation" });
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
};
|
|
7123
|
+
}
|
|
7124
|
+
};
|
|
7125
|
+
var twilioEnqueueTaskJsonStringifyRule = rule93;
|
|
7126
|
+
|
|
7127
|
+
// src/providers/twilio/rules/media-streams-key-by-call-sid.ts
|
|
7128
|
+
var rule94 = {
|
|
7129
|
+
meta: {
|
|
7130
|
+
type: "problem",
|
|
7131
|
+
docs: {
|
|
7132
|
+
description: "Media Streams session maps must be keyed by callSid, not phone number",
|
|
7133
|
+
category: "reliability",
|
|
7134
|
+
rationale: "A Map keyed on the caller's phone number breaks the moment the same number places two concurrent calls \u2014 a retry after a dropped call, two family members sharing a line, a misdial-and-redial. The second map.set() silently overwrites the first call's entry, and that first call's downstream events (reservation-accepted, outbound-leg wiring) end up pointed at the wrong session, crossing audio streams between two unrelated calls. Twilio provides a unique callSid on every start message specifically to avoid this collision.",
|
|
7135
|
+
docsUrl: "https://www.twilio.com/docs/voice/media-streams/websocket-messages",
|
|
7136
|
+
recommended: true
|
|
7137
|
+
},
|
|
7138
|
+
messages: {
|
|
7139
|
+
keyedByPhoneNumber: `This Map is keyed by "{{key}}" (a phone-number-like field) instead of callSid \u2014 two concurrent calls from the same number will silently overwrite each other's entry.`
|
|
7140
|
+
}
|
|
7141
|
+
},
|
|
7142
|
+
create(context) {
|
|
7143
|
+
function isFromLikeIdentifier(n) {
|
|
7144
|
+
if (n?.type === "Identifier" && n.name === "from") return "from";
|
|
7145
|
+
if (n?.type === "MemberExpression" && n.property?.type === "Identifier" && n.property.name === "from" && !n.computed) {
|
|
7146
|
+
return "from";
|
|
7147
|
+
}
|
|
7148
|
+
return null;
|
|
7149
|
+
}
|
|
7150
|
+
function isMapMutationCall(n, methodNames) {
|
|
7151
|
+
if (n?.type !== "CallExpression") return false;
|
|
7152
|
+
const callee = n.callee;
|
|
7153
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7154
|
+
return callee.property?.type === "Identifier" && methodNames.has(callee.property.name);
|
|
7155
|
+
}
|
|
7156
|
+
const SET_OR_GET = /* @__PURE__ */ new Set(["set", "get"]);
|
|
7157
|
+
return {
|
|
7158
|
+
"Program:exit"(program) {
|
|
7159
|
+
let callSidAvailable = false;
|
|
7160
|
+
function scanForCallSid(n, depth = 0) {
|
|
7161
|
+
if (callSidAvailable || !n || typeof n !== "object" || depth > 60) return;
|
|
7162
|
+
if (Array.isArray(n)) {
|
|
7163
|
+
for (const item of n) scanForCallSid(item, depth + 1);
|
|
7164
|
+
return;
|
|
7165
|
+
}
|
|
7166
|
+
if (n.type === "Identifier" && n.name === "callSid") {
|
|
7167
|
+
callSidAvailable = true;
|
|
7168
|
+
return;
|
|
7169
|
+
}
|
|
7170
|
+
if (n.type === "MemberExpression" && !n.computed && n.property?.type === "Identifier" && n.property.name === "callSid") {
|
|
7171
|
+
callSidAvailable = true;
|
|
7172
|
+
return;
|
|
7173
|
+
}
|
|
7174
|
+
for (const key of Object.keys(n)) {
|
|
7175
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7176
|
+
const val = n[key];
|
|
7177
|
+
if (val && typeof val === "object") scanForCallSid(val, depth + 1);
|
|
7178
|
+
}
|
|
7179
|
+
}
|
|
7180
|
+
scanForCallSid(program);
|
|
7181
|
+
if (!callSidAvailable) return;
|
|
7182
|
+
function walk2(n, depth = 0) {
|
|
7183
|
+
if (!n || typeof n !== "object" || depth > 60) return;
|
|
7184
|
+
if (Array.isArray(n)) {
|
|
7185
|
+
for (const item of n) walk2(item, depth + 1);
|
|
7186
|
+
return;
|
|
7187
|
+
}
|
|
7188
|
+
if (isMapMutationCall(n, SET_OR_GET)) {
|
|
7189
|
+
const firstArg = n.arguments?.[0];
|
|
7190
|
+
const key = isFromLikeIdentifier(firstArg);
|
|
7191
|
+
if (key) context.report({ node: n, messageId: "keyedByPhoneNumber", data: { key } });
|
|
7192
|
+
}
|
|
7193
|
+
for (const k of Object.keys(n)) {
|
|
7194
|
+
if (k === "parent" || k === "loc" || k === "range") continue;
|
|
7195
|
+
const val = n[k];
|
|
7196
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
7197
|
+
}
|
|
7198
|
+
}
|
|
7199
|
+
walk2(program);
|
|
7200
|
+
}
|
|
7201
|
+
};
|
|
7202
|
+
}
|
|
7203
|
+
};
|
|
7204
|
+
var twilioMediaStreamsKeyByCallSidRule = rule94;
|
|
7205
|
+
|
|
7206
|
+
// src/providers/twilio/rules/await-or-catch-rest-calls-in-event-handlers.ts
|
|
7207
|
+
var REST_RESOURCE_METHODS = /* @__PURE__ */ new Set(["create", "update", "remove", "fetch"]);
|
|
7208
|
+
var EVENT_REGISTRATION_METHODS = /* @__PURE__ */ new Set(["onStart", "onStop", "onMedia", "onConnected", "on"]);
|
|
7209
|
+
var rule95 = {
|
|
7210
|
+
meta: {
|
|
7211
|
+
type: "problem",
|
|
7212
|
+
docs: {
|
|
7213
|
+
description: "Twilio REST calls inside event-handler callbacks must be wrapped in try/catch",
|
|
7214
|
+
category: "reliability",
|
|
7215
|
+
rationale: 'Event-emitter style callbacks (onStart, onMessage, on("message", ...)) are typically invoked via something like `callbacks.map((cb) => cb(event))`, which discards the returned promise. If the callback is `async` and calls `await twilio.calls.create(...)` with no try/catch, any Twilio REST error (invalid number, suspended account, rate limit) becomes an unhandled promise rejection. If the process has a global `unhandledRejection` handler that calls `process.exit()` (a common pattern), one failed outbound call takes down the entire server and every other in-progress call, not just the failing one.',
|
|
7216
|
+
docsUrl: "https://www.twilio.com/docs/usage/webhooks/webhooks-faq",
|
|
7217
|
+
recommended: true
|
|
7218
|
+
},
|
|
7219
|
+
messages: {
|
|
7220
|
+
missingTryCatch: "This await twilio.{{resource}}.{{method}}() call is inside an event-handler callback with no surrounding try/catch \u2014 a REST API error here becomes an unhandled promise rejection that can crash the whole process."
|
|
7221
|
+
}
|
|
7222
|
+
},
|
|
7223
|
+
create(context) {
|
|
7224
|
+
function isAwaitedTwilioRestCall(n) {
|
|
7225
|
+
if (n?.type !== "AwaitExpression") return null;
|
|
7226
|
+
const call = n.argument;
|
|
7227
|
+
if (call?.type !== "CallExpression") return null;
|
|
7228
|
+
const callee = call.callee;
|
|
7229
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
7230
|
+
const method = callee.property?.type === "Identifier" ? callee.property.name : null;
|
|
7231
|
+
if (!method || !REST_RESOURCE_METHODS.has(method)) return null;
|
|
7232
|
+
const obj = callee.object;
|
|
7233
|
+
if (obj?.type !== "MemberExpression") return null;
|
|
7234
|
+
const resource = obj.property?.type === "Identifier" ? obj.property.name : null;
|
|
7235
|
+
if (!resource) return null;
|
|
7236
|
+
const base = obj.object;
|
|
7237
|
+
const baseName = base?.type === "Identifier" ? base.name : null;
|
|
7238
|
+
if (!baseName || !/^(twilio\w*|client)$/i.test(baseName)) return null;
|
|
7239
|
+
return { resource, method };
|
|
7240
|
+
}
|
|
7241
|
+
function isEventHandlerRegistration(n) {
|
|
7242
|
+
if (n?.type !== "CallExpression") return false;
|
|
7243
|
+
const callee = n.callee;
|
|
7244
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7245
|
+
return callee.property?.type === "Identifier" && EVENT_REGISTRATION_METHODS.has(callee.property.name);
|
|
7246
|
+
}
|
|
7247
|
+
function isAsyncCallback(n) {
|
|
7248
|
+
return (n?.type === "ArrowFunctionExpression" || n?.type === "FunctionExpression") && n.async === true;
|
|
7249
|
+
}
|
|
7250
|
+
function posOf2(n) {
|
|
7251
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
7252
|
+
const line = n?.loc?.start?.line ?? 0;
|
|
7253
|
+
const column = n?.loc?.start?.column ?? 0;
|
|
7254
|
+
return line * 1e6 + column;
|
|
7255
|
+
}
|
|
7256
|
+
function posEnd(n) {
|
|
7257
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
7258
|
+
const line = n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0;
|
|
7259
|
+
const column = n?.loc?.end?.column ?? n?.loc?.start?.column ?? 0;
|
|
7260
|
+
return line * 1e6 + column;
|
|
7261
|
+
}
|
|
7262
|
+
function isWithinTryBlock(node, root) {
|
|
7263
|
+
const tryRanges = [];
|
|
7264
|
+
function collectTryRanges(n, depth = 0) {
|
|
7265
|
+
if (!n || typeof n !== "object" || depth > 60) return;
|
|
7266
|
+
if (Array.isArray(n)) {
|
|
7267
|
+
for (const item of n) collectTryRanges(item, depth + 1);
|
|
7268
|
+
return;
|
|
7269
|
+
}
|
|
7270
|
+
if (n.type === "TryStatement" && n.block) {
|
|
7271
|
+
tryRanges.push([posOf2(n.block), posEnd(n.block)]);
|
|
7272
|
+
}
|
|
7273
|
+
for (const key of Object.keys(n)) {
|
|
7274
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7275
|
+
const val = n[key];
|
|
7276
|
+
if (val && typeof val === "object") collectTryRanges(val, depth + 1);
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
7279
|
+
collectTryRanges(root);
|
|
7280
|
+
const p = posOf2(node);
|
|
7281
|
+
return tryRanges.some(([start, end]) => p >= start && p <= end);
|
|
7282
|
+
}
|
|
7283
|
+
return {
|
|
7284
|
+
CallExpression(node) {
|
|
7285
|
+
if (!isEventHandlerRegistration(node)) return;
|
|
7286
|
+
const callback = node.arguments?.find((a) => isAsyncCallback(a));
|
|
7287
|
+
if (!callback) return;
|
|
7288
|
+
const restCalls = [];
|
|
7289
|
+
function collect(n, depth = 0) {
|
|
7290
|
+
if (!n || typeof n !== "object" || depth > 40) return;
|
|
7291
|
+
if (Array.isArray(n)) {
|
|
7292
|
+
for (const item of n) collect(item, depth + 1);
|
|
7293
|
+
return;
|
|
7294
|
+
}
|
|
7295
|
+
const info = isAwaitedTwilioRestCall(n);
|
|
7296
|
+
if (info) restCalls.push({ node: n, ...info });
|
|
7297
|
+
for (const key of Object.keys(n)) {
|
|
7298
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7299
|
+
const val = n[key];
|
|
7300
|
+
if (val && typeof val === "object") collect(val, depth + 1);
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
collect(callback.body);
|
|
7304
|
+
for (const call of restCalls) {
|
|
7305
|
+
if (!isWithinTryBlock(call.node, callback.body)) {
|
|
7306
|
+
context.report({
|
|
7307
|
+
node: call.node,
|
|
7308
|
+
messageId: "missingTryCatch",
|
|
7309
|
+
data: { resource: call.resource, method: call.method }
|
|
7310
|
+
});
|
|
7311
|
+
}
|
|
7312
|
+
}
|
|
7313
|
+
}
|
|
7314
|
+
};
|
|
7315
|
+
}
|
|
7316
|
+
};
|
|
7317
|
+
var twilioAwaitOrCatchRestCallsInEventHandlersRule = rule95;
|
|
7318
|
+
|
|
7319
|
+
// src/providers/twilio/rules/use-twiml-builder-not-string-templates.ts
|
|
7320
|
+
var rule96 = {
|
|
7321
|
+
meta: {
|
|
7322
|
+
type: "suggestion",
|
|
7323
|
+
docs: {
|
|
7324
|
+
description: "TwiML responses must be built with the VoiceResponse builder, not raw XML template strings",
|
|
7325
|
+
category: "security",
|
|
7326
|
+
cwe: "CWE-91",
|
|
7327
|
+
rationale: "The VoiceResponse builder XML-escapes every interpolated value automatically. A raw template literal that interpolates values directly into XML attribute or text positions has no such protection \u2014 if either value's source ever changes to something attacker-influenced (e.g. a SIP header instead of a Twilio-controlled CallSid), unescaped `<`/`\"`/`&` characters can break out of the intended XML structure.",
|
|
7328
|
+
docsUrl: "https://www.twilio.com/docs/voice/twiml/stream",
|
|
7329
|
+
recommended: true
|
|
7330
|
+
},
|
|
7331
|
+
messages: {
|
|
7332
|
+
rawTwimlTemplate: "TwiML is built here as a raw template literal with interpolated values instead of the VoiceResponse builder \u2014 interpolated values are not XML-escaped, unlike new VoiceResponse()."
|
|
7333
|
+
}
|
|
7334
|
+
},
|
|
7335
|
+
create(context) {
|
|
7336
|
+
function looksLikeTwimlXml(raw) {
|
|
7337
|
+
return /<Response>|<Connect>|<Say>|<Stream\b|<Enqueue\b|<Dial\b/.test(raw);
|
|
7338
|
+
}
|
|
7339
|
+
return {
|
|
7340
|
+
TemplateLiteral(node) {
|
|
7341
|
+
if ((node.expressions ?? []).length === 0) return;
|
|
7342
|
+
const raw = (node.quasis ?? []).map((q) => q.value?.raw ?? "").join("");
|
|
7343
|
+
if (!looksLikeTwimlXml(raw)) return;
|
|
7344
|
+
context.report({ node, messageId: "rawTwimlTemplate" });
|
|
7345
|
+
}
|
|
7346
|
+
};
|
|
7347
|
+
}
|
|
7348
|
+
};
|
|
7349
|
+
var twilioUseTwimlBuilderNotStringTemplatesRule = rule96;
|
|
7350
|
+
|
|
7351
|
+
// src/providers/twilio/rules/media-streams-mark-pacing.ts
|
|
7352
|
+
var rule97 = {
|
|
7353
|
+
meta: {
|
|
7354
|
+
type: "suggestion",
|
|
7355
|
+
docs: {
|
|
7356
|
+
description: "Media Streams audio forwarding should use mark-based pacing to avoid buffer overflow",
|
|
7357
|
+
category: "reliability",
|
|
7358
|
+
rationale: 'Twilio buffers at most 10 minutes of audio per bidirectional Stream. If audio chunks are forwarded with no mark events and no pacing, and the sender produces media faster than real-time (or playback stalls), Twilio stops accepting further audio for that Stream and emits warning 31931 ("Media Discarded") \u2014 silently dropping audio rather than erroring loudly.',
|
|
7359
|
+
docsUrl: "https://www.twilio.com/docs/api/errors/31931",
|
|
7360
|
+
recommended: true
|
|
7361
|
+
},
|
|
7362
|
+
messages: {
|
|
7363
|
+
noMarkPacing: "This file forwards media payloads via send() but never passes isLast/a mark argument anywhere \u2014 sustained high-throughput forwarding risks Twilio silently discarding buffered audio (warning 31931)."
|
|
7364
|
+
}
|
|
7365
|
+
},
|
|
7366
|
+
create(context) {
|
|
7367
|
+
function isMediaSendCall(n) {
|
|
7368
|
+
if (n?.type !== "CallExpression") return false;
|
|
7369
|
+
const callee = n.callee;
|
|
7370
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7371
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "send") return false;
|
|
7372
|
+
const arg = n.arguments?.[0];
|
|
7373
|
+
const argText = arg?.type === "ArrayExpression" && (arg.elements ?? []).some(
|
|
7374
|
+
(el) => el?.type === "MemberExpression" && el.property?.type === "Identifier" && el.property.name === "delta"
|
|
7375
|
+
) || arg?.type === "Identifier" && /delta|payload|media/i.test(arg.name);
|
|
7376
|
+
return !!argText;
|
|
7377
|
+
}
|
|
7378
|
+
function hasIsLastOrMarkSignal(program) {
|
|
7379
|
+
let found = false;
|
|
7380
|
+
function walk2(n, depth = 0) {
|
|
7381
|
+
if (found || !n || typeof n !== "object" || depth > 60) return;
|
|
7382
|
+
if (Array.isArray(n)) {
|
|
7383
|
+
for (const item of n) walk2(item, depth + 1);
|
|
7384
|
+
return;
|
|
7385
|
+
}
|
|
7386
|
+
if (typeof n.type === "string" && n.type.startsWith("TS")) return;
|
|
7387
|
+
if (n.type === "CallExpression") {
|
|
7388
|
+
const callee = n.callee;
|
|
7389
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.name === "send") {
|
|
7390
|
+
const second = n.arguments?.[1];
|
|
7391
|
+
if (second?.type === "Literal" && second.value === true) {
|
|
7392
|
+
found = true;
|
|
7393
|
+
return;
|
|
7394
|
+
}
|
|
7395
|
+
if (second?.type === "Identifier" && /isLast/i.test(second.name)) {
|
|
7396
|
+
found = true;
|
|
7397
|
+
return;
|
|
7398
|
+
}
|
|
7399
|
+
}
|
|
7400
|
+
}
|
|
7401
|
+
if (n.type === "Identifier" && n.name === "isLast") {
|
|
7402
|
+
found = true;
|
|
7403
|
+
return;
|
|
7404
|
+
}
|
|
7405
|
+
for (const key of Object.keys(n)) {
|
|
7406
|
+
if (key === "parent" || key === "loc" || key === "range" || key === "typeAnnotation" || key === "returnType") continue;
|
|
7407
|
+
const val = n[key];
|
|
7408
|
+
if (val && typeof val === "object") walk2(val, depth + 1);
|
|
7409
|
+
}
|
|
7410
|
+
}
|
|
7411
|
+
walk2(program);
|
|
7412
|
+
return found;
|
|
7413
|
+
}
|
|
7414
|
+
return {
|
|
7415
|
+
"Program:exit"(program) {
|
|
7416
|
+
const sendCalls = [];
|
|
7417
|
+
findInSubtree(program, (n) => {
|
|
7418
|
+
if (isMediaSendCall(n)) sendCalls.push(n);
|
|
7419
|
+
return false;
|
|
7420
|
+
});
|
|
7421
|
+
if (sendCalls.length === 0) return;
|
|
7422
|
+
if (hasIsLastOrMarkSignal(program)) return;
|
|
7423
|
+
context.report({ node: sendCalls[0], messageId: "noMarkPacing" });
|
|
7424
|
+
}
|
|
7425
|
+
};
|
|
7426
|
+
}
|
|
7427
|
+
};
|
|
7428
|
+
var twilioMediaStreamsMarkPacingRule = rule97;
|
|
7429
|
+
|
|
7430
|
+
// src/providers/twilio/rules/validate-all-request-inputs.ts
|
|
7431
|
+
var rule98 = {
|
|
7432
|
+
meta: {
|
|
7433
|
+
type: "suggestion",
|
|
7434
|
+
docs: {
|
|
7435
|
+
description: "Fastify Twilio webhook routes must declare a schema for every request input they read",
|
|
7436
|
+
category: "correctness",
|
|
7437
|
+
rationale: "A route that validates `body` but not `querystring` (or declares no schema at all) lets unvalidated, possibly-undefined fields flow downstream. If a later call does `value.toString()` or similar on a field that never arrived, that throws instead of failing the request cleanly with a 400 \u2014 the failure surfaces far from the actual missing-validation root cause.",
|
|
7438
|
+
docsUrl: "https://www.twilio.com/docs/voice/twiml/stream",
|
|
7439
|
+
recommended: true
|
|
7440
|
+
},
|
|
7441
|
+
messages: {
|
|
7442
|
+
missingQuerystringSchema: "This route reads req.query.{{field}} but the route schema validates body without a matching querystring schema \u2014 a missing query param flows downstream as undefined instead of failing with a 400.",
|
|
7443
|
+
missingSchemaEntirely: "This route reads req.body.{{field}} but declares no request schema at all \u2014 req.body is untyped and unvalidated before use."
|
|
7444
|
+
}
|
|
7445
|
+
},
|
|
7446
|
+
create(context) {
|
|
7447
|
+
function getOptionsArg(routeCall) {
|
|
7448
|
+
return routeCall.arguments?.length === 3 ? routeCall.arguments[1] : null;
|
|
7449
|
+
}
|
|
7450
|
+
function getHandlerArg(routeCall) {
|
|
7451
|
+
const args = routeCall.arguments ?? [];
|
|
7452
|
+
return args[args.length - 1];
|
|
7453
|
+
}
|
|
7454
|
+
function hasSchemaProperty(optionsArg, propName2) {
|
|
7455
|
+
if (optionsArg?.type !== "ObjectExpression") return false;
|
|
7456
|
+
const schemaProp = (optionsArg.properties ?? []).find(
|
|
7457
|
+
(p) => p.type === "Property" && p.key?.type === "Identifier" && p.key.name === "schema"
|
|
7458
|
+
);
|
|
7459
|
+
if (!schemaProp || schemaProp.value?.type !== "ObjectExpression") return false;
|
|
7460
|
+
return (schemaProp.value.properties ?? []).some(
|
|
7461
|
+
(p) => p.type === "Property" && p.key?.type === "Identifier" && p.key.name === propName2
|
|
7462
|
+
);
|
|
7463
|
+
}
|
|
7464
|
+
function hasAnySchema(optionsArg) {
|
|
7465
|
+
if (optionsArg?.type !== "ObjectExpression") return false;
|
|
7466
|
+
return (optionsArg.properties ?? []).some(
|
|
7467
|
+
(p) => p.type === "Property" && p.key?.type === "Identifier" && p.key.name === "schema"
|
|
7468
|
+
);
|
|
7469
|
+
}
|
|
7470
|
+
function findReadFields(handlerBody, objectNames) {
|
|
7471
|
+
const fields = /* @__PURE__ */ new Set();
|
|
7472
|
+
findInSubtree(handlerBody, (n) => {
|
|
7473
|
+
if (n?.type !== "MemberExpression") return false;
|
|
7474
|
+
const obj = n.object;
|
|
7475
|
+
if (obj?.type !== "MemberExpression") return false;
|
|
7476
|
+
if (obj.object?.type !== "Identifier" || obj.object.name !== "req" && obj.object.name !== "request") return false;
|
|
7477
|
+
if (obj.property?.type !== "Identifier" || !objectNames.has(obj.property.name)) return false;
|
|
7478
|
+
const field = n.property?.type === "Identifier" ? n.property.name : null;
|
|
7479
|
+
if (field) fields.add(field);
|
|
7480
|
+
return false;
|
|
7481
|
+
});
|
|
7482
|
+
return fields;
|
|
7483
|
+
}
|
|
7484
|
+
return {
|
|
7485
|
+
CallExpression(node) {
|
|
7486
|
+
if (!isPostRouteRegistration(node)) return;
|
|
7487
|
+
const optionsArg = getOptionsArg(node);
|
|
7488
|
+
const handler = getHandlerArg(node);
|
|
7489
|
+
if (!handler || handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return;
|
|
7490
|
+
const queryFields = findReadFields(handler.body, /* @__PURE__ */ new Set(["query"]));
|
|
7491
|
+
const bodyFields = findReadFields(handler.body, /* @__PURE__ */ new Set(["body"]));
|
|
7492
|
+
const hasBodySchema = hasSchemaProperty(optionsArg, "body");
|
|
7493
|
+
const hasQuerystringSchema = hasSchemaProperty(optionsArg, "querystring");
|
|
7494
|
+
if (queryFields.size > 0 && hasBodySchema && !hasQuerystringSchema) {
|
|
7495
|
+
const field = [...queryFields][0];
|
|
7496
|
+
context.report({ node, messageId: "missingQuerystringSchema", data: { field } });
|
|
7497
|
+
}
|
|
7498
|
+
if (bodyFields.size > 0 && !hasAnySchema(optionsArg)) {
|
|
7499
|
+
const field = [...bodyFields][0];
|
|
7500
|
+
context.report({ node, messageId: "missingSchemaEntirely", data: { field } });
|
|
7501
|
+
}
|
|
7502
|
+
}
|
|
7503
|
+
};
|
|
7504
|
+
}
|
|
7505
|
+
};
|
|
7506
|
+
var twilioValidateAllRequestInputsRule = rule98;
|
|
7507
|
+
|
|
7508
|
+
// src/providers/twilio/rules/media-streams-mark-name-string.ts
|
|
7509
|
+
var rule99 = {
|
|
7510
|
+
meta: {
|
|
7511
|
+
type: "suggestion",
|
|
7512
|
+
docs: {
|
|
7513
|
+
description: "Media Streams mark.name must be a string, not a number",
|
|
7514
|
+
category: "correctness",
|
|
7515
|
+
rationale: 'Twilio\'s documented Media Streams `mark` message schema and every example show `mark.name` as a string (e.g. "my label"). Setting it to a bare number \u2014 `Date.now()` is a common culprit \u2014 means JSON.stringify() serializes it as a numeric literal, not the documented string type, a latent type mismatch that surfaces once mark-based pacing actually depends on matching mark names.',
|
|
7516
|
+
docsUrl: "https://www.twilio.com/docs/voice/media-streams/websocket-messages",
|
|
7517
|
+
recommended: true
|
|
7518
|
+
},
|
|
7519
|
+
messages: {
|
|
7520
|
+
markNameNotString: "mark.name is set to a non-string value here \u2014 Twilio's documented schema requires mark.name to be a string (e.g. String(Date.now()))."
|
|
7521
|
+
}
|
|
7522
|
+
},
|
|
7523
|
+
create(context) {
|
|
7524
|
+
const NUMERIC_CALLS = /* @__PURE__ */ new Set(["Date.now", "performance.now", "Math.random", "Math.floor", "Math.round"]);
|
|
7525
|
+
function isNumericLooking(n) {
|
|
7526
|
+
if (!n) return false;
|
|
7527
|
+
if (n.type === "Literal" && typeof n.value === "number") return true;
|
|
7528
|
+
if (n.type === "CallExpression") {
|
|
7529
|
+
const callee = n.callee;
|
|
7530
|
+
if (callee?.type === "Identifier") return false;
|
|
7531
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.property?.type === "Identifier") {
|
|
7532
|
+
return NUMERIC_CALLS.has(`${callee.object.name}.${callee.property.name}`);
|
|
7533
|
+
}
|
|
7534
|
+
return false;
|
|
7535
|
+
}
|
|
7536
|
+
return false;
|
|
7537
|
+
}
|
|
7538
|
+
function findMarkNameProperty(markObjExpr) {
|
|
7539
|
+
for (const p of markObjExpr.properties ?? []) {
|
|
7540
|
+
if (p.type !== "Property") continue;
|
|
7541
|
+
const keyName = p.key?.type === "Identifier" ? p.key.name : p.key?.type === "Literal" ? p.key.value : null;
|
|
7542
|
+
if (keyName === "name") return p;
|
|
7543
|
+
}
|
|
7544
|
+
return null;
|
|
7545
|
+
}
|
|
7546
|
+
return {
|
|
7547
|
+
Property(node) {
|
|
7548
|
+
const keyName = node.key?.type === "Identifier" ? node.key.name : node.key?.type === "Literal" ? node.key.value : null;
|
|
7549
|
+
if (keyName !== "mark") return;
|
|
7550
|
+
if (node.value?.type !== "ObjectExpression") return;
|
|
7551
|
+
const nameProp = findMarkNameProperty(node.value);
|
|
7552
|
+
if (!nameProp) return;
|
|
7553
|
+
if (!isNumericLooking(nameProp.value)) return;
|
|
7554
|
+
context.report({ node: nameProp, messageId: "markNameNotString" });
|
|
7555
|
+
}
|
|
7556
|
+
};
|
|
7557
|
+
}
|
|
7558
|
+
};
|
|
7559
|
+
var twilioMediaStreamsMarkNameStringRule = rule99;
|
|
7560
|
+
|
|
7561
|
+
// src/providers/openai-realtime/utils.ts
|
|
7562
|
+
function isOpenAIRealtimeUrlArg(node) {
|
|
7563
|
+
if (!node) return false;
|
|
7564
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
7565
|
+
return node.value.includes("api.openai.com/v1/realtime");
|
|
7566
|
+
}
|
|
7567
|
+
if (node.type === "TemplateLiteral") {
|
|
7568
|
+
return (node.quasis ?? []).some(
|
|
7569
|
+
(q) => typeof q?.value?.raw === "string" && q.value.raw.includes("api.openai.com/v1/realtime")
|
|
7570
|
+
);
|
|
7571
|
+
}
|
|
7572
|
+
return false;
|
|
7573
|
+
}
|
|
7574
|
+
function collectOpenAIRealtimeUrlVarNames(program) {
|
|
7575
|
+
const names = /* @__PURE__ */ new Set();
|
|
7576
|
+
visit(program);
|
|
7577
|
+
return names;
|
|
7578
|
+
function visit(node, depth = 0) {
|
|
7579
|
+
if (!node || typeof node !== "object" || depth > 40) return;
|
|
7580
|
+
if (Array.isArray(node)) {
|
|
7581
|
+
for (const n of node) visit(n, depth + 1);
|
|
7582
|
+
return;
|
|
7583
|
+
}
|
|
7584
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier" && isOpenAIRealtimeUrlArg(node.init)) {
|
|
7585
|
+
names.add(node.id.name);
|
|
7586
|
+
}
|
|
7587
|
+
for (const key of Object.keys(node)) {
|
|
7588
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7589
|
+
const val = node[key];
|
|
7590
|
+
if (val && typeof val === "object") visit(val, depth + 1);
|
|
7591
|
+
}
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
7594
|
+
function isOpenAIRealtimeUrlNode(urlArgNode, urlVarNames) {
|
|
7595
|
+
if (isOpenAIRealtimeUrlArg(urlArgNode)) return true;
|
|
7596
|
+
return urlArgNode?.type === "Identifier" && urlVarNames.has(urlArgNode.name);
|
|
7597
|
+
}
|
|
7598
|
+
function isOpenAIRealtimeNewWebSocket(node, urlVarNames = /* @__PURE__ */ new Set()) {
|
|
7599
|
+
if (node?.type !== "NewExpression") return false;
|
|
7600
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "WebSocket") return false;
|
|
7601
|
+
return isOpenAIRealtimeUrlNode(node.arguments?.[0], urlVarNames);
|
|
7602
|
+
}
|
|
7603
|
+
function findProperty4(objectExpression, propertyName) {
|
|
7604
|
+
if (objectExpression?.type !== "ObjectExpression") return null;
|
|
7605
|
+
for (const prop of objectExpression.properties ?? []) {
|
|
7606
|
+
if (prop?.type !== "Property") continue;
|
|
7607
|
+
const key = prop.key;
|
|
7608
|
+
const name = key?.type === "Identifier" ? key.name : key?.type === "Literal" ? key.value : null;
|
|
7609
|
+
if (name === propertyName) return prop;
|
|
7610
|
+
}
|
|
7611
|
+
return null;
|
|
7612
|
+
}
|
|
7613
|
+
function collectOpenAIRealtimeSocketVarNames(program) {
|
|
7614
|
+
const urlVarNames = collectOpenAIRealtimeUrlVarNames(program);
|
|
7615
|
+
const names = /* @__PURE__ */ new Set();
|
|
7616
|
+
visit(program);
|
|
7617
|
+
return names;
|
|
7618
|
+
function visit(node, depth = 0) {
|
|
7619
|
+
if (!node || typeof node !== "object" || depth > 40) return;
|
|
7620
|
+
if (Array.isArray(node)) {
|
|
7621
|
+
for (const n of node) visit(n, depth + 1);
|
|
7622
|
+
return;
|
|
7623
|
+
}
|
|
7624
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier" && isOpenAIRealtimeNewWebSocket(node.init, urlVarNames)) {
|
|
7625
|
+
names.add(node.id.name);
|
|
7626
|
+
}
|
|
7627
|
+
if (node.type === "AssignmentExpression" && node.operator === "=" && isOpenAIRealtimeNewWebSocket(node.right, urlVarNames)) {
|
|
7628
|
+
const left = node.left;
|
|
7629
|
+
if (left?.type === "Identifier") {
|
|
7630
|
+
names.add(left.name);
|
|
7631
|
+
} else if (left?.type === "MemberExpression" && left.property) {
|
|
7632
|
+
const propName2 = left.property.name;
|
|
7633
|
+
if (typeof propName2 === "string") names.add(propName2);
|
|
7634
|
+
}
|
|
7635
|
+
}
|
|
7636
|
+
for (const key of Object.keys(node)) {
|
|
7637
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7638
|
+
const val = node[key];
|
|
7639
|
+
if (val && typeof val === "object") visit(val, depth + 1);
|
|
7640
|
+
}
|
|
7641
|
+
}
|
|
7642
|
+
}
|
|
7643
|
+
function isTrackedSocketRef(objNode, socketVarNames) {
|
|
7644
|
+
if (objNode?.type === "Identifier") return socketVarNames.has(objNode.name);
|
|
7645
|
+
if (objNode?.type === "MemberExpression" && objNode.property) {
|
|
7646
|
+
const propName2 = objNode.property.name;
|
|
7647
|
+
return typeof propName2 === "string" && socketVarNames.has(propName2);
|
|
7648
|
+
}
|
|
7649
|
+
return false;
|
|
7650
|
+
}
|
|
7651
|
+
function isSocketOnCall(node, socketVarNames, eventName) {
|
|
7652
|
+
if (node?.type !== "CallExpression") return false;
|
|
7653
|
+
const callee = node.callee;
|
|
7654
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
7655
|
+
const prop = callee.property;
|
|
7656
|
+
if (prop?.type !== "Identifier" || prop.name !== "on") return false;
|
|
7657
|
+
if (!isTrackedSocketRef(callee.object, socketVarNames)) return false;
|
|
7658
|
+
const firstArg = node.arguments?.[0];
|
|
7659
|
+
return firstArg?.type === "Literal" && firstArg.value === eventName;
|
|
7660
|
+
}
|
|
7661
|
+
function collectStringVarValues(program) {
|
|
7662
|
+
const values = /* @__PURE__ */ new Map();
|
|
7663
|
+
visit(program);
|
|
7664
|
+
return values;
|
|
7665
|
+
function visit(node, depth = 0) {
|
|
7666
|
+
if (!node || typeof node !== "object" || depth > 40) return;
|
|
7667
|
+
if (Array.isArray(node)) {
|
|
7668
|
+
for (const n of node) visit(n, depth + 1);
|
|
7669
|
+
return;
|
|
7670
|
+
}
|
|
7671
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
7672
|
+
const init = node.init;
|
|
7673
|
+
if (init?.type === "Literal" && typeof init.value === "string") {
|
|
7674
|
+
values.set(node.id.name, init.value);
|
|
7675
|
+
} else if (init?.type === "TemplateLiteral" && (init.expressions ?? []).length === 0) {
|
|
7676
|
+
values.set(node.id.name, (init.quasis ?? []).map((q) => q.value?.raw ?? "").join(""));
|
|
7677
|
+
}
|
|
7678
|
+
}
|
|
7679
|
+
for (const key of Object.keys(node)) {
|
|
7680
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7681
|
+
const val = node[key];
|
|
7682
|
+
if (val && typeof val === "object") visit(val, depth + 1);
|
|
7683
|
+
}
|
|
7684
|
+
}
|
|
7685
|
+
}
|
|
7686
|
+
function resolveStringValue(node, stringVarValues) {
|
|
7687
|
+
if (!node) return null;
|
|
7688
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
7689
|
+
if (node.type === "TemplateLiteral" && (node.expressions ?? []).length === 0) {
|
|
7690
|
+
return (node.quasis ?? []).map((q) => q.value?.raw ?? "").join("");
|
|
7691
|
+
}
|
|
7692
|
+
if (node.type === "Identifier" && stringVarValues.has(node.name)) {
|
|
7693
|
+
return stringVarValues.get(node.name) ?? null;
|
|
7694
|
+
}
|
|
7695
|
+
return null;
|
|
7696
|
+
}
|
|
7697
|
+
|
|
7698
|
+
// src/providers/openai-realtime/rules/migrate-beta-to-ga.ts
|
|
7699
|
+
var rule100 = {
|
|
7700
|
+
meta: {
|
|
7701
|
+
type: "problem",
|
|
7702
|
+
docs: {
|
|
7703
|
+
description: "OpenAI Realtime connections must not pin to the deprecated beta interface",
|
|
7704
|
+
category: "correctness",
|
|
7705
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/realtime",
|
|
7706
|
+
rationale: "OpenAI's current Realtime guide states that beta integrations must migrate to the GA interface before new work proceeds, and explicitly calls out removing the OpenAI-Beta: realtime=v1 header as a required step. Staying on the beta header keeps a connection locked to the legacy session shape and event names with no confirmed sunset date \u2014 a ticking liability rather than an active failure.",
|
|
7707
|
+
recommended: true
|
|
7708
|
+
},
|
|
7709
|
+
messages: {
|
|
7710
|
+
betaHeaderPresent: "This OpenAI Realtime connection sends the deprecated 'OpenAI-Beta: realtime=v1' header instead of using the GA interface."
|
|
7711
|
+
},
|
|
7712
|
+
schema: []
|
|
7713
|
+
},
|
|
7714
|
+
create(context) {
|
|
7715
|
+
let urlVarNames = /* @__PURE__ */ new Set();
|
|
7716
|
+
return {
|
|
7717
|
+
Program(node) {
|
|
7718
|
+
urlVarNames = collectOpenAIRealtimeUrlVarNames(node);
|
|
7719
|
+
},
|
|
7720
|
+
NewExpression(node) {
|
|
7721
|
+
if (!isOpenAIRealtimeNewWebSocket(node, urlVarNames)) return;
|
|
7722
|
+
const optionsArg = node.arguments?.[1];
|
|
7723
|
+
if (optionsArg?.type !== "ObjectExpression") return;
|
|
7724
|
+
const headersProp = findProperty4(optionsArg, "headers");
|
|
7725
|
+
if (headersProp?.value?.type !== "ObjectExpression") return;
|
|
7726
|
+
const betaProp = findProperty4(headersProp.value, "OpenAI-Beta");
|
|
7727
|
+
if (!betaProp) return;
|
|
7728
|
+
const value = betaProp.value;
|
|
7729
|
+
const isRealtimeV1 = value?.type === "Literal" && typeof value.value === "string" && value.value.includes("realtime=v1");
|
|
7730
|
+
if (!isRealtimeV1) return;
|
|
7731
|
+
context.report({ node: betaProp, messageId: "betaHeaderPresent" });
|
|
7732
|
+
}
|
|
7733
|
+
};
|
|
7734
|
+
}
|
|
7735
|
+
};
|
|
7736
|
+
var openaiRealtimeMigrateBetaToGaRule = rule100;
|
|
7737
|
+
|
|
7738
|
+
// src/providers/openai-realtime/rules/no-log-raw-message-payloads.ts
|
|
7739
|
+
var LOG_METHOD_NAMES = /* @__PURE__ */ new Set(["info", "warn", "error", "log"]);
|
|
7740
|
+
function isLogCall(node) {
|
|
7741
|
+
if (node?.type !== "CallExpression") return { matches: false };
|
|
7742
|
+
const callee = node.callee;
|
|
7743
|
+
if (callee?.type !== "MemberExpression") return { matches: false };
|
|
7744
|
+
const prop = callee.property;
|
|
7745
|
+
if (prop?.type !== "Identifier" || !LOG_METHOD_NAMES.has(prop.name)) return { matches: false };
|
|
7746
|
+
return { matches: true, calleeProp: prop };
|
|
7747
|
+
}
|
|
7748
|
+
function referencesRawParam(argNode, paramName) {
|
|
7749
|
+
if (!argNode) return false;
|
|
7750
|
+
if (argNode.type === "Identifier" && argNode.name === paramName) return true;
|
|
7751
|
+
if (argNode.type === "CallExpression" && argNode.callee?.type === "MemberExpression" && argNode.callee.object?.type === "Identifier" && argNode.callee.object.name === paramName && argNode.callee.property?.type === "Identifier" && argNode.callee.property.name === "toString") {
|
|
7752
|
+
return true;
|
|
7753
|
+
}
|
|
7754
|
+
if (argNode.type === "TemplateLiteral") {
|
|
7755
|
+
return (argNode.expressions ?? []).some((expr) => referencesRawParam(expr, paramName));
|
|
7756
|
+
}
|
|
7757
|
+
return false;
|
|
7758
|
+
}
|
|
7759
|
+
function findCallExpressions(node, out, depth = 0) {
|
|
7760
|
+
if (!node || typeof node !== "object" || depth > 40) return;
|
|
7761
|
+
if (Array.isArray(node)) {
|
|
7762
|
+
for (const n of node) findCallExpressions(n, out, depth + 1);
|
|
7763
|
+
return;
|
|
7764
|
+
}
|
|
7765
|
+
if (node.type === "CallExpression") out.push(node);
|
|
7766
|
+
for (const key of Object.keys(node)) {
|
|
7767
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7768
|
+
const val = node[key];
|
|
7769
|
+
if (val && typeof val === "object") findCallExpressions(val, out, depth + 1);
|
|
7770
|
+
}
|
|
7771
|
+
}
|
|
7772
|
+
var rule101 = {
|
|
7773
|
+
meta: {
|
|
7774
|
+
type: "problem",
|
|
7775
|
+
docs: {
|
|
7776
|
+
description: "Raw OpenAI Realtime message payloads must not be logged verbatim",
|
|
7777
|
+
category: "security",
|
|
7778
|
+
cwe: "CWE-532",
|
|
7779
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/realtime",
|
|
7780
|
+
rationale: "Every inbound Realtime WebSocket message can include response.audio.delta payloads (base64-encoded live call audio) and, once transcription is wired up, transcript text. Logging the raw message object or string verbatim writes a durable, unredacted record of live conversation content into whatever log sink the application ships to.",
|
|
7781
|
+
recommended: true
|
|
7782
|
+
},
|
|
7783
|
+
messages: {
|
|
7784
|
+
rawPayloadLogged: "A raw OpenAI Realtime message is logged verbatim here, which can include live call audio or transcript content."
|
|
7785
|
+
},
|
|
7786
|
+
schema: []
|
|
7787
|
+
},
|
|
7788
|
+
create(context) {
|
|
7789
|
+
let socketVarNames = /* @__PURE__ */ new Set();
|
|
7790
|
+
return {
|
|
7791
|
+
Program(node) {
|
|
7792
|
+
socketVarNames = collectOpenAIRealtimeSocketVarNames(node);
|
|
7793
|
+
},
|
|
7794
|
+
CallExpression(node) {
|
|
7795
|
+
if (socketVarNames.size === 0) return;
|
|
7796
|
+
if (!isSocketOnCall(node, socketVarNames, "message")) return;
|
|
7797
|
+
const handler = node.arguments?.[1];
|
|
7798
|
+
if (handler?.type !== "ArrowFunctionExpression" && handler?.type !== "FunctionExpression") return;
|
|
7799
|
+
const param = handler.params?.[0];
|
|
7800
|
+
if (param?.type !== "Identifier") return;
|
|
7801
|
+
const paramName = param.name;
|
|
7802
|
+
const calls = [];
|
|
7803
|
+
findCallExpressions(handler.body, calls);
|
|
7804
|
+
for (const call of calls) {
|
|
7805
|
+
const { matches } = isLogCall(call);
|
|
7806
|
+
if (!matches) continue;
|
|
7807
|
+
const hasRawArg = (call.arguments ?? []).some((arg) => referencesRawParam(arg, paramName));
|
|
7808
|
+
if (hasRawArg) {
|
|
7809
|
+
context.report({ node: call, messageId: "rawPayloadLogged" });
|
|
7810
|
+
}
|
|
7811
|
+
}
|
|
7812
|
+
}
|
|
7813
|
+
};
|
|
7814
|
+
}
|
|
7815
|
+
};
|
|
7816
|
+
var openaiRealtimeNoLogRawMessagePayloadsRule = rule101;
|
|
7817
|
+
|
|
7818
|
+
// src/providers/openai-realtime/rules/handle-error-server-event.ts
|
|
7819
|
+
function isTypeMemberExpression(node) {
|
|
7820
|
+
return node?.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === "type";
|
|
7821
|
+
}
|
|
7822
|
+
function collectTypeComparisonLiterals(node, out, depth = 0) {
|
|
7823
|
+
if (!node || typeof node !== "object" || depth > 60) return;
|
|
7824
|
+
if (Array.isArray(node)) {
|
|
7825
|
+
for (const n of node) collectTypeComparisonLiterals(n, out, depth + 1);
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
7828
|
+
if (node.type === "BinaryExpression" && (node.operator === "===" || node.operator === "==")) {
|
|
7829
|
+
if (isTypeMemberExpression(node.left) && node.right?.type === "Literal" && typeof node.right.value === "string") {
|
|
7830
|
+
out.add(node.right.value);
|
|
7831
|
+
}
|
|
7832
|
+
if (isTypeMemberExpression(node.right) && node.left?.type === "Literal" && typeof node.left.value === "string") {
|
|
7833
|
+
out.add(node.left.value);
|
|
7834
|
+
}
|
|
7835
|
+
}
|
|
7836
|
+
if (node.type === "SwitchStatement" && isTypeMemberExpression(node.discriminant)) {
|
|
7837
|
+
for (const c of node.cases ?? []) {
|
|
7838
|
+
if (c?.test?.type === "Literal" && typeof c.test.value === "string") out.add(c.test.value);
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
for (const key of Object.keys(node)) {
|
|
7842
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7843
|
+
const val = node[key];
|
|
7844
|
+
if (val && typeof val === "object") collectTypeComparisonLiterals(val, out, depth + 1);
|
|
7845
|
+
}
|
|
7846
|
+
}
|
|
7847
|
+
var rule102 = {
|
|
7848
|
+
meta: {
|
|
7849
|
+
type: "problem",
|
|
7850
|
+
docs: {
|
|
7851
|
+
description: 'OpenAI Realtime message handlers must check for the API-level "error" server event',
|
|
7852
|
+
category: "reliability",
|
|
7853
|
+
docsUrl: "https://developers.openai.com/api/reference/resources/realtime/server-events",
|
|
7854
|
+
rationale: "The Realtime API's own error server event \u2014 distinct from the WebSocket transport-level error event \u2014 covers rate limits, invalid session.update payloads, content-moderation blocks, and retired/invalid model ids. A message handler that branches on event types but never checks for 'error' lets the call continue silently with translation/processing permanently dead for that leg, with no operator-visible signal of why.",
|
|
7855
|
+
recommended: true
|
|
7856
|
+
},
|
|
7857
|
+
messages: {
|
|
7858
|
+
missingErrorBranch: "This Realtime message handler branches on event types but never checks for message.type === 'error'."
|
|
7859
|
+
},
|
|
7860
|
+
schema: []
|
|
7861
|
+
},
|
|
7862
|
+
create(context) {
|
|
7863
|
+
let socketVarNames = /* @__PURE__ */ new Set();
|
|
7864
|
+
return {
|
|
7865
|
+
Program(node) {
|
|
7866
|
+
socketVarNames = collectOpenAIRealtimeSocketVarNames(node);
|
|
7867
|
+
},
|
|
7868
|
+
CallExpression(node) {
|
|
7869
|
+
if (socketVarNames.size === 0) return;
|
|
7870
|
+
if (!isSocketOnCall(node, socketVarNames, "message")) return;
|
|
7871
|
+
const handler = node.arguments?.[1];
|
|
7872
|
+
if (handler?.type !== "ArrowFunctionExpression" && handler?.type !== "FunctionExpression") return;
|
|
7873
|
+
const literals = /* @__PURE__ */ new Set();
|
|
7874
|
+
collectTypeComparisonLiterals(handler.body, literals);
|
|
7875
|
+
if (literals.size === 0) return;
|
|
7876
|
+
if (!literals.has("error")) {
|
|
7877
|
+
context.report({ node, messageId: "missingErrorBranch" });
|
|
7878
|
+
}
|
|
7879
|
+
}
|
|
7880
|
+
};
|
|
7881
|
+
}
|
|
7882
|
+
};
|
|
7883
|
+
var openaiRealtimeHandleErrorServerEventRule = rule102;
|
|
7884
|
+
|
|
7885
|
+
// src/providers/openai-realtime/rules/reconnect-on-drop.ts
|
|
7886
|
+
var RECONNECT_NAME_PATTERN = /reconnect/i;
|
|
7887
|
+
function hasReconnectAttempt(node, depth = 0) {
|
|
7888
|
+
if (!node || typeof node !== "object" || depth > 60) return false;
|
|
7889
|
+
if (Array.isArray(node)) {
|
|
7890
|
+
return node.some((n) => hasReconnectAttempt(n, depth + 1));
|
|
7891
|
+
}
|
|
7892
|
+
if (node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "WebSocket") {
|
|
7893
|
+
return true;
|
|
7894
|
+
}
|
|
7895
|
+
if (node.type === "CallExpression") {
|
|
7896
|
+
const callee = node.callee;
|
|
7897
|
+
const calleeName = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && callee.property?.type === "Identifier" ? callee.property.name : null;
|
|
7898
|
+
if (typeof calleeName === "string" && RECONNECT_NAME_PATTERN.test(calleeName)) return true;
|
|
7899
|
+
}
|
|
7900
|
+
for (const key of Object.keys(node)) {
|
|
7901
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
7902
|
+
const val = node[key];
|
|
7903
|
+
if (val && typeof val === "object" && hasReconnectAttempt(val, depth + 1)) return true;
|
|
7904
|
+
}
|
|
7905
|
+
return false;
|
|
7906
|
+
}
|
|
7907
|
+
var rule103 = {
|
|
7908
|
+
meta: {
|
|
7909
|
+
type: "problem",
|
|
7910
|
+
docs: {
|
|
7911
|
+
description: "A dropped OpenAI Realtime connection must be retried, not just logged",
|
|
7912
|
+
category: "reliability",
|
|
7913
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/realtime",
|
|
7914
|
+
rationale: "For a multi-minute phone call, a single transient OpenAI-side disconnect that is only logged on 'close' permanently kills translation/processing for the remainder of the call in that direction \u2014 the call itself stays connected and silently degraded, with neither party getting a signal that something stopped working.",
|
|
7915
|
+
recommended: true
|
|
7916
|
+
},
|
|
7917
|
+
messages: {
|
|
7918
|
+
noReconnectAttempt: "This Realtime socket's close handler only logs and never attempts to reconnect."
|
|
7919
|
+
},
|
|
7920
|
+
schema: []
|
|
7921
|
+
},
|
|
7922
|
+
create(context) {
|
|
7923
|
+
let socketVarNames = /* @__PURE__ */ new Set();
|
|
7924
|
+
return {
|
|
7925
|
+
Program(node) {
|
|
7926
|
+
socketVarNames = collectOpenAIRealtimeSocketVarNames(node);
|
|
7927
|
+
},
|
|
7928
|
+
CallExpression(node) {
|
|
7929
|
+
if (socketVarNames.size === 0) return;
|
|
7930
|
+
if (!isSocketOnCall(node, socketVarNames, "close")) return;
|
|
7931
|
+
const handler = node.arguments?.[1];
|
|
7932
|
+
if (handler?.type !== "ArrowFunctionExpression" && handler?.type !== "FunctionExpression") return;
|
|
7933
|
+
if (!hasReconnectAttempt(handler.body)) {
|
|
7934
|
+
context.report({ node, messageId: "noReconnectAttempt" });
|
|
7935
|
+
}
|
|
7936
|
+
}
|
|
7937
|
+
};
|
|
7938
|
+
}
|
|
7939
|
+
};
|
|
7940
|
+
var openaiRealtimeReconnectOnDropRule = rule103;
|
|
7941
|
+
|
|
7942
|
+
// src/providers/openai-realtime/rules/avoid-dated-preview-snapshots.ts
|
|
7943
|
+
var DATED_PREVIEW_MODEL_PATTERN = /model=[^&'"`]*-preview-\d{4}-\d{2}-\d{2}/;
|
|
7944
|
+
var rule104 = {
|
|
7945
|
+
meta: {
|
|
7946
|
+
type: "suggestion",
|
|
7947
|
+
docs: {
|
|
7948
|
+
description: "OpenAI Realtime connections should not pin to a dated preview model snapshot",
|
|
7949
|
+
category: "correctness",
|
|
7950
|
+
docsUrl: "https://developers.openai.com/api/reference/resources/realtime/subresources/client_secrets",
|
|
7951
|
+
rationale: "Dated preview snapshots are the category of model id OpenAI has historically retired with advance-notice sunset windows. Pinning to a dated preview snapshot rather than the floating GA alias (or a current dated GA snapshot) maximizes exposure to an eventual retirement, with no code path to detect or gracefully react to a model_not_found-style error if/when that happens.",
|
|
7952
|
+
recommended: true
|
|
7953
|
+
},
|
|
7954
|
+
messages: {
|
|
7955
|
+
datedPreviewSnapshot: "This Realtime connection is pinned to a dated preview model snapshot instead of the GA alias."
|
|
7956
|
+
},
|
|
7957
|
+
schema: []
|
|
7958
|
+
},
|
|
7959
|
+
create(context) {
|
|
7960
|
+
let stringVarValues = /* @__PURE__ */ new Map();
|
|
7961
|
+
let urlVarNames = /* @__PURE__ */ new Set();
|
|
7962
|
+
return {
|
|
7963
|
+
Program(node) {
|
|
7964
|
+
stringVarValues = collectStringVarValues(node);
|
|
7965
|
+
urlVarNames = collectOpenAIRealtimeUrlVarNames(node);
|
|
7966
|
+
},
|
|
7967
|
+
NewExpression(node) {
|
|
7968
|
+
if (!isOpenAIRealtimeNewWebSocket(node, urlVarNames)) return;
|
|
7969
|
+
const urlArg = node.arguments?.[0];
|
|
7970
|
+
const urlString = resolveStringValue(urlArg, stringVarValues);
|
|
7971
|
+
if (!urlString) return;
|
|
7972
|
+
if (DATED_PREVIEW_MODEL_PATTERN.test(urlString)) {
|
|
7973
|
+
context.report({ node: urlArg, messageId: "datedPreviewSnapshot" });
|
|
7974
|
+
}
|
|
7975
|
+
}
|
|
7976
|
+
};
|
|
7977
|
+
}
|
|
7978
|
+
};
|
|
7979
|
+
var openaiRealtimeAvoidDatedPreviewSnapshotsRule = rule104;
|
|
7980
|
+
|
|
7981
|
+
// src/providers/openai-realtime/rules/verify-deprecated-session-fields.ts
|
|
7982
|
+
var rule105 = {
|
|
7983
|
+
meta: {
|
|
7984
|
+
type: "suggestion",
|
|
7985
|
+
docs: {
|
|
7986
|
+
description: "OpenAI Realtime session.update payloads should not rely on an unverified 'temperature' field",
|
|
7987
|
+
category: "correctness",
|
|
7988
|
+
docsUrl: "https://developers.openai.com/api/reference/resources/realtime/subresources/client_secrets",
|
|
7989
|
+
rationale: "Two independent fetches of the current Realtime sessions API reference did not surface 'temperature' as a documented session field. Sending an undocumented field is typically harmless (silently ignored), but code that depends on an assumed constraint (e.g. 'a hard floor of 0.6') for behavior like deterministic output is relying on a claim that is no longer traceable to any current, citable source.",
|
|
7990
|
+
recommended: true
|
|
7991
|
+
},
|
|
7992
|
+
messages: {
|
|
7993
|
+
unverifiedTemperatureField: "This session.update payload sets 'temperature', a field not documented in the current GA Realtime sessions schema."
|
|
7994
|
+
},
|
|
7995
|
+
schema: []
|
|
7996
|
+
},
|
|
7997
|
+
create(context) {
|
|
7998
|
+
return {
|
|
7999
|
+
ObjectExpression(node) {
|
|
8000
|
+
const typeProp = findProperty4(node, "type");
|
|
8001
|
+
const typeValue = typeProp?.value;
|
|
8002
|
+
const isSessionUpdate = typeValue?.type === "Literal" && typeof typeValue.value === "string" && typeValue.value === "session.update";
|
|
8003
|
+
if (!isSessionUpdate) return;
|
|
8004
|
+
const sessionProp = findProperty4(node, "session");
|
|
8005
|
+
if (sessionProp?.value?.type !== "ObjectExpression") return;
|
|
8006
|
+
const temperatureProp = findProperty4(sessionProp.value, "temperature");
|
|
8007
|
+
if (!temperatureProp) return;
|
|
8008
|
+
context.report({ node: temperatureProp, messageId: "unverifiedTemperatureField" });
|
|
8009
|
+
}
|
|
8010
|
+
};
|
|
8011
|
+
}
|
|
8012
|
+
};
|
|
8013
|
+
var openaiRealtimeVerifyDeprecatedSessionFieldsRule = rule105;
|
|
8014
|
+
|
|
8015
|
+
// src/providers/openai-realtime/rules/buffer-audio-until-session-ready.ts
|
|
8016
|
+
var QUEUE_CALL_NAME_PATTERN = /^(push|unshift|enqueue|queue)$/i;
|
|
8017
|
+
function readyStateOpenCheckKind(test) {
|
|
8018
|
+
if (test?.type !== "BinaryExpression") return null;
|
|
8019
|
+
const isEq = test.operator === "===" || test.operator === "==";
|
|
8020
|
+
const isNeq = test.operator === "!==" || test.operator === "!=";
|
|
8021
|
+
if (!isEq && !isNeq) return null;
|
|
8022
|
+
const isReadyStateMember = (n) => n?.type === "MemberExpression" && n.property?.type === "Identifier" && n.property.name === "readyState";
|
|
8023
|
+
const isOpenValue = (n) => n?.type === "MemberExpression" && n.property?.type === "Identifier" && n.property.name === "OPEN" || n?.type === "Literal" && n.value === 1;
|
|
8024
|
+
const matches = isReadyStateMember(test.left) && isOpenValue(test.right) || isReadyStateMember(test.right) && isOpenValue(test.left);
|
|
8025
|
+
if (!matches) return null;
|
|
8026
|
+
return isEq ? "is-open" : "is-not-open";
|
|
8027
|
+
}
|
|
8028
|
+
function hasQueueCall(node, depth = 0) {
|
|
8029
|
+
if (!node || typeof node !== "object" || depth > 40) return false;
|
|
8030
|
+
if (Array.isArray(node)) return node.some((n) => hasQueueCall(n, depth + 1));
|
|
8031
|
+
if (node.type === "CallExpression") {
|
|
8032
|
+
const callee = node.callee;
|
|
8033
|
+
const calleeName = callee?.type === "MemberExpression" && callee.property?.type === "Identifier" ? callee.property.name : callee?.type === "Identifier" ? callee.name : null;
|
|
8034
|
+
if (typeof calleeName === "string" && QUEUE_CALL_NAME_PATTERN.test(calleeName)) return true;
|
|
8035
|
+
}
|
|
8036
|
+
for (const key of Object.keys(node)) {
|
|
8037
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
8038
|
+
const val = node[key];
|
|
8039
|
+
if (val && typeof val === "object" && hasQueueCall(val, depth + 1)) return true;
|
|
8040
|
+
}
|
|
8041
|
+
return false;
|
|
8042
|
+
}
|
|
8043
|
+
var rule106 = {
|
|
8044
|
+
meta: {
|
|
8045
|
+
type: "suggestion",
|
|
8046
|
+
docs: {
|
|
8047
|
+
description: "Audio sent before an OpenAI Realtime socket is open must be buffered, not dropped",
|
|
8048
|
+
category: "reliability",
|
|
8049
|
+
docsUrl: "https://developers.openai.com/api/reference/resources/realtime/client-events",
|
|
8050
|
+
rationale: "Caller audio can arrive before the Realtime WebSocket finishes its connect + session.update round-trip. A readyState !== OPEN branch that only logs and returns silently drops every audio chunk that arrives in that window, meaning the first fragment of speech is lost to translation/processing on every call.",
|
|
8051
|
+
recommended: true
|
|
8052
|
+
},
|
|
8053
|
+
messages: {
|
|
8054
|
+
audioDroppedNotBuffered: "Audio sent while this Realtime socket is not yet open is dropped here instead of being buffered and flushed once open."
|
|
8055
|
+
},
|
|
8056
|
+
schema: []
|
|
8057
|
+
},
|
|
8058
|
+
create(context) {
|
|
8059
|
+
return {
|
|
8060
|
+
IfStatement(node) {
|
|
8061
|
+
const kind = readyStateOpenCheckKind(node.test);
|
|
8062
|
+
if (!kind) return;
|
|
8063
|
+
const notOpenBranch = kind === "is-open" ? node.alternate : node.consequent;
|
|
8064
|
+
if (!notOpenBranch) return;
|
|
8065
|
+
if (hasQueueCall(notOpenBranch)) return;
|
|
8066
|
+
context.report({ node: notOpenBranch, messageId: "audioDroppedNotBuffered" });
|
|
8067
|
+
}
|
|
8068
|
+
};
|
|
8069
|
+
}
|
|
8070
|
+
};
|
|
8071
|
+
var openaiRealtimeBufferAudioUntilSessionReadyRule = rule106;
|
|
8072
|
+
|
|
8073
|
+
// src/providers/openai-realtime/rules/send-safety-identifier.ts
|
|
8074
|
+
var rule107 = {
|
|
8075
|
+
meta: {
|
|
8076
|
+
type: "suggestion",
|
|
8077
|
+
docs: {
|
|
8078
|
+
description: "OpenAI Realtime connections should send an OpenAI-Safety-Identifier header",
|
|
8079
|
+
category: "security",
|
|
8080
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/realtime",
|
|
8081
|
+
rationale: "OpenAI's Realtime guidance recommends including an OpenAI-Safety-Identifier header with a stable, privacy-preserving value (e.g. a hashed user/account id) on Realtime connections, to support OpenAI's abuse/safety monitoring for voice content. A connection that omits it gives OpenAI no way to correlate abusive sessions back to an account without per-connection metadata.",
|
|
8082
|
+
recommended: true
|
|
8083
|
+
},
|
|
8084
|
+
messages: {
|
|
8085
|
+
missingSafetyIdentifier: "This OpenAI Realtime WebSocket connection does not send an OpenAI-Safety-Identifier header."
|
|
8086
|
+
},
|
|
8087
|
+
schema: []
|
|
8088
|
+
},
|
|
8089
|
+
create(context) {
|
|
8090
|
+
let urlVarNames = /* @__PURE__ */ new Set();
|
|
8091
|
+
return {
|
|
8092
|
+
Program(node) {
|
|
8093
|
+
urlVarNames = collectOpenAIRealtimeUrlVarNames(node);
|
|
8094
|
+
},
|
|
8095
|
+
NewExpression(node) {
|
|
8096
|
+
if (!isOpenAIRealtimeNewWebSocket(node, urlVarNames)) return;
|
|
8097
|
+
const optionsArg = node.arguments?.[1];
|
|
8098
|
+
const headersProp = optionsArg?.type === "ObjectExpression" ? findProperty4(optionsArg, "headers") : null;
|
|
8099
|
+
const headersObj = headersProp?.value?.type === "ObjectExpression" ? headersProp.value : null;
|
|
8100
|
+
const safetyIdProp = headersObj ? findProperty4(headersObj, "OpenAI-Safety-Identifier") : null;
|
|
8101
|
+
if (safetyIdProp) return;
|
|
8102
|
+
context.report({ node: headersObj ?? optionsArg ?? node, messageId: "missingSafetyIdentifier" });
|
|
8103
|
+
}
|
|
8104
|
+
};
|
|
8105
|
+
}
|
|
8106
|
+
};
|
|
8107
|
+
var openaiRealtimeSendSafetyIdentifierRule = rule107;
|
|
8108
|
+
|
|
8109
|
+
// src/providers/openai-realtime/rules/transcription-model-choice.ts
|
|
8110
|
+
var rule108 = {
|
|
8111
|
+
meta: {
|
|
8112
|
+
type: "suggestion",
|
|
8113
|
+
docs: {
|
|
8114
|
+
description: "OpenAI Realtime sessions should not configure 'whisper-1' for input transcription",
|
|
8115
|
+
category: "correctness",
|
|
8116
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/realtime-transcription",
|
|
8117
|
+
rationale: "OpenAI's transcription guidance describes 'whisper-1' as for existing Whisper integrations that are not natively streaming, while 'gpt-realtime-whisper' is the natively-streaming option designed for realtime sessions. Configuring input_audio_transcription with 'whisper-1' is the wrong tool for a realtime session, adding latency/cost to a streaming pipeline.",
|
|
8118
|
+
recommended: true
|
|
8119
|
+
},
|
|
8120
|
+
messages: {
|
|
8121
|
+
nonStreamingTranscriptionModel: "This session's input transcription is configured with 'whisper-1', which is not natively streaming and not optimized for realtime sessions."
|
|
8122
|
+
},
|
|
8123
|
+
schema: []
|
|
8124
|
+
},
|
|
8125
|
+
create(context) {
|
|
8126
|
+
return {
|
|
8127
|
+
ObjectExpression(node) {
|
|
8128
|
+
const typeProp = findProperty4(node, "type");
|
|
8129
|
+
const typeValue = typeProp?.value;
|
|
8130
|
+
const isSessionUpdate = typeValue?.type === "Literal" && typeof typeValue.value === "string" && typeValue.value === "session.update";
|
|
8131
|
+
if (!isSessionUpdate) return;
|
|
8132
|
+
const sessionProp = findProperty4(node, "session");
|
|
8133
|
+
if (sessionProp?.value?.type !== "ObjectExpression") return;
|
|
8134
|
+
let transcriptionProp = findProperty4(sessionProp.value, "input_audio_transcription");
|
|
8135
|
+
if (!transcriptionProp) {
|
|
8136
|
+
const audioProp = findProperty4(sessionProp.value, "audio");
|
|
8137
|
+
const inputProp = audioProp?.value?.type === "ObjectExpression" ? findProperty4(audioProp.value, "input") : null;
|
|
8138
|
+
transcriptionProp = inputProp?.value?.type === "ObjectExpression" ? findProperty4(inputProp.value, "transcription") : null;
|
|
8139
|
+
}
|
|
8140
|
+
if (transcriptionProp?.value?.type !== "ObjectExpression") return;
|
|
8141
|
+
const modelProp = findProperty4(transcriptionProp.value, "model");
|
|
8142
|
+
const modelValue = modelProp?.value;
|
|
8143
|
+
const isWhisper1 = modelValue?.type === "Literal" && typeof modelValue.value === "string" && modelValue.value === "whisper-1";
|
|
8144
|
+
if (!isWhisper1) return;
|
|
8145
|
+
context.report({ node: modelProp, messageId: "nonStreamingTranscriptionModel" });
|
|
8146
|
+
}
|
|
8147
|
+
};
|
|
8148
|
+
}
|
|
8149
|
+
};
|
|
8150
|
+
var openaiRealtimeTranscriptionModelChoiceRule = rule108;
|
|
6027
8151
|
|
|
6028
8152
|
// src/plugin/index.ts
|
|
6029
8153
|
var plugin = {
|
|
@@ -6068,7 +8192,6 @@ var plugin = {
|
|
|
6068
8192
|
"firebase-rtdb-write-promise-not-handled": firebaseRtdbWritePromiseNotHandledRule,
|
|
6069
8193
|
"firebase-firestore-rules-expired": firebaseFirestoreRulesExpiredRule,
|
|
6070
8194
|
"firebase-id-token-cookie-flags": firebaseIdTokenCookieFlagsRule,
|
|
6071
|
-
"firebase-middleware-token-not-verified": firebaseMiddlewareTokenNotVerifiedRule,
|
|
6072
8195
|
"firebase-hardcoded-user-id": firebaseHardcodedUserIdRule,
|
|
6073
8196
|
"firebase-auth-user-not-found-disclosure": firebaseAuthUserNotFoundDisclosureRule,
|
|
6074
8197
|
"firebase-signup-password-confirm": firebaseSignupPasswordConfirmRule,
|
|
@@ -6107,10 +8230,37 @@ var plugin = {
|
|
|
6107
8230
|
"tiptap-appendTransaction-add-to-history": tiptapAppendTransactionAddToHistoryRule,
|
|
6108
8231
|
"tiptap-appendTransaction-full-scan": tiptapAppendTransactionFullScanRule,
|
|
6109
8232
|
"tiptap-atom-node-wrap-in": tiptapAtomNodeWrapInRule,
|
|
6110
|
-
"tiptap-twitter-url-regex": tiptapTwitterUrlRegexRule,
|
|
6111
8233
|
"tiptap-drop-handler-pos-precedence": tiptapDropHandlerPosPrecedenceRule,
|
|
6112
8234
|
"tiptap-prefer-table-kit": tiptapPreferTableKitRule,
|
|
6113
|
-
"tiptap-tiptap-markdown-missing-node-spec": tiptapTiptapMarkdownMissingNodeSpecRule
|
|
8235
|
+
"tiptap-tiptap-markdown-missing-node-spec": tiptapTiptapMarkdownMissingNodeSpecRule,
|
|
8236
|
+
"elevenlabs-validate-signed-url-response": elevenlabsValidateSignedUrlResponseRule,
|
|
8237
|
+
"elevenlabs-no-error-object-logging": elevenlabsNoErrorObjectLoggingRule,
|
|
8238
|
+
"elevenlabs-fetch-timeout-required": elevenlabsFetchTimeoutRequiredRule,
|
|
8239
|
+
"elevenlabs-validate-agent-id-format": elevenlabsValidateAgentIdFormatRule,
|
|
8240
|
+
"elevenlabs-secure-session-id-generation": elevenlabsSecureSessionIdGenerationRule,
|
|
8241
|
+
"elevenlabs-conversation-error-recovery": elevenlabsConversationErrorRecoveryRule,
|
|
8242
|
+
"elevenlabs-api-version-pinning": elevenlabsApiVersionPinningRule,
|
|
8243
|
+
"elevenlabs-check-http-status-before-json": elevenlabsCheckHttpStatusBeforeJsonRule,
|
|
8244
|
+
"elevenlabs-conversation-cleanup-on-error": elevenlabsConversationCleanupOnErrorRule,
|
|
8245
|
+
"elevenlabs-env-var-validation": elevenlabsEnvVarValidationRule,
|
|
8246
|
+
"twilio-validate-webhook-signature": twilioValidateWebhookSignatureRule,
|
|
8247
|
+
"twilio-taskrouter-attributes-match-consumer": twilioTaskrouterAttributesMatchConsumerRule,
|
|
8248
|
+
"twilio-enqueue-task-json-stringify": twilioEnqueueTaskJsonStringifyRule,
|
|
8249
|
+
"twilio-media-streams-key-by-call-sid": twilioMediaStreamsKeyByCallSidRule,
|
|
8250
|
+
"twilio-await-or-catch-rest-calls-in-event-handlers": twilioAwaitOrCatchRestCallsInEventHandlersRule,
|
|
8251
|
+
"twilio-use-twiml-builder-not-string-templates": twilioUseTwimlBuilderNotStringTemplatesRule,
|
|
8252
|
+
"twilio-media-streams-mark-pacing": twilioMediaStreamsMarkPacingRule,
|
|
8253
|
+
"twilio-validate-all-request-inputs": twilioValidateAllRequestInputsRule,
|
|
8254
|
+
"twilio-media-streams-mark-name-string": twilioMediaStreamsMarkNameStringRule,
|
|
8255
|
+
"openai-realtime-migrate-beta-to-ga": openaiRealtimeMigrateBetaToGaRule,
|
|
8256
|
+
"openai-realtime-no-log-raw-message-payloads": openaiRealtimeNoLogRawMessagePayloadsRule,
|
|
8257
|
+
"openai-realtime-handle-error-server-event": openaiRealtimeHandleErrorServerEventRule,
|
|
8258
|
+
"openai-realtime-reconnect-on-drop": openaiRealtimeReconnectOnDropRule,
|
|
8259
|
+
"openai-realtime-avoid-dated-preview-snapshots": openaiRealtimeAvoidDatedPreviewSnapshotsRule,
|
|
8260
|
+
"openai-realtime-verify-deprecated-session-fields": openaiRealtimeVerifyDeprecatedSessionFieldsRule,
|
|
8261
|
+
"openai-realtime-buffer-audio-until-session-ready": openaiRealtimeBufferAudioUntilSessionReadyRule,
|
|
8262
|
+
"openai-realtime-send-safety-identifier": openaiRealtimeSendSafetyIdentifierRule,
|
|
8263
|
+
"openai-realtime-transcription-model-choice": openaiRealtimeTranscriptionModelChoiceRule
|
|
6114
8264
|
}
|
|
6115
8265
|
};
|
|
6116
8266
|
var plugin_default = plugin;
|