@api-doctor/cli 0.0.3 → 0.0.4
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 +36 -46
- package/dist/cli.cjs +2920 -1413
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.mjs +2920 -1413
- package/dist/cli.mjs.map +1 -1
- package/dist/plugin.d.ts +475 -0
- package/dist/plugin.js +2723 -1414
- package/dist/plugin.js.map +1 -1
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -2825,85 +2825,47 @@ var rule37 = {
|
|
|
2825
2825
|
};
|
|
2826
2826
|
var firebaseRtdbWritePromiseNotHandledRule = rule37;
|
|
2827
2827
|
|
|
2828
|
-
// src/providers/
|
|
2829
|
-
var LLM_HOST_PATTERN = /api\.anthropic\.com|api\.openai\.com/;
|
|
2830
|
-
function containsKnownLlmHost(node) {
|
|
2831
|
-
if (node?.type === "Literal" && typeof node.value === "string") {
|
|
2832
|
-
return LLM_HOST_PATTERN.test(node.value);
|
|
2833
|
-
}
|
|
2834
|
-
if (node?.type === "TemplateLiteral") {
|
|
2835
|
-
return (node.quasis ?? []).some((q) => LLM_HOST_PATTERN.test(q.value?.raw ?? ""));
|
|
2836
|
-
}
|
|
2837
|
-
return false;
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
// src/providers/lovable/rules/no-client-side-secret-fetch.ts
|
|
2828
|
+
// src/providers/firebase/rules/firestore-rules-expired.ts
|
|
2841
2829
|
var rule38 = {
|
|
2842
2830
|
meta: {
|
|
2843
2831
|
type: "problem",
|
|
2844
2832
|
docs: {
|
|
2845
|
-
description: "
|
|
2833
|
+
description: "Firestore security rules contain a hard-coded expiry date that has already passed",
|
|
2846
2834
|
category: "security",
|
|
2847
|
-
cwe: "CWE-
|
|
2848
|
-
owasp: "
|
|
2849
|
-
rationale:
|
|
2850
|
-
docsUrl: "https://
|
|
2835
|
+
cwe: "CWE-284",
|
|
2836
|
+
owasp: "A01:2021 Broken Access Control",
|
|
2837
|
+
rationale: 'The Firebase "get started" rules template includes a timestamp.date() expiry. Once that date passes the condition permanently evaluates to false, denying every client read and write. Replace with proper auth-based rules scoped to the authenticated user.',
|
|
2838
|
+
docsUrl: "https://firebase.google.com/docs/firestore/security/insecure-rules",
|
|
2851
2839
|
recommended: true
|
|
2852
2840
|
},
|
|
2853
2841
|
messages: {
|
|
2854
|
-
|
|
2855
|
-
}
|
|
2842
|
+
firestoreRulesExpired: "Firestore security rules contain a hard-coded expiry date that has already passed. All client reads and writes are permanently denied. Replace with proper auth-based rules."
|
|
2843
|
+
},
|
|
2844
|
+
schema: []
|
|
2856
2845
|
},
|
|
2857
2846
|
create(context) {
|
|
2858
|
-
const
|
|
2859
|
-
function
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
if (typeof propertyName !== "string" || !propertyName.startsWith("VITE_")) return false;
|
|
2869
|
-
const envMember = node.object;
|
|
2870
|
-
if (envMember?.type !== "MemberExpression" || envMember.computed) return false;
|
|
2871
|
-
if (envMember.property?.name !== "env") return false;
|
|
2872
|
-
const metaProp = envMember.object;
|
|
2873
|
-
return metaProp?.type === "MetaProperty" && metaProp.meta?.name === "import" && metaProp.property?.name === "meta";
|
|
2874
|
-
}
|
|
2875
|
-
function referencesViteSecret(node) {
|
|
2876
|
-
if (!node) return false;
|
|
2877
|
-
if (isImportMetaEnvViteAccess(node)) return true;
|
|
2878
|
-
if (node.type === "Identifier" && viteVars.has(node.name)) return true;
|
|
2879
|
-
if (node.type === "TemplateLiteral") {
|
|
2880
|
-
return (node.expressions ?? []).some((e) => referencesViteSecret(e));
|
|
2847
|
+
const EXPIRED_YEAR = 2025;
|
|
2848
|
+
function checkStringForExpiredDate(value, reportNode) {
|
|
2849
|
+
const re = /timestamp\.date\(\s*(\d{4})\s*,/g;
|
|
2850
|
+
let match;
|
|
2851
|
+
while ((match = re.exec(value)) !== null) {
|
|
2852
|
+
const year = parseInt(match[1], 10);
|
|
2853
|
+
if (year <= EXPIRED_YEAR) {
|
|
2854
|
+
context.report({ node: reportNode, messageId: "firestoreRulesExpired" });
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2881
2857
|
}
|
|
2882
|
-
return false;
|
|
2883
2858
|
}
|
|
2884
2859
|
return {
|
|
2885
|
-
|
|
2886
|
-
if (node.
|
|
2887
|
-
|
|
2888
|
-
}
|
|
2860
|
+
Literal(node) {
|
|
2861
|
+
if (typeof node.value !== "string") return;
|
|
2862
|
+
checkStringForExpiredDate(node.value, node);
|
|
2889
2863
|
},
|
|
2890
|
-
|
|
2891
|
-
const
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
const optsArg = node.arguments?.[1];
|
|
2896
|
-
if (optsArg?.type !== "ObjectExpression") return;
|
|
2897
|
-
const headersProp = (optsArg.properties ?? []).find(
|
|
2898
|
-
(p) => p?.type === "Property" && propName2(p.key) === "headers"
|
|
2899
|
-
);
|
|
2900
|
-
if (!headersProp || headersProp.value?.type !== "ObjectExpression") return;
|
|
2901
|
-
for (const hp of headersProp.value.properties ?? []) {
|
|
2902
|
-
if (hp?.type !== "Property") continue;
|
|
2903
|
-
const keyName = propName2(hp.key)?.toLowerCase();
|
|
2904
|
-
if (keyName !== "x-api-key" && keyName !== "authorization") continue;
|
|
2905
|
-
if (referencesViteSecret(hp.value)) {
|
|
2906
|
-
context.report({ node, messageId: "clientSideSecretFetch" });
|
|
2864
|
+
TemplateLiteral(node) {
|
|
2865
|
+
for (const quasi of node.quasis ?? []) {
|
|
2866
|
+
const cooked = quasi?.value?.cooked ?? quasi?.value?.raw ?? "";
|
|
2867
|
+
if (typeof cooked === "string" && cooked.includes("timestamp.date(")) {
|
|
2868
|
+
checkStringForExpiredDate(cooked, node);
|
|
2907
2869
|
return;
|
|
2908
2870
|
}
|
|
2909
2871
|
}
|
|
@@ -2911,1189 +2873,2320 @@ var rule38 = {
|
|
|
2911
2873
|
};
|
|
2912
2874
|
}
|
|
2913
2875
|
};
|
|
2914
|
-
var
|
|
2876
|
+
var firebaseFirestoreRulesExpiredRule = rule38;
|
|
2915
2877
|
|
|
2916
|
-
// src/providers/
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2878
|
+
// src/providers/firebase/rules/id-token-cookie-flags.ts
|
|
2879
|
+
function findProp(obj, name) {
|
|
2880
|
+
if (obj?.type !== "ObjectExpression") return void 0;
|
|
2881
|
+
return (obj.properties ?? []).find(
|
|
2882
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2920
2885
|
var rule39 = {
|
|
2921
2886
|
meta: {
|
|
2922
2887
|
type: "problem",
|
|
2923
2888
|
docs: {
|
|
2924
|
-
description: "
|
|
2889
|
+
description: "Firebase ID token stored in a cookie without httpOnly flag",
|
|
2925
2890
|
category: "security",
|
|
2926
|
-
cwe: "CWE-
|
|
2927
|
-
owasp: "
|
|
2928
|
-
rationale: "
|
|
2929
|
-
docsUrl: "https://
|
|
2891
|
+
cwe: "CWE-1004",
|
|
2892
|
+
owasp: "A02:2021 Cryptographic Failures",
|
|
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.",
|
|
2894
|
+
docsUrl: "https://firebase.google.com/docs/auth/admin/manage-cookies",
|
|
2930
2895
|
recommended: true
|
|
2931
2896
|
},
|
|
2932
2897
|
messages: {
|
|
2933
|
-
|
|
2934
|
-
}
|
|
2898
|
+
idTokenCookieMissingHttpOnly: "Firebase ID token stored in cookie without httpOnly: true. Any XSS vulnerability can steal the token. Use the Firebase Admin SDK createSessionCookie flow instead."
|
|
2899
|
+
},
|
|
2900
|
+
schema: []
|
|
2935
2901
|
},
|
|
2936
2902
|
create(context) {
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
let sawPriceLabel = false;
|
|
2940
|
-
function ensureState(fn) {
|
|
2941
|
-
let s = states.get(fn);
|
|
2942
|
-
if (!s) {
|
|
2943
|
-
s = { sawFlagUpdate: false, updateNode: null, sawPaymentCall: false };
|
|
2944
|
-
states.set(fn, s);
|
|
2945
|
-
}
|
|
2946
|
-
return s;
|
|
2947
|
-
}
|
|
2948
|
-
function top() {
|
|
2949
|
-
return stack[stack.length - 1];
|
|
2950
|
-
}
|
|
2951
|
-
function pushScope(node) {
|
|
2952
|
-
stack.push(node);
|
|
2953
|
-
ensureState(node);
|
|
2954
|
-
}
|
|
2955
|
-
function popScope() {
|
|
2956
|
-
stack.pop();
|
|
2957
|
-
}
|
|
2958
|
-
function propName2(node) {
|
|
2959
|
-
if (!node) return void 0;
|
|
2960
|
-
if (node.type === "Identifier") return node.name;
|
|
2961
|
-
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2962
|
-
return void 0;
|
|
2963
|
-
}
|
|
2964
|
-
function isBooleanFlagProperty(name) {
|
|
2965
|
-
if (!name) return false;
|
|
2966
|
-
return FLAG_PROPERTY_PATTERN.test(name) || FLAG_SUFFIX_PATTERN.test(name);
|
|
2967
|
-
}
|
|
2968
|
-
function isSupabaseUpdateCall(node) {
|
|
2969
|
-
if (node?.type !== "CallExpression") return false;
|
|
2970
|
-
const callee = node.callee;
|
|
2971
|
-
return callee?.type === "MemberExpression" && callee.property?.name === "update";
|
|
2972
|
-
}
|
|
2973
|
-
function updateSetsBooleanFlag(node) {
|
|
2974
|
-
const arg = node.arguments?.[0];
|
|
2975
|
-
if (arg?.type !== "ObjectExpression") return false;
|
|
2976
|
-
return (arg.properties ?? []).some(
|
|
2977
|
-
(p) => p?.type === "Property" && isBooleanFlagProperty(propName2(p.key))
|
|
2978
|
-
);
|
|
2979
|
-
}
|
|
2980
|
-
function memberChainNames2(node, names = []) {
|
|
2981
|
-
if (node?.type === "MemberExpression") {
|
|
2982
|
-
memberChainNames2(node.object, names);
|
|
2983
|
-
const n = propName2(node.property);
|
|
2984
|
-
if (n) names.push(n);
|
|
2985
|
-
} else if (node?.type === "Identifier") {
|
|
2986
|
-
names.push(node.name);
|
|
2987
|
-
} else if (node?.type === "CallExpression") {
|
|
2988
|
-
memberChainNames2(node.callee, names);
|
|
2989
|
-
}
|
|
2990
|
-
return names;
|
|
2903
|
+
function isTokenName(val) {
|
|
2904
|
+
return /token/i.test(val);
|
|
2991
2905
|
}
|
|
2992
|
-
function
|
|
2993
|
-
if (
|
|
2994
|
-
const
|
|
2995
|
-
if (
|
|
2996
|
-
|
|
2997
|
-
if (node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
2998
|
-
const urlArg = node.arguments?.[0];
|
|
2999
|
-
if (urlArg?.type === "Literal" && typeof urlArg.value === "string") {
|
|
3000
|
-
return urlArg.value.includes("/functions/");
|
|
3001
|
-
}
|
|
3002
|
-
if (urlArg?.type === "TemplateLiteral") {
|
|
3003
|
-
return (urlArg.quasis ?? []).some((q) => (q.value?.raw ?? "").includes("/functions/"));
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
return false;
|
|
2906
|
+
function hasHttpOnlyTrue(optsNode) {
|
|
2907
|
+
if (optsNode?.type !== "ObjectExpression") return false;
|
|
2908
|
+
const prop = findProp(optsNode, "httpOnly");
|
|
2909
|
+
if (!prop) return false;
|
|
2910
|
+
return prop.value?.type === "Literal" && prop.value.value === true;
|
|
3007
2911
|
}
|
|
3008
2912
|
return {
|
|
3009
|
-
Program(node) {
|
|
3010
|
-
pushScope(node);
|
|
3011
|
-
},
|
|
3012
|
-
"Program:exit"() {
|
|
3013
|
-
if (!sawPriceLabel) return;
|
|
3014
|
-
for (const state of states.values()) {
|
|
3015
|
-
if (state.sawFlagUpdate && !state.sawPaymentCall) {
|
|
3016
|
-
context.report({ node: state.updateNode, messageId: "paidFlagWithoutPayment" });
|
|
3017
|
-
}
|
|
3018
|
-
}
|
|
3019
|
-
},
|
|
3020
|
-
FunctionDeclaration(node) {
|
|
3021
|
-
pushScope(node);
|
|
3022
|
-
},
|
|
3023
|
-
"FunctionDeclaration:exit"() {
|
|
3024
|
-
popScope();
|
|
3025
|
-
},
|
|
3026
|
-
FunctionExpression(node) {
|
|
3027
|
-
pushScope(node);
|
|
3028
|
-
},
|
|
3029
|
-
"FunctionExpression:exit"() {
|
|
3030
|
-
popScope();
|
|
3031
|
-
},
|
|
3032
|
-
ArrowFunctionExpression(node) {
|
|
3033
|
-
pushScope(node);
|
|
3034
|
-
},
|
|
3035
|
-
"ArrowFunctionExpression:exit"() {
|
|
3036
|
-
popScope();
|
|
3037
|
-
},
|
|
3038
|
-
Literal(node) {
|
|
3039
|
-
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3040
|
-
},
|
|
3041
|
-
JSXText(node) {
|
|
3042
|
-
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3043
|
-
},
|
|
3044
|
-
TemplateElement(node) {
|
|
3045
|
-
if (PRICE_PATTERN.test(node.value?.raw ?? "")) sawPriceLabel = true;
|
|
3046
|
-
},
|
|
3047
2913
|
CallExpression(node) {
|
|
3048
|
-
const
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
2914
|
+
const callee = node.callee;
|
|
2915
|
+
const isCookieSet = callee?.type === "Identifier" && callee.name === "setCookie";
|
|
2916
|
+
if (!isCookieSet) return;
|
|
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;
|
|
3054
2925
|
}
|
|
3055
|
-
if (
|
|
3056
|
-
|
|
2926
|
+
if (!hasHttpOnlyTrue(optsArg)) {
|
|
2927
|
+
context.report({ node, messageId: "idTokenCookieMissingHttpOnly" });
|
|
3057
2928
|
}
|
|
3058
2929
|
}
|
|
3059
2930
|
};
|
|
3060
2931
|
}
|
|
3061
2932
|
};
|
|
3062
|
-
var
|
|
2933
|
+
var firebaseIdTokenCookieFlagsRule = rule39;
|
|
3063
2934
|
|
|
3064
|
-
// src/providers/
|
|
3065
|
-
var EXPIRY_COLUMN_PATTERN = /_(until|expires?_at|expiry)$/i;
|
|
3066
|
-
var DATE_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([">", "<", ">=", "<="]);
|
|
3067
|
-
var FILTER_METHODS = /* @__PURE__ */ new Set(["gt", "gte", "lt", "lte"]);
|
|
2935
|
+
// src/providers/firebase/rules/middleware-token-not-verified.ts
|
|
3068
2936
|
var rule40 = {
|
|
3069
2937
|
meta: {
|
|
3070
|
-
type: "
|
|
2938
|
+
type: "problem",
|
|
3071
2939
|
docs: {
|
|
3072
|
-
description: "
|
|
3073
|
-
category: "
|
|
3074
|
-
|
|
3075
|
-
|
|
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",
|
|
3076
2946
|
recommended: true
|
|
3077
2947
|
},
|
|
3078
2948
|
messages: {
|
|
3079
|
-
|
|
3080
|
-
}
|
|
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: []
|
|
3081
2952
|
},
|
|
3082
2953
|
create(context) {
|
|
3083
|
-
const
|
|
3084
|
-
const
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
return void 0;
|
|
3090
|
-
}
|
|
3091
|
-
function isSupabaseUpdateCall(node) {
|
|
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) {
|
|
3092
2960
|
if (node?.type !== "CallExpression") return false;
|
|
3093
2961
|
const callee = node.callee;
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
if (
|
|
3098
|
-
|
|
3099
|
-
return true;
|
|
3100
|
-
}
|
|
3101
|
-
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Date" && node.callee.property?.name === "now") {
|
|
3102
|
-
return true;
|
|
3103
|
-
}
|
|
3104
|
-
if (node.type === "MemberExpression") {
|
|
3105
|
-
return containsDateNowOrNewDate(node.object, depth + 1) || containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3106
|
-
}
|
|
3107
|
-
if (node.type === "CallExpression") {
|
|
3108
|
-
return containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3109
|
-
}
|
|
3110
|
-
return false;
|
|
3111
|
-
}
|
|
3112
|
-
function memberPropertyName(node) {
|
|
3113
|
-
if (node?.type !== "MemberExpression") return void 0;
|
|
3114
|
-
return propName2(node.property);
|
|
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);
|
|
3115
2967
|
}
|
|
3116
2968
|
return {
|
|
3117
2969
|
CallExpression(node) {
|
|
3118
|
-
if (
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
for (const prop of arg.properties ?? []) {
|
|
3122
|
-
if (prop?.type !== "Property") continue;
|
|
3123
|
-
const keyName = propName2(prop.key);
|
|
3124
|
-
if (keyName && EXPIRY_COLUMN_PATTERN.test(keyName) && !writtenColumns.has(keyName)) {
|
|
3125
|
-
writtenColumns.set(keyName, node);
|
|
3126
|
-
}
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
2970
|
+
if (isAuthCookieGet(node)) {
|
|
2971
|
+
readsCookieWithAuthName = true;
|
|
2972
|
+
cookieReadNodes.push(node);
|
|
3129
2973
|
}
|
|
3130
2974
|
const callee = node.callee;
|
|
3131
|
-
if (callee?.type === "
|
|
3132
|
-
|
|
3133
|
-
const colName = colArg?.type === "Literal" ? propName2(colArg) : void 0;
|
|
3134
|
-
if (colName && EXPIRY_COLUMN_PATTERN.test(colName)) {
|
|
3135
|
-
readColumns.add(colName);
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3138
|
-
},
|
|
3139
|
-
BinaryExpression(node) {
|
|
3140
|
-
if (!DATE_COMPARISON_OPERATORS.has(node.operator)) return;
|
|
3141
|
-
const leftCol = memberPropertyName(node.left);
|
|
3142
|
-
const rightCol = memberPropertyName(node.right);
|
|
3143
|
-
if (leftCol && EXPIRY_COLUMN_PATTERN.test(leftCol) && containsDateNowOrNewDate(node.right)) {
|
|
3144
|
-
readColumns.add(leftCol);
|
|
2975
|
+
if (callee?.type === "Identifier" && VERIFY_METHOD_NAMES.has(callee.name)) {
|
|
2976
|
+
hasVerifyCall = true;
|
|
3145
2977
|
}
|
|
3146
|
-
if (
|
|
3147
|
-
|
|
2978
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && VERIFY_METHOD_NAMES.has(callee.property.name)) {
|
|
2979
|
+
hasVerifyCall = true;
|
|
3148
2980
|
}
|
|
3149
2981
|
},
|
|
3150
2982
|
"Program:exit"() {
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
context.report({ node, messageId: "
|
|
2983
|
+
if (readsCookieWithAuthName && !hasVerifyCall) {
|
|
2984
|
+
for (const node of cookieReadNodes) {
|
|
2985
|
+
context.report({ node, messageId: "tokenNotVerified" });
|
|
3154
2986
|
}
|
|
3155
2987
|
}
|
|
3156
2988
|
}
|
|
3157
2989
|
};
|
|
3158
2990
|
}
|
|
3159
2991
|
};
|
|
3160
|
-
var
|
|
2992
|
+
var firebaseMiddlewareTokenNotVerifiedRule = rule40;
|
|
3161
2993
|
|
|
3162
|
-
// src/providers/
|
|
3163
|
-
var LOGGING_CONSOLE_METHODS = /* @__PURE__ */ new Set(["error", "warn", "log", "info"]);
|
|
2994
|
+
// src/providers/firebase/rules/hardcoded-user-id.ts
|
|
3164
2995
|
var rule41 = {
|
|
3165
2996
|
meta: {
|
|
3166
|
-
type: "
|
|
2997
|
+
type: "problem",
|
|
3167
2998
|
docs: {
|
|
3168
|
-
description: "
|
|
3169
|
-
category: "
|
|
3170
|
-
|
|
3171
|
-
|
|
2999
|
+
description: "Hardcoded string used as userId in Firestore operations",
|
|
3000
|
+
category: "security",
|
|
3001
|
+
cwe: "CWE-284",
|
|
3002
|
+
owasp: "A01:2021 Broken Access Control",
|
|
3003
|
+
rationale: "A hardcoded userId causes all users to share a single Firestore document. The last writer overwrites everyone else's data, and when security rules are tightened the writes are rejected. The userId must always come from the authenticated user object.",
|
|
3004
|
+
docsUrl: "https://firebase.google.com/docs/auth/web/manage-users",
|
|
3172
3005
|
recommended: true
|
|
3173
3006
|
},
|
|
3174
3007
|
messages: {
|
|
3175
|
-
|
|
3176
|
-
}
|
|
3008
|
+
hardcodedUserId: "Hardcoded userId found. All users share this document. Read the userId from the authenticated user: const { user } = useAuth(); user?.uid"
|
|
3009
|
+
},
|
|
3010
|
+
schema: []
|
|
3177
3011
|
},
|
|
3178
3012
|
create(context) {
|
|
3179
|
-
|
|
3180
|
-
if (!node || typeof node !== "object" || depth > 12) return null;
|
|
3181
|
-
if (Array.isArray(node)) {
|
|
3182
|
-
for (const n of node) {
|
|
3183
|
-
const found = findFetchToLlmHost(n, depth + 1);
|
|
3184
|
-
if (found) return found;
|
|
3185
|
-
}
|
|
3186
|
-
return null;
|
|
3187
|
-
}
|
|
3188
|
-
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
3189
|
-
const urlArg = node.arguments?.[0];
|
|
3190
|
-
if (containsKnownLlmHost(urlArg)) return node;
|
|
3191
|
-
}
|
|
3192
|
-
for (const key of Object.keys(node)) {
|
|
3193
|
-
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3194
|
-
const val = node[key];
|
|
3195
|
-
if (val && typeof val === "object") {
|
|
3196
|
-
const found = findFetchToLlmHost(val, depth + 1);
|
|
3197
|
-
if (found) return found;
|
|
3198
|
-
}
|
|
3199
|
-
}
|
|
3200
|
-
return null;
|
|
3201
|
-
}
|
|
3202
|
-
function containsLoggingCall(node, depth = 0) {
|
|
3203
|
-
if (!node || typeof node !== "object" || depth > 12) return false;
|
|
3204
|
-
if (Array.isArray(node)) return node.some((n) => containsLoggingCall(n, depth + 1));
|
|
3205
|
-
if (node.type === "CallExpression") {
|
|
3206
|
-
const callee = node.callee;
|
|
3207
|
-
if (callee?.type === "MemberExpression") {
|
|
3208
|
-
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
3209
|
-
const propertyName = callee.property?.name;
|
|
3210
|
-
if (objName === "console" && LOGGING_CONSOLE_METHODS.has(propertyName)) return true;
|
|
3211
|
-
if (propertyName === "captureException") return true;
|
|
3212
|
-
}
|
|
3213
|
-
if (callee?.type === "Identifier" && callee.name === "reportError") return true;
|
|
3214
|
-
}
|
|
3215
|
-
for (const key of Object.keys(node)) {
|
|
3216
|
-
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3217
|
-
const val = node[key];
|
|
3218
|
-
if (val && typeof val === "object") {
|
|
3219
|
-
if (containsLoggingCall(val, depth + 1)) return true;
|
|
3220
|
-
}
|
|
3221
|
-
}
|
|
3222
|
-
return false;
|
|
3223
|
-
}
|
|
3013
|
+
const USER_ID_NAMES = /* @__PURE__ */ new Set(["userId", "uid", "userID", "user_id"]);
|
|
3224
3014
|
return {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
if (!
|
|
3228
|
-
const
|
|
3229
|
-
if (!
|
|
3230
|
-
if (
|
|
3231
|
-
context.report({ node
|
|
3015
|
+
VariableDeclarator(node) {
|
|
3016
|
+
if (node.id?.type !== "Identifier") return;
|
|
3017
|
+
if (!USER_ID_NAMES.has(node.id.name)) return;
|
|
3018
|
+
const init = node.init;
|
|
3019
|
+
if (!init) return;
|
|
3020
|
+
if (init.type === "Literal" && typeof init.value === "string" && init.value.length > 0) {
|
|
3021
|
+
context.report({ node, messageId: "hardcodedUserId" });
|
|
3232
3022
|
}
|
|
3233
3023
|
}
|
|
3234
3024
|
};
|
|
3235
3025
|
}
|
|
3236
3026
|
};
|
|
3237
|
-
var
|
|
3238
|
-
|
|
3239
|
-
// src/providers/browserbase/utils.ts
|
|
3240
|
-
function memberPropName3(node) {
|
|
3241
|
-
if (node?.type !== "CallExpression") return void 0;
|
|
3242
|
-
const callee = node.callee;
|
|
3243
|
-
if (callee?.type !== "MemberExpression") return void 0;
|
|
3244
|
-
const prop = callee.property;
|
|
3245
|
-
if (!callee.computed && prop?.type === "Identifier") return prop.name;
|
|
3246
|
-
if (callee.computed && prop?.type === "Literal" && typeof prop.value === "string") return prop.value;
|
|
3247
|
-
return void 0;
|
|
3248
|
-
}
|
|
3249
|
-
function isSessionsCall(node, name) {
|
|
3250
|
-
if (memberPropName3(node) !== name) return false;
|
|
3251
|
-
const obj = node.callee.object;
|
|
3252
|
-
return obj?.type === "MemberExpression" && !obj.computed && obj.property?.type === "Identifier" && obj.property.name === "sessions";
|
|
3253
|
-
}
|
|
3254
|
-
function isSessionsRecordingCall(node, name) {
|
|
3255
|
-
if (memberPropName3(node) !== name) return false;
|
|
3256
|
-
const recordingObj = node.callee.object;
|
|
3257
|
-
if (recordingObj?.type !== "MemberExpression" || recordingObj.computed) return false;
|
|
3258
|
-
if (recordingObj.property?.type !== "Identifier" || recordingObj.property.name !== "recording") return false;
|
|
3259
|
-
const sessionsObj = recordingObj.object;
|
|
3260
|
-
return sessionsObj?.type === "MemberExpression" && !sessionsObj.computed && sessionsObj.property?.type === "Identifier" && sessionsObj.property.name === "sessions";
|
|
3261
|
-
}
|
|
3262
|
-
function findProperty2(objectExpression, name) {
|
|
3263
|
-
if (objectExpression?.type !== "ObjectExpression") return void 0;
|
|
3264
|
-
return objectExpression.properties?.find(
|
|
3265
|
-
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
3266
|
-
);
|
|
3267
|
-
}
|
|
3268
|
-
function startOffset3(n) {
|
|
3269
|
-
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
3270
|
-
if (typeof n?.start === "number") return n.start;
|
|
3271
|
-
return (n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.start?.column ?? 0);
|
|
3272
|
-
}
|
|
3273
|
-
function endOffset3(n) {
|
|
3274
|
-
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
3275
|
-
if (typeof n?.end === "number") return n.end;
|
|
3276
|
-
return (n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.end?.column ?? 0);
|
|
3277
|
-
}
|
|
3278
|
-
function contains3(outer, inner) {
|
|
3279
|
-
const s = startOffset3(inner);
|
|
3280
|
-
return s >= startOffset3(outer) && s <= endOffset3(outer);
|
|
3281
|
-
}
|
|
3282
|
-
function someDescendant2(node, predicate) {
|
|
3283
|
-
let found = false;
|
|
3284
|
-
function visit(n) {
|
|
3285
|
-
if (found || !n || typeof n !== "object") return;
|
|
3286
|
-
if (Array.isArray(n)) {
|
|
3287
|
-
for (const item of n) visit(item);
|
|
3288
|
-
return;
|
|
3289
|
-
}
|
|
3290
|
-
if (typeof n.type !== "string") return;
|
|
3291
|
-
if (predicate(n)) {
|
|
3292
|
-
found = true;
|
|
3293
|
-
return;
|
|
3294
|
-
}
|
|
3295
|
-
for (const key of Object.keys(n)) {
|
|
3296
|
-
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3297
|
-
visit(n[key]);
|
|
3298
|
-
}
|
|
3299
|
-
}
|
|
3300
|
-
visit(node);
|
|
3301
|
-
return found;
|
|
3302
|
-
}
|
|
3303
|
-
function isResponseSendCall(node) {
|
|
3304
|
-
const name = memberPropName3(node);
|
|
3305
|
-
if (name !== "json" && name !== "send") return false;
|
|
3306
|
-
const obj = node.callee.object;
|
|
3307
|
-
if (obj?.type === "Identifier") {
|
|
3308
|
-
return /^(res|response)$/i.test(obj.name) || obj.name === "Response" || obj.name === "NextResponse";
|
|
3309
|
-
}
|
|
3310
|
-
return false;
|
|
3311
|
-
}
|
|
3312
|
-
function isInsideTestFile3(filename) {
|
|
3313
|
-
return /(^|[\\/])__tests__[\\/]|\.(test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
3314
|
-
}
|
|
3027
|
+
var firebaseHardcodedUserIdRule = rule41;
|
|
3315
3028
|
|
|
3316
|
-
// src/providers/
|
|
3317
|
-
function isUserLikeExpr(node) {
|
|
3318
|
-
if (node?.type === "Identifier") return /user/i.test(node.name);
|
|
3319
|
-
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3320
|
-
return /user/i.test(node.property.name);
|
|
3321
|
-
}
|
|
3322
|
-
return false;
|
|
3323
|
-
}
|
|
3324
|
-
function isFalsyGuardTest(test) {
|
|
3325
|
-
if (test?.type === "UnaryExpression" && test.operator === "!") return isUserLikeExpr(test.argument);
|
|
3326
|
-
if (test?.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) {
|
|
3327
|
-
const sides = [test.left, test.right];
|
|
3328
|
-
const isNullish = (n) => n?.type === "Literal" && n.value === null || n?.type === "Identifier" && n.name === "undefined";
|
|
3329
|
-
const nullSide = sides.find(isNullish);
|
|
3330
|
-
const otherSide = sides.find((s) => s !== nullSide);
|
|
3331
|
-
return !!nullSide && isUserLikeExpr(otherSide);
|
|
3332
|
-
}
|
|
3333
|
-
return false;
|
|
3334
|
-
}
|
|
3335
|
-
function hasReturnOrThrow(stmt) {
|
|
3336
|
-
if (!stmt) return false;
|
|
3337
|
-
if (stmt.type === "ReturnStatement" || stmt.type === "ThrowStatement") return true;
|
|
3338
|
-
if (stmt.type === "BlockStatement") {
|
|
3339
|
-
return (stmt.body ?? []).some((s) => s.type === "ReturnStatement" || s.type === "ThrowStatement");
|
|
3340
|
-
}
|
|
3341
|
-
return false;
|
|
3342
|
-
}
|
|
3343
|
-
function isTruthyGateTest(test) {
|
|
3344
|
-
if (isUserLikeExpr(test)) return true;
|
|
3345
|
-
if (test?.type === "LogicalExpression" && test.operator === "&&") {
|
|
3346
|
-
return isTruthyGateTest(test.left) || isTruthyGateTest(test.right);
|
|
3347
|
-
}
|
|
3348
|
-
return false;
|
|
3349
|
-
}
|
|
3029
|
+
// src/providers/firebase/rules/auth-user-not-found-disclosure.ts
|
|
3350
3030
|
var rule42 = {
|
|
3351
3031
|
meta: {
|
|
3352
|
-
type: "
|
|
3032
|
+
type: "suggestion",
|
|
3353
3033
|
docs: {
|
|
3354
|
-
description: "
|
|
3034
|
+
description: "auth/user-not-found error code surfaced to the user enables email enumeration",
|
|
3355
3035
|
category: "security",
|
|
3356
|
-
cwe: "CWE-
|
|
3357
|
-
owasp: "
|
|
3358
|
-
rationale: "
|
|
3359
|
-
docsUrl: "https://
|
|
3036
|
+
cwe: "CWE-204",
|
|
3037
|
+
owasp: "A07:2021 Identification and Authentication Failures",
|
|
3038
|
+
rationale: "Displaying a distinct message for the auth/user-not-found error code reveals whether an email address is registered. Firebase email enumeration protection (default on since September 2023) stops returning this code \u2014 making this branch dead code while still being a security risk on older projects.",
|
|
3039
|
+
docsUrl: "https://firebase.google.com/docs/auth/web/password-auth#enumeration-protection",
|
|
3360
3040
|
recommended: true
|
|
3361
3041
|
},
|
|
3362
3042
|
messages: {
|
|
3363
|
-
|
|
3043
|
+
userNotFoundDisclosure: "Checking for auth/user-not-found exposes whether an email is registered (user enumeration). Show the same generic message for all auth errors."
|
|
3364
3044
|
},
|
|
3365
3045
|
schema: []
|
|
3366
3046
|
},
|
|
3367
3047
|
create(context) {
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
const sensitiveCalls = [];
|
|
3371
|
-
function registerScope(node) {
|
|
3372
|
-
scopes.push({ node, start: startOffset3(node), end: endOffset3(node) });
|
|
3048
|
+
function isErrorCode(n) {
|
|
3049
|
+
return n?.type === "MemberExpression" && !n.computed && n.property?.type === "Identifier" && n.property.name === "code";
|
|
3373
3050
|
}
|
|
3374
|
-
function
|
|
3375
|
-
|
|
3376
|
-
for (const scope of scopes) {
|
|
3377
|
-
if (pos < scope.start || pos > scope.end) continue;
|
|
3378
|
-
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
3379
|
-
}
|
|
3380
|
-
return best;
|
|
3051
|
+
function isUserNotFoundLiteral(n) {
|
|
3052
|
+
return n?.type === "Literal" && n.value === "auth/user-not-found";
|
|
3381
3053
|
}
|
|
3382
3054
|
return {
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
registerScope(node);
|
|
3388
|
-
},
|
|
3389
|
-
ArrowFunctionExpression(node) {
|
|
3390
|
-
registerScope(node);
|
|
3391
|
-
},
|
|
3392
|
-
IfStatement(node) {
|
|
3393
|
-
if (isFalsyGuardTest(node.test) && hasReturnOrThrow(node.consequent)) {
|
|
3394
|
-
const pos = startOffset3(node);
|
|
3395
|
-
const scope = innermostScope(pos);
|
|
3396
|
-
if (scope && (scope.guardPos === void 0 || pos < scope.guardPos)) scope.guardPos = pos;
|
|
3397
|
-
return;
|
|
3398
|
-
}
|
|
3399
|
-
if (isTruthyGateTest(node.test) && !node.alternate) {
|
|
3400
|
-
gates.push({ start: startOffset3(node), end: endOffset3(node) });
|
|
3055
|
+
BinaryExpression(node) {
|
|
3056
|
+
if (node.operator !== "===" && node.operator !== "==" && node.operator !== "!==" && node.operator !== "!=") return;
|
|
3057
|
+
if (isErrorCode(node.left) && isUserNotFoundLiteral(node.right) || isErrorCode(node.right) && isUserNotFoundLiteral(node.left)) {
|
|
3058
|
+
context.report({ node, messageId: "userNotFoundDisclosure" });
|
|
3401
3059
|
}
|
|
3402
3060
|
},
|
|
3403
|
-
|
|
3404
|
-
if (
|
|
3405
|
-
|
|
3406
|
-
}
|
|
3407
|
-
},
|
|
3408
|
-
"Program:exit"() {
|
|
3409
|
-
for (const call of sensitiveCalls) {
|
|
3410
|
-
const gate = gates.find((g) => contains3(g, call));
|
|
3411
|
-
if (!gate) continue;
|
|
3412
|
-
const scope = innermostScope(startOffset3(call));
|
|
3413
|
-
const hasGuardBeforeGate = scope?.guardPos !== void 0 && scope.guardPos < gate.start;
|
|
3414
|
-
if (!hasGuardBeforeGate) {
|
|
3415
|
-
context.report({ node: call, messageId: "conditionalAuthz" });
|
|
3416
|
-
}
|
|
3061
|
+
SwitchCase(node) {
|
|
3062
|
+
if (isUserNotFoundLiteral(node.test)) {
|
|
3063
|
+
context.report({ node, messageId: "userNotFoundDisclosure" });
|
|
3417
3064
|
}
|
|
3418
3065
|
}
|
|
3419
3066
|
};
|
|
3420
3067
|
}
|
|
3421
3068
|
};
|
|
3422
|
-
var
|
|
3069
|
+
var firebaseAuthUserNotFoundDisclosureRule = rule42;
|
|
3423
3070
|
|
|
3424
|
-
// src/providers/
|
|
3071
|
+
// src/providers/firebase/rules/signup-password-confirm.ts
|
|
3425
3072
|
var rule43 = {
|
|
3426
3073
|
meta: {
|
|
3427
|
-
type: "
|
|
3074
|
+
type: "suggestion",
|
|
3428
3075
|
docs: {
|
|
3429
|
-
description: "
|
|
3430
|
-
category: "
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
rationale: "session.connectUrl is the WebSocket CDP URL returned by sessions.create() \u2014 it is self-authenticating; connecting to it grants full read/write control of the live browser (arbitrary JS execution, cookie/localStorage read, simulated input), equivalent to a bearer token. Putting it in a response body widens its exposure (network tab, browser extensions, error trackers, logs) for callers that almost never need it client-side \u2014 the intended sharable artifact is debuggerFullscreenUrl from sessions.debug().",
|
|
3434
|
-
docsUrl: "https://docs.browserbase.com/reference/api/create-a-session",
|
|
3076
|
+
description: "createUserWithEmailAndPassword called without verifying password matches confirmPassword",
|
|
3077
|
+
category: "correctness",
|
|
3078
|
+
rationale: "If confirmPassword is collected but never compared to password before account creation, users who mistype their password will successfully create an account they cannot log in to. Always compare password === confirmPassword and bail early on mismatch.",
|
|
3079
|
+
docsUrl: "https://firebase.google.com/docs/auth/web/password-auth",
|
|
3435
3080
|
recommended: true
|
|
3436
3081
|
},
|
|
3437
3082
|
messages: {
|
|
3438
|
-
|
|
3083
|
+
passwordConfirmNotChecked: "createUserWithEmailAndPassword is called without verifying password === confirmPassword. Users who mistype their password will create an account they cannot log in to."
|
|
3439
3084
|
},
|
|
3440
3085
|
schema: []
|
|
3441
3086
|
},
|
|
3442
3087
|
create(context) {
|
|
3088
|
+
let createUserLocalName;
|
|
3089
|
+
let hasConfirmPasswordIdentifier = false;
|
|
3090
|
+
let hasPasswordComparison = false;
|
|
3091
|
+
const createUserCalls = [];
|
|
3443
3092
|
return {
|
|
3093
|
+
ImportDeclaration(node) {
|
|
3094
|
+
const imports = namedImportsFrom(node, "firebase/auth");
|
|
3095
|
+
if (imports.has("createUserWithEmailAndPassword")) {
|
|
3096
|
+
createUserLocalName = imports.get("createUserWithEmailAndPassword");
|
|
3097
|
+
}
|
|
3098
|
+
},
|
|
3099
|
+
Identifier(node) {
|
|
3100
|
+
if (node.name === "confirmPassword") {
|
|
3101
|
+
hasConfirmPasswordIdentifier = true;
|
|
3102
|
+
}
|
|
3103
|
+
},
|
|
3104
|
+
BinaryExpression(node) {
|
|
3105
|
+
if (node.operator !== "===" && node.operator !== "==" && node.operator !== "!==" && node.operator !== "!=") return;
|
|
3106
|
+
const mentionsConfirm = (n) => n?.type === "Identifier" && n.name === "confirmPassword";
|
|
3107
|
+
if (mentionsConfirm(node.left) || mentionsConfirm(node.right)) {
|
|
3108
|
+
hasPasswordComparison = true;
|
|
3109
|
+
}
|
|
3110
|
+
},
|
|
3444
3111
|
CallExpression(node) {
|
|
3445
|
-
if (!
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3112
|
+
if (!createUserLocalName) return;
|
|
3113
|
+
if (node.callee?.type === "Identifier" && node.callee.name === createUserLocalName) {
|
|
3114
|
+
createUserCalls.push(node);
|
|
3115
|
+
}
|
|
3116
|
+
},
|
|
3117
|
+
"Program:exit"() {
|
|
3118
|
+
if (!createUserLocalName) return;
|
|
3119
|
+
if (createUserCalls.length === 0) return;
|
|
3120
|
+
if (!hasConfirmPasswordIdentifier) return;
|
|
3121
|
+
if (hasPasswordComparison) return;
|
|
3122
|
+
for (const call of createUserCalls) {
|
|
3123
|
+
context.report({ node: call, messageId: "passwordConfirmNotChecked" });
|
|
3450
3124
|
}
|
|
3451
3125
|
}
|
|
3452
3126
|
};
|
|
3453
3127
|
}
|
|
3454
3128
|
};
|
|
3455
|
-
var
|
|
3129
|
+
var firebaseSignupPasswordConfirmRule = rule43;
|
|
3456
3130
|
|
|
3457
|
-
// src/providers/
|
|
3458
|
-
function isOwnershipCheckCall(node) {
|
|
3459
|
-
if (node?.type !== "CallExpression") return false;
|
|
3460
|
-
const callee = node.callee;
|
|
3461
|
-
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
3462
|
-
if (!name) return false;
|
|
3463
|
-
return /owner|belongsto|hasaccess|authorize|verifyaccess|checkaccess|findproject|getproject/i.test(name);
|
|
3464
|
-
}
|
|
3465
|
-
function isSessionIdLikeArg(node) {
|
|
3466
|
-
if (node?.type === "Identifier") return /sessionid/i.test(node.name);
|
|
3467
|
-
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3468
|
-
return /sessionid/i.test(node.property.name);
|
|
3469
|
-
}
|
|
3470
|
-
return false;
|
|
3471
|
-
}
|
|
3131
|
+
// src/providers/firebase/rules/use-array-union-remove.ts
|
|
3472
3132
|
var rule44 = {
|
|
3473
3133
|
meta: {
|
|
3474
3134
|
type: "suggestion",
|
|
3475
3135
|
docs: {
|
|
3476
|
-
description: "
|
|
3477
|
-
category: "
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
docsUrl: "https://docs.browserbase.com/features/session-live-view",
|
|
3136
|
+
description: "Firestore array field updated with read-modify-write spread/filter instead of arrayUnion/arrayRemove",
|
|
3137
|
+
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.",
|
|
3139
|
+
docsUrl: "https://firebase.google.com/docs/firestore/manage-data/add-data#update_elements_in_an_array",
|
|
3481
3140
|
recommended: true
|
|
3482
3141
|
},
|
|
3483
3142
|
messages: {
|
|
3484
|
-
|
|
3143
|
+
useArrayUnionRemove: "Firestore array updated with read-modify-write spread/filter. Use arrayUnion() or arrayRemove() for atomic updates that do not require reading the document first."
|
|
3485
3144
|
},
|
|
3486
3145
|
schema: []
|
|
3487
3146
|
},
|
|
3488
3147
|
create(context) {
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3148
|
+
function hasSpreadOrFilterValue(obj) {
|
|
3149
|
+
if (obj?.type !== "ObjectExpression") return false;
|
|
3150
|
+
for (const prop of obj.properties ?? []) {
|
|
3151
|
+
if (prop?.type !== "Property") continue;
|
|
3152
|
+
const val = prop.value;
|
|
3153
|
+
if (val?.type === "ArrayExpression") {
|
|
3154
|
+
if ((val.elements ?? []).some((el) => el?.type === "SpreadElement")) {
|
|
3155
|
+
return true;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
if (val?.type === "CallExpression" && val.callee?.type === "MemberExpression" && val.callee.property?.type === "Identifier" && val.callee.property.name === "filter") {
|
|
3159
|
+
return true;
|
|
3160
|
+
}
|
|
3499
3161
|
}
|
|
3500
|
-
return
|
|
3162
|
+
return false;
|
|
3501
3163
|
}
|
|
3502
3164
|
return {
|
|
3503
|
-
FunctionDeclaration(node) {
|
|
3504
|
-
registerScope(node);
|
|
3505
|
-
},
|
|
3506
|
-
FunctionExpression(node) {
|
|
3507
|
-
registerScope(node);
|
|
3508
|
-
},
|
|
3509
|
-
ArrowFunctionExpression(node) {
|
|
3510
|
-
registerScope(node);
|
|
3511
|
-
},
|
|
3512
3165
|
CallExpression(node) {
|
|
3513
|
-
const
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
}
|
|
3521
|
-
if (isSessionsCall(node, "debug") || isSessionsRecordingCall(node, "retrieve")) {
|
|
3522
|
-
const sessionIdArg = (node.arguments ?? []).some(isSessionIdLikeArg);
|
|
3523
|
-
if (sessionIdArg) sensitiveCalls.push({ node, scope });
|
|
3524
|
-
}
|
|
3525
|
-
},
|
|
3526
|
-
"Program:exit"() {
|
|
3527
|
-
for (const { node, scope } of sensitiveCalls) {
|
|
3528
|
-
if (!scope || !scope.sawOwnershipCheck) {
|
|
3529
|
-
context.report({ node, messageId: "missingOwnershipCheck" });
|
|
3530
|
-
}
|
|
3166
|
+
const callee = node.callee;
|
|
3167
|
+
if (callee?.type !== "Identifier" || callee.name !== "updateDoc") return;
|
|
3168
|
+
const args = node.arguments ?? [];
|
|
3169
|
+
if (args.length < 2) return;
|
|
3170
|
+
const payload = args[1];
|
|
3171
|
+
if (hasSpreadOrFilterValue(payload)) {
|
|
3172
|
+
context.report({ node, messageId: "useArrayUnionRemove" });
|
|
3531
3173
|
}
|
|
3532
3174
|
}
|
|
3533
3175
|
};
|
|
3534
3176
|
}
|
|
3535
3177
|
};
|
|
3536
|
-
var
|
|
3178
|
+
var firebaseUseArrayUnionRemoveRule = rule44;
|
|
3537
3179
|
|
|
3538
|
-
// src/providers/
|
|
3539
|
-
function isPromiseAllCall2(node) {
|
|
3540
|
-
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.object?.type === "Identifier" && node.callee.object.name === "Promise" && node.callee.property?.type === "Identifier" && node.callee.property.name === "all";
|
|
3541
|
-
}
|
|
3542
|
-
function isMapCall2(node) {
|
|
3543
|
-
return memberPropName3(node) === "map";
|
|
3544
|
-
}
|
|
3545
|
-
function callbackParamNames(callback) {
|
|
3546
|
-
const names = /* @__PURE__ */ new Set();
|
|
3547
|
-
const param = callback?.params?.[0];
|
|
3548
|
-
if (param?.type === "Identifier") {
|
|
3549
|
-
names.add(param.name);
|
|
3550
|
-
} else if (param?.type === "ObjectPattern") {
|
|
3551
|
-
for (const p of param.properties ?? []) {
|
|
3552
|
-
if (p?.type === "Property" && p.value?.type === "Identifier") names.add(p.value.name);
|
|
3553
|
-
else if (p?.type === "RestElement" && p.argument?.type === "Identifier") names.add(p.argument.name);
|
|
3554
|
-
}
|
|
3555
|
-
}
|
|
3556
|
-
return names;
|
|
3557
|
-
}
|
|
3558
|
-
function contextIdValueIsShared(callback, contextIdNode) {
|
|
3559
|
-
if (contextIdNode?.type !== "Identifier") return false;
|
|
3560
|
-
const params = callbackParamNames(callback);
|
|
3561
|
-
if (params.has(contextIdNode.name)) return false;
|
|
3562
|
-
return true;
|
|
3563
|
-
}
|
|
3564
|
-
function findSharedContextCreateCall(callback) {
|
|
3565
|
-
const body = callback?.body;
|
|
3566
|
-
if (!body) return null;
|
|
3567
|
-
let found = null;
|
|
3568
|
-
function visit(n) {
|
|
3569
|
-
if (found || !n || typeof n !== "object") return;
|
|
3570
|
-
if (Array.isArray(n)) {
|
|
3571
|
-
for (const item of n) visit(item);
|
|
3572
|
-
return;
|
|
3573
|
-
}
|
|
3574
|
-
if (typeof n.type !== "string") return;
|
|
3575
|
-
if (isSessionsCall(n, "create")) {
|
|
3576
|
-
const optsArg = n.arguments?.[0];
|
|
3577
|
-
const browserSettings = findProperty2(optsArg, "browserSettings")?.value;
|
|
3578
|
-
const ctxProp = findProperty2(browserSettings, "context")?.value;
|
|
3579
|
-
const idProp = findProperty2(ctxProp, "id");
|
|
3580
|
-
if (idProp && contextIdValueIsShared(callback, idProp.value)) {
|
|
3581
|
-
found = n;
|
|
3582
|
-
return;
|
|
3583
|
-
}
|
|
3584
|
-
}
|
|
3585
|
-
for (const key of Object.keys(n)) {
|
|
3586
|
-
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3587
|
-
visit(n[key]);
|
|
3588
|
-
}
|
|
3589
|
-
}
|
|
3590
|
-
visit(body);
|
|
3591
|
-
return found;
|
|
3592
|
-
}
|
|
3180
|
+
// src/providers/firebase/rules/duplicate-initialize-app.ts
|
|
3593
3181
|
var rule45 = {
|
|
3594
3182
|
meta: {
|
|
3595
|
-
type: "
|
|
3183
|
+
type: "suggestion",
|
|
3596
3184
|
docs: {
|
|
3597
|
-
description: "
|
|
3185
|
+
description: "initializeApp called without checking getApps() first \u2014 risk of duplicate-app crash",
|
|
3598
3186
|
category: "correctness",
|
|
3599
|
-
rationale:
|
|
3600
|
-
docsUrl: "https://
|
|
3187
|
+
rationale: 'Calling initializeApp() when the default app already exists throws "Firebase App named [DEFAULT] already exists". Always guard with getApps().length to make initialization idempotent across hot reloads and server-side rendering.',
|
|
3188
|
+
docsUrl: "https://firebase.google.com/docs/projects/multiprojects",
|
|
3601
3189
|
recommended: true
|
|
3602
3190
|
},
|
|
3603
3191
|
messages: {
|
|
3604
|
-
|
|
3192
|
+
duplicateInitializeApp: 'initializeApp() called without checking getApps() first. Calling it twice throws "Firebase App already exists". Use: getApps().length ? getApp() : initializeApp(config)'
|
|
3605
3193
|
},
|
|
3606
3194
|
schema: []
|
|
3607
3195
|
},
|
|
3608
3196
|
create(context) {
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
if (!callback) return;
|
|
3614
|
-
const sharedCall = findSharedContextCreateCall(callback);
|
|
3615
|
-
if (sharedCall) {
|
|
3616
|
-
context.report({ node: sharedCall, messageId: "concurrentSharedContext" });
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3197
|
+
let initializeAppLocalName;
|
|
3198
|
+
let hasGetAppsCall = false;
|
|
3199
|
+
const initializeAppCalls = [];
|
|
3200
|
+
const FIREBASE_APP_SOURCES = /* @__PURE__ */ new Set(["firebase/app", "firebase-admin/app", "firebase-admin"]);
|
|
3619
3201
|
return {
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3202
|
+
ImportDeclaration(node) {
|
|
3203
|
+
for (const src of FIREBASE_APP_SOURCES) {
|
|
3204
|
+
const imports = namedImportsFrom(node, src);
|
|
3205
|
+
if (imports.has("initializeApp")) {
|
|
3206
|
+
initializeAppLocalName = imports.get("initializeApp");
|
|
3207
|
+
}
|
|
3623
3208
|
}
|
|
3624
3209
|
},
|
|
3625
3210
|
CallExpression(node) {
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
if (
|
|
3629
|
-
|
|
3630
|
-
return;
|
|
3211
|
+
const callee = node.callee;
|
|
3212
|
+
if (callee?.type !== "Identifier") return;
|
|
3213
|
+
if (initializeAppLocalName && callee.name === initializeAppLocalName) {
|
|
3214
|
+
initializeAppCalls.push(node);
|
|
3631
3215
|
}
|
|
3632
|
-
if (
|
|
3633
|
-
|
|
3634
|
-
|
|
3216
|
+
if (callee.name === "getApps") {
|
|
3217
|
+
hasGetAppsCall = true;
|
|
3218
|
+
}
|
|
3219
|
+
},
|
|
3220
|
+
"Program:exit"() {
|
|
3221
|
+
if (hasGetAppsCall) return;
|
|
3222
|
+
for (const call of initializeAppCalls) {
|
|
3223
|
+
context.report({ node: call, messageId: "duplicateInitializeApp" });
|
|
3635
3224
|
}
|
|
3636
3225
|
}
|
|
3637
3226
|
};
|
|
3638
3227
|
}
|
|
3639
3228
|
};
|
|
3640
|
-
var
|
|
3229
|
+
var firebaseDuplicateInitializeAppRule = rule45;
|
|
3641
3230
|
|
|
3642
|
-
// src/providers/
|
|
3643
|
-
function isMobileLiteral(node) {
|
|
3644
|
-
return node?.type === "Literal" && (node.value === "mobile" || node.value === "tablet");
|
|
3645
|
-
}
|
|
3646
|
-
function testComparesToMobile(test) {
|
|
3647
|
-
if (test?.type !== "BinaryExpression") return false;
|
|
3648
|
-
if (test.operator !== "===" && test.operator !== "==") return false;
|
|
3649
|
-
return isMobileLiteral(test.left) || isMobileLiteral(test.right);
|
|
3650
|
-
}
|
|
3651
|
-
function isViewportReference(node) {
|
|
3652
|
-
if (node?.type === "Identifier") return /viewport/i.test(node.name);
|
|
3653
|
-
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3654
|
-
return /viewport|width|height/i.test(node.property.name);
|
|
3655
|
-
}
|
|
3656
|
-
if (node?.type === "Property") {
|
|
3657
|
-
return node.key?.type === "Identifier" && /viewport|width|height/i.test(node.key.name);
|
|
3658
|
-
}
|
|
3659
|
-
return false;
|
|
3660
|
-
}
|
|
3661
|
-
function setsViewport(node) {
|
|
3662
|
-
return someDescendant2(node, isViewportReference);
|
|
3663
|
-
}
|
|
3664
|
-
function isOsMobileSetting(node) {
|
|
3665
|
-
if (node?.type === "Property" && node.key?.type === "Identifier" && node.key.name === "os" && isMobileLiteral(node.value)) {
|
|
3666
|
-
return true;
|
|
3667
|
-
}
|
|
3668
|
-
if (node?.type === "AssignmentExpression" && node.left?.type === "MemberExpression" && !node.left.computed && node.left.property?.type === "Identifier" && node.left.property.name === "os" && isMobileLiteral(node.right)) {
|
|
3669
|
-
return true;
|
|
3670
|
-
}
|
|
3671
|
-
return false;
|
|
3672
|
-
}
|
|
3231
|
+
// src/providers/firebase/rules/onSnapshot-async-throw.ts
|
|
3673
3232
|
var rule46 = {
|
|
3674
3233
|
meta: {
|
|
3675
|
-
type: "
|
|
3234
|
+
type: "suggestion",
|
|
3676
3235
|
docs: {
|
|
3677
|
-
description: "
|
|
3678
|
-
category: "
|
|
3679
|
-
rationale:
|
|
3680
|
-
docsUrl: "https://
|
|
3236
|
+
description: "throw inside async onSnapshot callback creates an unhandled promise rejection",
|
|
3237
|
+
category: "reliability",
|
|
3238
|
+
rationale: "onSnapshot does not handle Promise rejections from its callback. A throw inside an async callback becomes an unhandled rejection, silently terminating the listener and leaving the UI in a broken state with no error feedback.",
|
|
3239
|
+
docsUrl: "https://firebase.google.com/docs/firestore/query-data/listen#handle_listen_errors",
|
|
3681
3240
|
recommended: true
|
|
3682
3241
|
},
|
|
3683
3242
|
messages: {
|
|
3684
|
-
|
|
3243
|
+
asyncThrowInSnapshot: "throw inside an async onSnapshot callback creates an unhandled promise rejection. The listener silently stops. Use return with error logging or the onSnapshot error callback instead."
|
|
3685
3244
|
},
|
|
3686
3245
|
schema: []
|
|
3687
3246
|
},
|
|
3688
3247
|
create(context) {
|
|
3689
|
-
|
|
3690
|
-
|
|
3248
|
+
function isAsyncFn(node) {
|
|
3249
|
+
return (node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression") && node.async === true;
|
|
3250
|
+
}
|
|
3251
|
+
function hasDirectThrow(fnNode) {
|
|
3252
|
+
if (!fnNode?.body) return false;
|
|
3253
|
+
function visit(n, depth) {
|
|
3254
|
+
if (!n || typeof n !== "object") return false;
|
|
3255
|
+
if (Array.isArray(n)) return n.some((item) => visit(item, depth));
|
|
3256
|
+
if (typeof n.type !== "string") return false;
|
|
3257
|
+
if (depth > 0 && (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression")) {
|
|
3258
|
+
return false;
|
|
3259
|
+
}
|
|
3260
|
+
if (n.type === "ThrowStatement") return true;
|
|
3261
|
+
for (const key of Object.keys(n)) {
|
|
3262
|
+
if (key === "parent" || key === "loc" || key === "range" || key === "type") continue;
|
|
3263
|
+
if (visit(n[key], depth + 1)) return true;
|
|
3264
|
+
}
|
|
3265
|
+
return false;
|
|
3266
|
+
}
|
|
3267
|
+
return visit(fnNode.body, 0);
|
|
3268
|
+
}
|
|
3691
3269
|
return {
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
AssignmentExpression(node) {
|
|
3701
|
-
if (isOsMobileSetting(node)) sawOsMobileSetting = true;
|
|
3702
|
-
},
|
|
3703
|
-
"Program:exit"() {
|
|
3704
|
-
if (sawOsMobileSetting) return;
|
|
3705
|
-
for (const branch of mobileViewportBranches) {
|
|
3706
|
-
context.report({ node: branch, messageId: "missingOsSetting" });
|
|
3270
|
+
CallExpression(node) {
|
|
3271
|
+
const callee = node.callee;
|
|
3272
|
+
if (callee?.type !== "Identifier" || callee.name !== "onSnapshot") return;
|
|
3273
|
+
for (const arg of node.arguments ?? []) {
|
|
3274
|
+
if (!isAsyncFn(arg)) continue;
|
|
3275
|
+
if (hasDirectThrow(arg)) {
|
|
3276
|
+
context.report({ node: arg, messageId: "asyncThrowInSnapshot" });
|
|
3277
|
+
}
|
|
3707
3278
|
}
|
|
3708
3279
|
}
|
|
3709
3280
|
};
|
|
3710
3281
|
}
|
|
3711
3282
|
};
|
|
3712
|
-
var
|
|
3283
|
+
var firebaseOnSnapshotAsyncThrowRule = rule46;
|
|
3713
3284
|
|
|
3714
|
-
// src/providers/
|
|
3715
|
-
function isStringifiedErrorSource(node, errName) {
|
|
3716
|
-
if (node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String") {
|
|
3717
|
-
const arg = node.arguments?.[0];
|
|
3718
|
-
return arg?.type === "Identifier" && arg.name === errName;
|
|
3719
|
-
}
|
|
3720
|
-
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression") {
|
|
3721
|
-
const obj = node.callee.object;
|
|
3722
|
-
const prop = node.callee.property;
|
|
3723
|
-
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "toString") {
|
|
3724
|
-
return true;
|
|
3725
|
-
}
|
|
3726
|
-
}
|
|
3727
|
-
if (node?.type === "MemberExpression" && !node.computed) {
|
|
3728
|
-
const obj = node.object;
|
|
3729
|
-
const prop = node.property;
|
|
3730
|
-
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "message") {
|
|
3731
|
-
return true;
|
|
3732
|
-
}
|
|
3733
|
-
}
|
|
3734
|
-
return false;
|
|
3735
|
-
}
|
|
3736
|
-
function unwrapToLowerCase(node) {
|
|
3737
|
-
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "toLowerCase") {
|
|
3738
|
-
return node.callee.object;
|
|
3739
|
-
}
|
|
3740
|
-
return node;
|
|
3741
|
-
}
|
|
3742
|
-
function usesTypedStatus(body, errName) {
|
|
3743
|
-
return someDescendant2(body, (n) => {
|
|
3744
|
-
if (n.type === "MemberExpression" && !n.computed && n.object?.type === "Identifier" && n.object.name === errName) {
|
|
3745
|
-
return n.property?.type === "Identifier" && n.property.name === "status";
|
|
3746
|
-
}
|
|
3747
|
-
if (n.type === "BinaryExpression" && n.operator === "instanceof" && n.left?.type === "Identifier" && n.left.name === errName) {
|
|
3748
|
-
return true;
|
|
3749
|
-
}
|
|
3750
|
-
return false;
|
|
3751
|
-
});
|
|
3752
|
-
}
|
|
3753
|
-
function usesSubstringMatch(body, errName, stringifiedVars) {
|
|
3754
|
-
return someDescendant2(body, (n) => {
|
|
3755
|
-
if (n.type !== "CallExpression") return false;
|
|
3756
|
-
if (n.callee?.type !== "MemberExpression" || n.callee.computed) return false;
|
|
3757
|
-
if (n.callee.property?.type !== "Identifier" || n.callee.property.name !== "includes") return false;
|
|
3758
|
-
const source = unwrapToLowerCase(n.callee.object);
|
|
3759
|
-
if (isStringifiedErrorSource(source, errName)) return true;
|
|
3760
|
-
return source?.type === "Identifier" && stringifiedVars.has(source.name);
|
|
3761
|
-
});
|
|
3762
|
-
}
|
|
3763
|
-
function collectStringifiedVars(body, errName) {
|
|
3764
|
-
const names = /* @__PURE__ */ new Set();
|
|
3765
|
-
someDescendant2(body, (n) => {
|
|
3766
|
-
if (n.type === "VariableDeclarator" && n.id?.type === "Identifier" && n.init) {
|
|
3767
|
-
const source = unwrapToLowerCase(n.init);
|
|
3768
|
-
if (isStringifiedErrorSource(source, errName)) names.add(n.id.name);
|
|
3769
|
-
}
|
|
3770
|
-
return false;
|
|
3771
|
-
});
|
|
3772
|
-
return names;
|
|
3773
|
-
}
|
|
3285
|
+
// src/providers/firebase/rules/onSnapshot-missing-error-callback.ts
|
|
3774
3286
|
var rule47 = {
|
|
3775
3287
|
meta: {
|
|
3776
3288
|
type: "suggestion",
|
|
3777
3289
|
docs: {
|
|
3778
|
-
description: "
|
|
3779
|
-
category: "
|
|
3780
|
-
rationale:
|
|
3781
|
-
docsUrl: "https://
|
|
3290
|
+
description: "onSnapshot called without an error callback \u2014 Firestore permission errors silently stop the listener",
|
|
3291
|
+
category: "reliability",
|
|
3292
|
+
rationale: "When Firestore denies a read, the listener silently stops emitting snapshots with no error state set or user feedback. Always provide a third argument error callback to onSnapshot so permission denials and connection drops are surfaced.",
|
|
3293
|
+
docsUrl: "https://firebase.google.com/docs/firestore/query-data/listen#handle_listen_errors",
|
|
3782
3294
|
recommended: true
|
|
3783
3295
|
},
|
|
3784
3296
|
messages: {
|
|
3785
|
-
|
|
3297
|
+
missingErrorCallback: "onSnapshot called without an error callback. Firestore permission errors will silently stop the listener with no user feedback. Add an error callback: onSnapshot(ref, successFn, errorFn)."
|
|
3786
3298
|
},
|
|
3787
3299
|
schema: []
|
|
3788
3300
|
},
|
|
3789
3301
|
create(context) {
|
|
3302
|
+
function isFunctionNode(n) {
|
|
3303
|
+
return n?.type === "ArrowFunctionExpression" || n?.type === "FunctionExpression";
|
|
3304
|
+
}
|
|
3305
|
+
function isOptionsObject(n) {
|
|
3306
|
+
return n?.type === "ObjectExpression";
|
|
3307
|
+
}
|
|
3790
3308
|
return {
|
|
3791
|
-
|
|
3792
|
-
const
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
if (
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3309
|
+
CallExpression(node) {
|
|
3310
|
+
const callee = node.callee;
|
|
3311
|
+
if (callee?.type !== "Identifier" || callee.name !== "onSnapshot") return;
|
|
3312
|
+
const args = node.arguments ?? [];
|
|
3313
|
+
if (args.length === 2 && isFunctionNode(args[1])) {
|
|
3314
|
+
context.report({ node, messageId: "missingErrorCallback" });
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
if (args.length === 3 && isOptionsObject(args[1]) && isFunctionNode(args[2])) {
|
|
3318
|
+
context.report({ node, messageId: "missingErrorCallback" });
|
|
3799
3319
|
}
|
|
3800
3320
|
}
|
|
3801
3321
|
};
|
|
3802
3322
|
}
|
|
3803
3323
|
};
|
|
3804
|
-
var
|
|
3324
|
+
var firebaseOnSnapshotMissingErrorCallbackRule = rule47;
|
|
3805
3325
|
|
|
3806
|
-
// src/providers/
|
|
3807
|
-
function isSessionsCreateAwait(node) {
|
|
3808
|
-
return node?.type === "AwaitExpression" && isSessionsCall(node.argument, "create");
|
|
3809
|
-
}
|
|
3810
|
-
function isConnectCall(node) {
|
|
3811
|
-
if (node?.type !== "CallExpression") return false;
|
|
3812
|
-
const callee = node.callee;
|
|
3813
|
-
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3814
|
-
return !!name && /connect/i.test(name);
|
|
3815
|
-
}
|
|
3816
|
-
function isReleaseCall(node) {
|
|
3817
|
-
if (node?.type !== "CallExpression") return false;
|
|
3818
|
-
if (isSessionsCall(node, "update")) {
|
|
3819
|
-
const optsArg = node.arguments?.[1];
|
|
3820
|
-
const statusProp = findProperty2(optsArg, "status");
|
|
3821
|
-
if (statusProp?.value?.type === "Literal" && statusProp.value.value === "REQUEST_RELEASE") return true;
|
|
3822
|
-
}
|
|
3823
|
-
const callee = node.callee;
|
|
3824
|
-
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3825
|
-
return !!name && /requeststop|releasesession|release_session/i.test(name);
|
|
3826
|
-
}
|
|
3326
|
+
// src/providers/firebase/rules/firestore-document-size-guard.ts
|
|
3827
3327
|
var rule48 = {
|
|
3828
3328
|
meta: {
|
|
3829
|
-
type: "
|
|
3329
|
+
type: "suggestion",
|
|
3830
3330
|
docs: {
|
|
3831
|
-
description: "
|
|
3331
|
+
description: "Firestore write includes editor.getJSON() without a document size guard",
|
|
3832
3332
|
category: "reliability",
|
|
3833
|
-
rationale:
|
|
3834
|
-
docsUrl: "https://
|
|
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 silently fails with INVALID_ARGUMENT. Always check document size before calling updateDoc/setDoc when the payload includes editor JSON.",
|
|
3334
|
+
docsUrl: "https://firebase.google.com/docs/firestore/quotas#limits",
|
|
3835
3335
|
recommended: true
|
|
3836
3336
|
},
|
|
3837
3337
|
messages: {
|
|
3838
|
-
|
|
3338
|
+
missingDocumentSizeGuard: "Firestore write includes editor.getJSON() without a document size check. Base64 images can exceed the 1 MiB Firestore limit, silently failing the write. Add a Blob size guard before calling updateDoc/setDoc."
|
|
3839
3339
|
},
|
|
3840
3340
|
schema: []
|
|
3841
3341
|
},
|
|
3842
3342
|
create(context) {
|
|
3843
|
-
|
|
3844
|
-
const
|
|
3845
|
-
|
|
3343
|
+
const WRITE_FNS = /* @__PURE__ */ new Set(["updateDoc", "setDoc"]);
|
|
3344
|
+
const flagCandidates = [];
|
|
3345
|
+
let hasBlobCheck = false;
|
|
3346
|
+
function containsGetJson(args) {
|
|
3347
|
+
return args.some(
|
|
3348
|
+
(arg) => someDescendant(arg, (n) => {
|
|
3349
|
+
if (n?.type !== "CallExpression") return false;
|
|
3350
|
+
const callee = n.callee;
|
|
3351
|
+
return callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.name === "getJSON";
|
|
3352
|
+
})
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3846
3355
|
return {
|
|
3847
|
-
AwaitExpression(node) {
|
|
3848
|
-
if (isSessionsCreateAwait(node)) sawSessionCreate = true;
|
|
3849
|
-
},
|
|
3850
|
-
TryStatement(node) {
|
|
3851
|
-
tryStatements.push(node);
|
|
3852
|
-
},
|
|
3853
3356
|
CallExpression(node) {
|
|
3854
|
-
|
|
3357
|
+
const callee = node.callee;
|
|
3358
|
+
if (callee?.type === "Identifier" && WRITE_FNS.has(callee.name)) {
|
|
3359
|
+
if (containsGetJson(node.arguments ?? [])) {
|
|
3360
|
+
flagCandidates.push(node);
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
},
|
|
3364
|
+
NewExpression(node) {
|
|
3365
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "Blob") {
|
|
3366
|
+
hasBlobCheck = true;
|
|
3367
|
+
}
|
|
3855
3368
|
},
|
|
3856
3369
|
"Program:exit"() {
|
|
3857
|
-
if (
|
|
3858
|
-
for (const
|
|
3859
|
-
|
|
3860
|
-
const handled = !!enclosingTry?.handler && someDescendant2(enclosingTry.handler, isReleaseCall);
|
|
3861
|
-
if (!handled) {
|
|
3862
|
-
context.report({ node: call, messageId: "sessionNotReleasedOnFailure" });
|
|
3863
|
-
}
|
|
3370
|
+
if (hasBlobCheck) return;
|
|
3371
|
+
for (const node of flagCandidates) {
|
|
3372
|
+
context.report({ node, messageId: "missingDocumentSizeGuard" });
|
|
3864
3373
|
}
|
|
3865
3374
|
}
|
|
3866
3375
|
};
|
|
3867
3376
|
}
|
|
3868
3377
|
};
|
|
3869
|
-
var
|
|
3378
|
+
var firebaseFirestoreDocumentSizeGuardRule = rule48;
|
|
3870
3379
|
|
|
3871
|
-
// src/providers/
|
|
3380
|
+
// src/providers/firebase/rules/use-timestamp-now.ts
|
|
3872
3381
|
var rule49 = {
|
|
3873
3382
|
meta: {
|
|
3874
3383
|
type: "suggestion",
|
|
3875
3384
|
docs: {
|
|
3876
|
-
description: "
|
|
3877
|
-
category: "
|
|
3878
|
-
rationale: "
|
|
3879
|
-
docsUrl: "https://
|
|
3385
|
+
description: "new Date() used for Firestore timestamp instead of Timestamp.now() or serverTimestamp()",
|
|
3386
|
+
category: "correctness",
|
|
3387
|
+
rationale: "While Firestore auto-converts Date objects on write, mixing new Date() with Timestamp.now() creates type inconsistencies. Firestore security rules that compare request.resource.data.createdAt against request.time expect a Timestamp on both sides; a bare Date object can cause silent rule evaluation failures.",
|
|
3388
|
+
docsUrl: "https://firebase.google.com/docs/reference/js/firestore_.timestamp",
|
|
3880
3389
|
recommended: true
|
|
3881
3390
|
},
|
|
3882
3391
|
messages: {
|
|
3883
|
-
|
|
3392
|
+
useTimestampNow: "Use Timestamp.now() or serverTimestamp() instead of new Date() for Firestore timestamp fields. new Date() creates type inconsistencies with security rules that compare against request.time."
|
|
3884
3393
|
},
|
|
3885
3394
|
schema: []
|
|
3886
3395
|
},
|
|
3887
3396
|
create(context) {
|
|
3888
|
-
|
|
3397
|
+
let importsFromFirestore = false;
|
|
3889
3398
|
return {
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
loopRanges.push(node);
|
|
3895
|
-
},
|
|
3896
|
-
DoWhileStatement(node) {
|
|
3897
|
-
loopRanges.push(node);
|
|
3898
|
-
},
|
|
3899
|
-
CallExpression(node) {
|
|
3900
|
-
if (!isSessionsCall(node, "create")) return;
|
|
3901
|
-
const insideLoop = loopRanges.some((loop) => {
|
|
3902
|
-
const start = loop.range?.[0] ?? loop.start;
|
|
3903
|
-
const end = loop.range?.[1] ?? loop.end;
|
|
3904
|
-
const pos = node.range?.[0] ?? node.start;
|
|
3905
|
-
return pos >= start && pos <= end;
|
|
3906
|
-
});
|
|
3907
|
-
if (!insideLoop) return;
|
|
3908
|
-
const optsArg = node.arguments?.[1];
|
|
3909
|
-
const hasMaxRetries = optsArg?.type === "ObjectExpression" && !!findProperty2(optsArg, "maxRetries");
|
|
3910
|
-
if (!hasMaxRetries) {
|
|
3911
|
-
context.report({ node, messageId: "stackedRetry" });
|
|
3399
|
+
ImportDeclaration(node) {
|
|
3400
|
+
const src = node.source?.value;
|
|
3401
|
+
if (typeof src === "string" && (src.startsWith("firebase/") || src === "firebase-admin/firestore")) {
|
|
3402
|
+
importsFromFirestore = true;
|
|
3912
3403
|
}
|
|
3404
|
+
},
|
|
3405
|
+
NewExpression(node) {
|
|
3406
|
+
if (!importsFromFirestore) return;
|
|
3407
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "Date") return;
|
|
3408
|
+
if ((node.arguments ?? []).length !== 0) return;
|
|
3409
|
+
context.report({ node, messageId: "useTimestampNow" });
|
|
3913
3410
|
}
|
|
3914
3411
|
};
|
|
3915
3412
|
}
|
|
3916
3413
|
};
|
|
3917
|
-
var
|
|
3414
|
+
var firebaseUseTimestampNowRule = rule49;
|
|
3918
3415
|
|
|
3919
|
-
// src/providers/
|
|
3920
|
-
var
|
|
3921
|
-
function
|
|
3922
|
-
if (
|
|
3923
|
-
|
|
3924
|
-
collectIncludesLiterals(test.left, literals);
|
|
3925
|
-
collectIncludesLiterals(test.right, literals);
|
|
3926
|
-
return;
|
|
3416
|
+
// src/providers/lovable/utils.ts
|
|
3417
|
+
var LLM_HOST_PATTERN = /api\.anthropic\.com|api\.openai\.com/;
|
|
3418
|
+
function containsKnownLlmHost(node) {
|
|
3419
|
+
if (node?.type === "Literal" && typeof node.value === "string") {
|
|
3420
|
+
return LLM_HOST_PATTERN.test(node.value);
|
|
3927
3421
|
}
|
|
3928
|
-
if (
|
|
3929
|
-
|
|
3930
|
-
if (arg?.type === "Literal" && typeof arg.value === "string") literals.push(arg.value.toLowerCase());
|
|
3422
|
+
if (node?.type === "TemplateLiteral") {
|
|
3423
|
+
return (node.quasis ?? []).some((q) => LLM_HOST_PATTERN.test(q.value?.raw ?? ""));
|
|
3931
3424
|
}
|
|
3425
|
+
return false;
|
|
3932
3426
|
}
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
const callee = node.callee;
|
|
3936
|
-
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
3937
|
-
return !!name && /release|remove|cleanup|stop|teardown/i.test(name);
|
|
3938
|
-
}
|
|
3427
|
+
|
|
3428
|
+
// src/providers/lovable/rules/no-client-side-secret-fetch.ts
|
|
3939
3429
|
var rule50 = {
|
|
3940
3430
|
meta: {
|
|
3941
3431
|
type: "problem",
|
|
3942
3432
|
docs: {
|
|
3943
|
-
description: "
|
|
3944
|
-
category: "
|
|
3945
|
-
|
|
3946
|
-
|
|
3433
|
+
description: "LLM provider calls must not run client-side with a VITE_-exposed API key",
|
|
3434
|
+
category: "security",
|
|
3435
|
+
cwe: "CWE-522",
|
|
3436
|
+
owasp: "A02:2021 \u2013 Cryptographic Failures",
|
|
3437
|
+
rationale: "Anything read from import.meta.env.VITE_* is inlined into the production JS bundle at build time and is visible to every site visitor via the Network tab or by reading the bundle \u2014 no git access required. Lovable documents Edge Functions specifically so the provider key stays server-side in Secrets and the browser calls your own Edge Function instead of the provider directly.",
|
|
3438
|
+
docsUrl: "https://docs.lovable.dev/features/security",
|
|
3947
3439
|
recommended: true
|
|
3948
3440
|
},
|
|
3949
3441
|
messages: {
|
|
3950
|
-
|
|
3951
|
-
}
|
|
3952
|
-
schema: []
|
|
3442
|
+
clientSideSecretFetch: "This fetch() calls an LLM provider directly with a key sourced from import.meta.env.VITE_* \u2014 that key ships into the browser bundle and is visible to every visitor."
|
|
3443
|
+
}
|
|
3953
3444
|
},
|
|
3954
3445
|
create(context) {
|
|
3446
|
+
const viteVars = /* @__PURE__ */ new Set();
|
|
3447
|
+
function propName2(node) {
|
|
3448
|
+
if (!node) return void 0;
|
|
3449
|
+
if (node.type === "Identifier") return node.name;
|
|
3450
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
3451
|
+
return void 0;
|
|
3452
|
+
}
|
|
3453
|
+
function isImportMetaEnvViteAccess(node) {
|
|
3454
|
+
if (node?.type !== "MemberExpression" || node.computed) return false;
|
|
3455
|
+
const propertyName = node.property?.name;
|
|
3456
|
+
if (typeof propertyName !== "string" || !propertyName.startsWith("VITE_")) return false;
|
|
3457
|
+
const envMember = node.object;
|
|
3458
|
+
if (envMember?.type !== "MemberExpression" || envMember.computed) return false;
|
|
3459
|
+
if (envMember.property?.name !== "env") return false;
|
|
3460
|
+
const metaProp = envMember.object;
|
|
3461
|
+
return metaProp?.type === "MetaProperty" && metaProp.meta?.name === "import" && metaProp.property?.name === "meta";
|
|
3462
|
+
}
|
|
3463
|
+
function referencesViteSecret(node) {
|
|
3464
|
+
if (!node) return false;
|
|
3465
|
+
if (isImportMetaEnvViteAccess(node)) return true;
|
|
3466
|
+
if (node.type === "Identifier" && viteVars.has(node.name)) return true;
|
|
3467
|
+
if (node.type === "TemplateLiteral") {
|
|
3468
|
+
return (node.expressions ?? []).some((e) => referencesViteSecret(e));
|
|
3469
|
+
}
|
|
3470
|
+
return false;
|
|
3471
|
+
}
|
|
3955
3472
|
return {
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
if (literals.length < 2) return;
|
|
3960
|
-
const overbroad = literals.find((l) => OVERBROAD_TERMS.has(l));
|
|
3961
|
-
if (!overbroad) return;
|
|
3962
|
-
if (someDescendant2(node.consequent, isCleanupCall)) {
|
|
3963
|
-
context.report({ node, messageId: "overbroadMatch", data: { term: overbroad } });
|
|
3473
|
+
VariableDeclarator(node) {
|
|
3474
|
+
if (node.id?.type === "Identifier" && isImportMetaEnvViteAccess(node.init)) {
|
|
3475
|
+
viteVars.add(node.id.name);
|
|
3964
3476
|
}
|
|
3965
|
-
}
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
3989
|
-
}
|
|
3990
|
-
if (callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier") {
|
|
3991
|
-
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
3992
|
-
const methodName = callee.property.name;
|
|
3993
|
-
if (objName && /^(axios|http|https)$/i.test(objName) && /^(get|post|put|patch|delete|request)$/i.test(methodName)) {
|
|
3994
|
-
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
3995
|
-
}
|
|
3996
|
-
}
|
|
3997
|
-
if (callee?.type === "Identifier" && callee.name === "axios") {
|
|
3998
|
-
const arg = node.arguments?.[0];
|
|
3999
|
-
if (arg?.type === "ObjectExpression") {
|
|
4000
|
-
const urlProp = arg.properties?.find(
|
|
4001
|
-
(p) => p?.type === "Property" && p.key?.type === "Identifier" && p.key.name === "url"
|
|
4002
|
-
);
|
|
4003
|
-
return { isCall: true, urlArg: urlProp?.value };
|
|
4004
|
-
}
|
|
4005
|
-
return { isCall: true, urlArg: arg };
|
|
3477
|
+
},
|
|
3478
|
+
CallExpression(node) {
|
|
3479
|
+
const callee = node.callee;
|
|
3480
|
+
if (callee?.type !== "Identifier" || callee.name !== "fetch") return;
|
|
3481
|
+
const urlArg = node.arguments?.[0];
|
|
3482
|
+
if (!containsKnownLlmHost(urlArg)) return;
|
|
3483
|
+
const optsArg = node.arguments?.[1];
|
|
3484
|
+
if (optsArg?.type !== "ObjectExpression") return;
|
|
3485
|
+
const headersProp = (optsArg.properties ?? []).find(
|
|
3486
|
+
(p) => p?.type === "Property" && propName2(p.key) === "headers"
|
|
3487
|
+
);
|
|
3488
|
+
if (!headersProp || headersProp.value?.type !== "ObjectExpression") return;
|
|
3489
|
+
for (const hp of headersProp.value.properties ?? []) {
|
|
3490
|
+
if (hp?.type !== "Property") continue;
|
|
3491
|
+
const keyName = propName2(hp.key)?.toLowerCase();
|
|
3492
|
+
if (keyName !== "x-api-key" && keyName !== "authorization") continue;
|
|
3493
|
+
if (referencesViteSecret(hp.value)) {
|
|
3494
|
+
context.report({ node, messageId: "clientSideSecretFetch" });
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
};
|
|
4006
3500
|
}
|
|
4007
|
-
|
|
4008
|
-
|
|
3501
|
+
};
|
|
3502
|
+
var lovableNoClientSideSecretFetchRule = rule50;
|
|
3503
|
+
|
|
3504
|
+
// src/providers/lovable/rules/paid-flag-without-edge-function.ts
|
|
3505
|
+
var PRICE_PATTERN = /\$\s?\d/;
|
|
3506
|
+
var FLAG_PROPERTY_PATTERN = /^is_[a-z0-9_]+$/i;
|
|
3507
|
+
var FLAG_SUFFIX_PATTERN = /_(unlocked|active|enabled)$/i;
|
|
4009
3508
|
var rule51 = {
|
|
4010
3509
|
meta: {
|
|
4011
|
-
type: "
|
|
3510
|
+
type: "problem",
|
|
4012
3511
|
docs: {
|
|
4013
|
-
description: "
|
|
4014
|
-
category: "
|
|
4015
|
-
|
|
4016
|
-
|
|
3512
|
+
description: "A paid feature flag must not be set by a direct database update with no payment-provider call",
|
|
3513
|
+
category: "security",
|
|
3514
|
+
cwe: "CWE-840",
|
|
3515
|
+
owasp: "A04:2021 \u2013 Insecure Design",
|
|
3516
|
+
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/features/cloud",
|
|
4017
3518
|
recommended: true
|
|
4018
3519
|
},
|
|
4019
3520
|
messages: {
|
|
4020
|
-
|
|
4021
|
-
}
|
|
4022
|
-
schema: []
|
|
3521
|
+
paidFlagWithoutPayment: "This sets a paid-looking flag via a direct database update, but nothing in this function calls a payment provider or Edge Function first \u2014 the flag can be set without anyone paying."
|
|
3522
|
+
}
|
|
4023
3523
|
},
|
|
4024
3524
|
create(context) {
|
|
4025
|
-
const
|
|
3525
|
+
const stack = [];
|
|
3526
|
+
const states = /* @__PURE__ */ new Map();
|
|
3527
|
+
let sawPriceLabel = false;
|
|
3528
|
+
function ensureState(fn) {
|
|
3529
|
+
let s = states.get(fn);
|
|
3530
|
+
if (!s) {
|
|
3531
|
+
s = { sawFlagUpdate: false, updateNode: null, sawPaymentCall: false };
|
|
3532
|
+
states.set(fn, s);
|
|
3533
|
+
}
|
|
3534
|
+
return s;
|
|
3535
|
+
}
|
|
3536
|
+
function top() {
|
|
3537
|
+
return stack[stack.length - 1];
|
|
3538
|
+
}
|
|
3539
|
+
function pushScope(node) {
|
|
3540
|
+
stack.push(node);
|
|
3541
|
+
ensureState(node);
|
|
3542
|
+
}
|
|
3543
|
+
function popScope() {
|
|
3544
|
+
stack.pop();
|
|
3545
|
+
}
|
|
3546
|
+
function propName2(node) {
|
|
3547
|
+
if (!node) return void 0;
|
|
3548
|
+
if (node.type === "Identifier") return node.name;
|
|
3549
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
3550
|
+
return void 0;
|
|
3551
|
+
}
|
|
3552
|
+
function isBooleanFlagProperty(name) {
|
|
3553
|
+
if (!name) return false;
|
|
3554
|
+
return FLAG_PROPERTY_PATTERN.test(name) || FLAG_SUFFIX_PATTERN.test(name);
|
|
3555
|
+
}
|
|
3556
|
+
function isSupabaseUpdateCall(node) {
|
|
3557
|
+
if (node?.type !== "CallExpression") return false;
|
|
3558
|
+
const callee = node.callee;
|
|
3559
|
+
return callee?.type === "MemberExpression" && callee.property?.name === "update";
|
|
3560
|
+
}
|
|
3561
|
+
function updateSetsBooleanFlag(node) {
|
|
3562
|
+
const arg = node.arguments?.[0];
|
|
3563
|
+
if (arg?.type !== "ObjectExpression") return false;
|
|
3564
|
+
return (arg.properties ?? []).some(
|
|
3565
|
+
(p) => p?.type === "Property" && isBooleanFlagProperty(propName2(p.key))
|
|
3566
|
+
);
|
|
3567
|
+
}
|
|
3568
|
+
function memberChainNames2(node, names = []) {
|
|
3569
|
+
if (node?.type === "MemberExpression") {
|
|
3570
|
+
memberChainNames2(node.object, names);
|
|
3571
|
+
const n = propName2(node.property);
|
|
3572
|
+
if (n) names.push(n);
|
|
3573
|
+
} else if (node?.type === "Identifier") {
|
|
3574
|
+
names.push(node.name);
|
|
3575
|
+
} else if (node?.type === "CallExpression") {
|
|
3576
|
+
memberChainNames2(node.callee, names);
|
|
3577
|
+
}
|
|
3578
|
+
return names;
|
|
3579
|
+
}
|
|
3580
|
+
function isPaymentRelatedCall(node) {
|
|
3581
|
+
if (node?.type !== "CallExpression") return false;
|
|
3582
|
+
const chain = memberChainNames2(node.callee).join(".").toLowerCase();
|
|
3583
|
+
if (/stripe|checkout|payment/.test(chain)) return true;
|
|
3584
|
+
if (chain.includes("functions") && chain.endsWith(".invoke")) return true;
|
|
3585
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
3586
|
+
const urlArg = node.arguments?.[0];
|
|
3587
|
+
if (urlArg?.type === "Literal" && typeof urlArg.value === "string") {
|
|
3588
|
+
return urlArg.value.includes("/functions/");
|
|
3589
|
+
}
|
|
3590
|
+
if (urlArg?.type === "TemplateLiteral") {
|
|
3591
|
+
return (urlArg.quasis ?? []).some((q) => (q.value?.raw ?? "").includes("/functions/"));
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
return false;
|
|
3595
|
+
}
|
|
4026
3596
|
return {
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
3597
|
+
Program(node) {
|
|
3598
|
+
pushScope(node);
|
|
3599
|
+
},
|
|
3600
|
+
"Program:exit"() {
|
|
3601
|
+
if (!sawPriceLabel) return;
|
|
3602
|
+
for (const state of states.values()) {
|
|
3603
|
+
if (state.sawFlagUpdate && !state.sawPaymentCall) {
|
|
3604
|
+
context.report({ node: state.updateNode, messageId: "paidFlagWithoutPayment" });
|
|
3605
|
+
}
|
|
4030
3606
|
}
|
|
4031
3607
|
},
|
|
3608
|
+
FunctionDeclaration(node) {
|
|
3609
|
+
pushScope(node);
|
|
3610
|
+
},
|
|
3611
|
+
"FunctionDeclaration:exit"() {
|
|
3612
|
+
popScope();
|
|
3613
|
+
},
|
|
3614
|
+
FunctionExpression(node) {
|
|
3615
|
+
pushScope(node);
|
|
3616
|
+
},
|
|
3617
|
+
"FunctionExpression:exit"() {
|
|
3618
|
+
popScope();
|
|
3619
|
+
},
|
|
3620
|
+
ArrowFunctionExpression(node) {
|
|
3621
|
+
pushScope(node);
|
|
3622
|
+
},
|
|
3623
|
+
"ArrowFunctionExpression:exit"() {
|
|
3624
|
+
popScope();
|
|
3625
|
+
},
|
|
3626
|
+
Literal(node) {
|
|
3627
|
+
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3628
|
+
},
|
|
3629
|
+
JSXText(node) {
|
|
3630
|
+
if (typeof node.value === "string" && PRICE_PATTERN.test(node.value)) sawPriceLabel = true;
|
|
3631
|
+
},
|
|
3632
|
+
TemplateElement(node) {
|
|
3633
|
+
if (PRICE_PATTERN.test(node.value?.raw ?? "")) sawPriceLabel = true;
|
|
3634
|
+
},
|
|
4032
3635
|
CallExpression(node) {
|
|
4033
|
-
const
|
|
4034
|
-
if (!
|
|
4035
|
-
const
|
|
4036
|
-
if (
|
|
4037
|
-
|
|
3636
|
+
const fn = top();
|
|
3637
|
+
if (!fn) return;
|
|
3638
|
+
const state = ensureState(fn);
|
|
3639
|
+
if (isSupabaseUpdateCall(node) && updateSetsBooleanFlag(node) && !state.sawFlagUpdate) {
|
|
3640
|
+
state.sawFlagUpdate = true;
|
|
3641
|
+
state.updateNode = node;
|
|
3642
|
+
}
|
|
3643
|
+
if (isPaymentRelatedCall(node)) {
|
|
3644
|
+
state.sawPaymentCall = true;
|
|
4038
3645
|
}
|
|
4039
3646
|
}
|
|
4040
3647
|
};
|
|
4041
3648
|
}
|
|
4042
3649
|
};
|
|
4043
|
-
var
|
|
3650
|
+
var lovablePaidFlagWithoutEdgeFunctionRule = rule51;
|
|
4044
3651
|
|
|
4045
|
-
// src/providers/
|
|
3652
|
+
// src/providers/lovable/rules/expiry-column-never-checked.ts
|
|
3653
|
+
var EXPIRY_COLUMN_PATTERN = /_(until|expires?_at|expiry)$/i;
|
|
3654
|
+
var DATE_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([">", "<", ">=", "<="]);
|
|
3655
|
+
var FILTER_METHODS = /* @__PURE__ */ new Set(["gt", "gte", "lt", "lte"]);
|
|
4046
3656
|
var rule52 = {
|
|
4047
3657
|
meta: {
|
|
4048
3658
|
type: "suggestion",
|
|
4049
3659
|
docs: {
|
|
4050
|
-
description: "
|
|
4051
|
-
category: "
|
|
4052
|
-
rationale:
|
|
4053
|
-
docsUrl: "https://docs.
|
|
3660
|
+
description: "An expiry column must be checked against the current time somewhere",
|
|
3661
|
+
category: "correctness",
|
|
3662
|
+
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/features/cloud",
|
|
4054
3664
|
recommended: true
|
|
4055
3665
|
},
|
|
4056
3666
|
messages: {
|
|
4057
|
-
|
|
4058
|
-
}
|
|
4059
|
-
schema: []
|
|
3667
|
+
expiryNeverChecked: 'The "{{column}}" column is written here but is never compared against the current time anywhere in this file, so it never actually expires anything.'
|
|
3668
|
+
}
|
|
4060
3669
|
},
|
|
4061
3670
|
create(context) {
|
|
4062
|
-
const
|
|
4063
|
-
|
|
4064
|
-
|
|
3671
|
+
const writtenColumns = /* @__PURE__ */ new Map();
|
|
3672
|
+
const readColumns = /* @__PURE__ */ new Set();
|
|
3673
|
+
function propName2(node) {
|
|
3674
|
+
if (!node) return void 0;
|
|
3675
|
+
if (node.type === "Identifier") return node.name;
|
|
3676
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
3677
|
+
return void 0;
|
|
3678
|
+
}
|
|
3679
|
+
function isSupabaseUpdateCall(node) {
|
|
3680
|
+
if (node?.type !== "CallExpression") return false;
|
|
3681
|
+
const callee = node.callee;
|
|
3682
|
+
return callee?.type === "MemberExpression" && callee.property?.name === "update";
|
|
3683
|
+
}
|
|
3684
|
+
function containsDateNowOrNewDate(node, depth = 0) {
|
|
3685
|
+
if (!node || typeof node !== "object" || depth > 6) return false;
|
|
3686
|
+
if (node.type === "NewExpression" && node.callee?.type === "Identifier" && node.callee.name === "Date") {
|
|
3687
|
+
return true;
|
|
3688
|
+
}
|
|
3689
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Date" && node.callee.property?.name === "now") {
|
|
3690
|
+
return true;
|
|
3691
|
+
}
|
|
3692
|
+
if (node.type === "MemberExpression") {
|
|
3693
|
+
return containsDateNowOrNewDate(node.object, depth + 1) || containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3694
|
+
}
|
|
3695
|
+
if (node.type === "CallExpression") {
|
|
3696
|
+
return containsDateNowOrNewDate(node.callee, depth + 1);
|
|
3697
|
+
}
|
|
3698
|
+
return false;
|
|
3699
|
+
}
|
|
3700
|
+
function memberPropertyName(node) {
|
|
3701
|
+
if (node?.type !== "MemberExpression") return void 0;
|
|
3702
|
+
return propName2(node.property);
|
|
4065
3703
|
}
|
|
4066
3704
|
return {
|
|
4067
3705
|
CallExpression(node) {
|
|
4068
|
-
if (
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
3706
|
+
if (isSupabaseUpdateCall(node)) {
|
|
3707
|
+
const arg = node.arguments?.[0];
|
|
3708
|
+
if (arg?.type === "ObjectExpression") {
|
|
3709
|
+
for (const prop of arg.properties ?? []) {
|
|
3710
|
+
if (prop?.type !== "Property") continue;
|
|
3711
|
+
const keyName = propName2(prop.key);
|
|
3712
|
+
if (keyName && EXPIRY_COLUMN_PATTERN.test(keyName) && !writtenColumns.has(keyName)) {
|
|
3713
|
+
writtenColumns.set(keyName, node);
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
const callee = node.callee;
|
|
3719
|
+
if (callee?.type === "MemberExpression" && FILTER_METHODS.has(callee.property?.name)) {
|
|
3720
|
+
const colArg = node.arguments?.[0];
|
|
3721
|
+
const colName = colArg?.type === "Literal" ? propName2(colArg) : void 0;
|
|
3722
|
+
if (colName && EXPIRY_COLUMN_PATTERN.test(colName)) {
|
|
3723
|
+
readColumns.add(colName);
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
},
|
|
3727
|
+
BinaryExpression(node) {
|
|
3728
|
+
if (!DATE_COMPARISON_OPERATORS.has(node.operator)) return;
|
|
3729
|
+
const leftCol = memberPropertyName(node.left);
|
|
3730
|
+
const rightCol = memberPropertyName(node.right);
|
|
3731
|
+
if (leftCol && EXPIRY_COLUMN_PATTERN.test(leftCol) && containsDateNowOrNewDate(node.right)) {
|
|
3732
|
+
readColumns.add(leftCol);
|
|
3733
|
+
}
|
|
3734
|
+
if (rightCol && EXPIRY_COLUMN_PATTERN.test(rightCol) && containsDateNowOrNewDate(node.left)) {
|
|
3735
|
+
readColumns.add(rightCol);
|
|
3736
|
+
}
|
|
3737
|
+
},
|
|
3738
|
+
"Program:exit"() {
|
|
3739
|
+
for (const [column, node] of writtenColumns) {
|
|
3740
|
+
if (!readColumns.has(column)) {
|
|
3741
|
+
context.report({ node, messageId: "expiryNeverChecked", data: { column } });
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
};
|
|
3748
|
+
var lovableExpiryColumnNeverCheckedRule = rule52;
|
|
3749
|
+
|
|
3750
|
+
// src/providers/lovable/rules/silent-catch-on-provider-call.ts
|
|
3751
|
+
var LOGGING_CONSOLE_METHODS = /* @__PURE__ */ new Set(["error", "warn", "log", "info"]);
|
|
3752
|
+
var rule53 = {
|
|
3753
|
+
meta: {
|
|
3754
|
+
type: "suggestion",
|
|
3755
|
+
docs: {
|
|
3756
|
+
description: "A catch block around an LLM provider call must log the failure",
|
|
3757
|
+
category: "correctness",
|
|
3758
|
+
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/features/cloud",
|
|
3760
|
+
recommended: true
|
|
3761
|
+
},
|
|
3762
|
+
messages: {
|
|
3763
|
+
silentCatch: 'This catch block swallows a failed LLM provider call with no logging \u2014 a real failure (expired key, rate limit, network error) is indistinguishable from "no key configured."'
|
|
3764
|
+
}
|
|
3765
|
+
},
|
|
3766
|
+
create(context) {
|
|
3767
|
+
function findFetchToLlmHost(node, depth = 0) {
|
|
3768
|
+
if (!node || typeof node !== "object" || depth > 12) return null;
|
|
3769
|
+
if (Array.isArray(node)) {
|
|
3770
|
+
for (const n of node) {
|
|
3771
|
+
const found = findFetchToLlmHost(n, depth + 1);
|
|
3772
|
+
if (found) return found;
|
|
3773
|
+
}
|
|
3774
|
+
return null;
|
|
3775
|
+
}
|
|
3776
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "fetch") {
|
|
3777
|
+
const urlArg = node.arguments?.[0];
|
|
3778
|
+
if (containsKnownLlmHost(urlArg)) return node;
|
|
3779
|
+
}
|
|
3780
|
+
for (const key of Object.keys(node)) {
|
|
3781
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3782
|
+
const val = node[key];
|
|
3783
|
+
if (val && typeof val === "object") {
|
|
3784
|
+
const found = findFetchToLlmHost(val, depth + 1);
|
|
3785
|
+
if (found) return found;
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
return null;
|
|
3789
|
+
}
|
|
3790
|
+
function containsLoggingCall(node, depth = 0) {
|
|
3791
|
+
if (!node || typeof node !== "object" || depth > 12) return false;
|
|
3792
|
+
if (Array.isArray(node)) return node.some((n) => containsLoggingCall(n, depth + 1));
|
|
3793
|
+
if (node.type === "CallExpression") {
|
|
3794
|
+
const callee = node.callee;
|
|
3795
|
+
if (callee?.type === "MemberExpression") {
|
|
3796
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
3797
|
+
const propertyName = callee.property?.name;
|
|
3798
|
+
if (objName === "console" && LOGGING_CONSOLE_METHODS.has(propertyName)) return true;
|
|
3799
|
+
if (propertyName === "captureException") return true;
|
|
3800
|
+
}
|
|
3801
|
+
if (callee?.type === "Identifier" && callee.name === "reportError") return true;
|
|
3802
|
+
}
|
|
3803
|
+
for (const key of Object.keys(node)) {
|
|
3804
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3805
|
+
const val = node[key];
|
|
3806
|
+
if (val && typeof val === "object") {
|
|
3807
|
+
if (containsLoggingCall(val, depth + 1)) return true;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
return false;
|
|
3811
|
+
}
|
|
3812
|
+
return {
|
|
3813
|
+
TryStatement(node) {
|
|
3814
|
+
const fetchNode = findFetchToLlmHost(node.block);
|
|
3815
|
+
if (!fetchNode) return;
|
|
3816
|
+
const handler = node.handler;
|
|
3817
|
+
if (!handler) return;
|
|
3818
|
+
if (!containsLoggingCall(handler.body)) {
|
|
3819
|
+
context.report({ node: handler, messageId: "silentCatch" });
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
};
|
|
3823
|
+
}
|
|
3824
|
+
};
|
|
3825
|
+
var lovableSilentCatchOnProviderCallRule = rule53;
|
|
3826
|
+
|
|
3827
|
+
// src/providers/browserbase/utils.ts
|
|
3828
|
+
function memberPropName3(node) {
|
|
3829
|
+
if (node?.type !== "CallExpression") return void 0;
|
|
3830
|
+
const callee = node.callee;
|
|
3831
|
+
if (callee?.type !== "MemberExpression") return void 0;
|
|
3832
|
+
const prop = callee.property;
|
|
3833
|
+
if (!callee.computed && prop?.type === "Identifier") return prop.name;
|
|
3834
|
+
if (callee.computed && prop?.type === "Literal" && typeof prop.value === "string") return prop.value;
|
|
3835
|
+
return void 0;
|
|
3836
|
+
}
|
|
3837
|
+
function isSessionsCall(node, name) {
|
|
3838
|
+
if (memberPropName3(node) !== name) return false;
|
|
3839
|
+
const obj = node.callee.object;
|
|
3840
|
+
return obj?.type === "MemberExpression" && !obj.computed && obj.property?.type === "Identifier" && obj.property.name === "sessions";
|
|
3841
|
+
}
|
|
3842
|
+
function isSessionsRecordingCall(node, name) {
|
|
3843
|
+
if (memberPropName3(node) !== name) return false;
|
|
3844
|
+
const recordingObj = node.callee.object;
|
|
3845
|
+
if (recordingObj?.type !== "MemberExpression" || recordingObj.computed) return false;
|
|
3846
|
+
if (recordingObj.property?.type !== "Identifier" || recordingObj.property.name !== "recording") return false;
|
|
3847
|
+
const sessionsObj = recordingObj.object;
|
|
3848
|
+
return sessionsObj?.type === "MemberExpression" && !sessionsObj.computed && sessionsObj.property?.type === "Identifier" && sessionsObj.property.name === "sessions";
|
|
3849
|
+
}
|
|
3850
|
+
function findProperty2(objectExpression, name) {
|
|
3851
|
+
if (objectExpression?.type !== "ObjectExpression") return void 0;
|
|
3852
|
+
return objectExpression.properties?.find(
|
|
3853
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
3854
|
+
);
|
|
3855
|
+
}
|
|
3856
|
+
function startOffset3(n) {
|
|
3857
|
+
if (typeof n?.range?.[0] === "number") return n.range[0];
|
|
3858
|
+
if (typeof n?.start === "number") return n.start;
|
|
3859
|
+
return (n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.start?.column ?? 0);
|
|
3860
|
+
}
|
|
3861
|
+
function endOffset3(n) {
|
|
3862
|
+
if (typeof n?.range?.[1] === "number") return n.range[1];
|
|
3863
|
+
if (typeof n?.end === "number") return n.end;
|
|
3864
|
+
return (n?.loc?.end?.line ?? n?.loc?.start?.line ?? 0) * 1e6 + (n?.loc?.end?.column ?? 0);
|
|
3865
|
+
}
|
|
3866
|
+
function contains3(outer, inner) {
|
|
3867
|
+
const s = startOffset3(inner);
|
|
3868
|
+
return s >= startOffset3(outer) && s <= endOffset3(outer);
|
|
3869
|
+
}
|
|
3870
|
+
function someDescendant2(node, predicate) {
|
|
3871
|
+
let found = false;
|
|
3872
|
+
function visit(n) {
|
|
3873
|
+
if (found || !n || typeof n !== "object") return;
|
|
3874
|
+
if (Array.isArray(n)) {
|
|
3875
|
+
for (const item of n) visit(item);
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
if (typeof n.type !== "string") return;
|
|
3879
|
+
if (predicate(n)) {
|
|
3880
|
+
found = true;
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
for (const key of Object.keys(n)) {
|
|
3884
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
3885
|
+
visit(n[key]);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
visit(node);
|
|
3889
|
+
return found;
|
|
3890
|
+
}
|
|
3891
|
+
function isResponseSendCall(node) {
|
|
3892
|
+
const name = memberPropName3(node);
|
|
3893
|
+
if (name !== "json" && name !== "send") return false;
|
|
3894
|
+
const obj = node.callee.object;
|
|
3895
|
+
if (obj?.type === "Identifier") {
|
|
3896
|
+
return /^(res|response)$/i.test(obj.name) || obj.name === "Response" || obj.name === "NextResponse";
|
|
3897
|
+
}
|
|
3898
|
+
return false;
|
|
3899
|
+
}
|
|
3900
|
+
function isInsideTestFile3(filename) {
|
|
3901
|
+
return /(^|[\\/])__tests__[\\/]|\.(test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
// src/providers/browserbase/rules/no-conditional-authz-on-anonymous-user.ts
|
|
3905
|
+
function isUserLikeExpr(node) {
|
|
3906
|
+
if (node?.type === "Identifier") return /user/i.test(node.name);
|
|
3907
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
3908
|
+
return /user/i.test(node.property.name);
|
|
3909
|
+
}
|
|
3910
|
+
return false;
|
|
3911
|
+
}
|
|
3912
|
+
function isFalsyGuardTest(test) {
|
|
3913
|
+
if (test?.type === "UnaryExpression" && test.operator === "!") return isUserLikeExpr(test.argument);
|
|
3914
|
+
if (test?.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) {
|
|
3915
|
+
const sides = [test.left, test.right];
|
|
3916
|
+
const isNullish = (n) => n?.type === "Literal" && n.value === null || n?.type === "Identifier" && n.name === "undefined";
|
|
3917
|
+
const nullSide = sides.find(isNullish);
|
|
3918
|
+
const otherSide = sides.find((s) => s !== nullSide);
|
|
3919
|
+
return !!nullSide && isUserLikeExpr(otherSide);
|
|
3920
|
+
}
|
|
3921
|
+
return false;
|
|
3922
|
+
}
|
|
3923
|
+
function hasReturnOrThrow(stmt) {
|
|
3924
|
+
if (!stmt) return false;
|
|
3925
|
+
if (stmt.type === "ReturnStatement" || stmt.type === "ThrowStatement") return true;
|
|
3926
|
+
if (stmt.type === "BlockStatement") {
|
|
3927
|
+
return (stmt.body ?? []).some((s) => s.type === "ReturnStatement" || s.type === "ThrowStatement");
|
|
3928
|
+
}
|
|
3929
|
+
return false;
|
|
3930
|
+
}
|
|
3931
|
+
function isTruthyGateTest(test) {
|
|
3932
|
+
if (isUserLikeExpr(test)) return true;
|
|
3933
|
+
if (test?.type === "LogicalExpression" && test.operator === "&&") {
|
|
3934
|
+
return isTruthyGateTest(test.left) || isTruthyGateTest(test.right);
|
|
3935
|
+
}
|
|
3936
|
+
return false;
|
|
3937
|
+
}
|
|
3938
|
+
var rule54 = {
|
|
3939
|
+
meta: {
|
|
3940
|
+
type: "problem",
|
|
3941
|
+
docs: {
|
|
3942
|
+
description: "Sensitive Browserbase resources must be guarded by unconditional authorization",
|
|
3943
|
+
category: "security",
|
|
3944
|
+
cwe: "CWE-862",
|
|
3945
|
+
owasp: "A01:2021 Broken Access Control",
|
|
3946
|
+
rationale: "bb.sessions.debug() and bb.sessions.recording.retrieve() return a live CDP debugger URL or a full rrweb session replay \u2014 high-value resources. Gating the ownership check behind `if (user) { ...check... }` with no unconditional `if (!user) return` guard means an anonymous caller (no credentials at all) simply skips the whole block and falls through to the sensitive call. Authorization must reject the unauthenticated case explicitly, not rely on a conditional that happens to be true for legitimate callers.",
|
|
3947
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
3948
|
+
recommended: true
|
|
3949
|
+
},
|
|
3950
|
+
messages: {
|
|
3951
|
+
conditionalAuthz: "This route returns a Browserbase live-view/recording resource gated only by `if (user) {...}` with no unconditional rejection for anonymous callers. Add an `if (!user) return ...` guard before this."
|
|
3952
|
+
},
|
|
3953
|
+
schema: []
|
|
3954
|
+
},
|
|
3955
|
+
create(context) {
|
|
3956
|
+
const scopes = [];
|
|
3957
|
+
const gates = [];
|
|
3958
|
+
const sensitiveCalls = [];
|
|
3959
|
+
function registerScope(node) {
|
|
3960
|
+
scopes.push({ node, start: startOffset3(node), end: endOffset3(node) });
|
|
3961
|
+
}
|
|
3962
|
+
function innermostScope(pos) {
|
|
3963
|
+
let best;
|
|
3964
|
+
for (const scope of scopes) {
|
|
3965
|
+
if (pos < scope.start || pos > scope.end) continue;
|
|
3966
|
+
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
3967
|
+
}
|
|
3968
|
+
return best;
|
|
3969
|
+
}
|
|
3970
|
+
return {
|
|
3971
|
+
FunctionDeclaration(node) {
|
|
3972
|
+
registerScope(node);
|
|
3973
|
+
},
|
|
3974
|
+
FunctionExpression(node) {
|
|
3975
|
+
registerScope(node);
|
|
3976
|
+
},
|
|
3977
|
+
ArrowFunctionExpression(node) {
|
|
3978
|
+
registerScope(node);
|
|
3979
|
+
},
|
|
3980
|
+
IfStatement(node) {
|
|
3981
|
+
if (isFalsyGuardTest(node.test) && hasReturnOrThrow(node.consequent)) {
|
|
3982
|
+
const pos = startOffset3(node);
|
|
3983
|
+
const scope = innermostScope(pos);
|
|
3984
|
+
if (scope && (scope.guardPos === void 0 || pos < scope.guardPos)) scope.guardPos = pos;
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
if (isTruthyGateTest(node.test) && !node.alternate) {
|
|
3988
|
+
gates.push({ start: startOffset3(node), end: endOffset3(node) });
|
|
3989
|
+
}
|
|
3990
|
+
},
|
|
3991
|
+
CallExpression(node) {
|
|
3992
|
+
if (isSessionsCall(node, "debug") || isSessionsRecordingCall(node, "retrieve")) {
|
|
3993
|
+
sensitiveCalls.push(node);
|
|
3994
|
+
}
|
|
3995
|
+
},
|
|
3996
|
+
"Program:exit"() {
|
|
3997
|
+
for (const call of sensitiveCalls) {
|
|
3998
|
+
const gate = gates.find((g) => contains3(g, call));
|
|
3999
|
+
if (!gate) continue;
|
|
4000
|
+
const scope = innermostScope(startOffset3(call));
|
|
4001
|
+
const hasGuardBeforeGate = scope?.guardPos !== void 0 && scope.guardPos < gate.start;
|
|
4002
|
+
if (!hasGuardBeforeGate) {
|
|
4003
|
+
context.report({ node: call, messageId: "conditionalAuthz" });
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
};
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
var browserbaseNoConditionalAuthzOnAnonymousUserRule = rule54;
|
|
4011
|
+
|
|
4012
|
+
// src/providers/browserbase/rules/no-connect-url-in-api-response.ts
|
|
4013
|
+
var rule55 = {
|
|
4014
|
+
meta: {
|
|
4015
|
+
type: "problem",
|
|
4016
|
+
docs: {
|
|
4017
|
+
description: "connectUrl must never be placed in an HTTP response body",
|
|
4018
|
+
category: "security",
|
|
4019
|
+
cwe: "CWE-200",
|
|
4020
|
+
owasp: "A04:2021 Insecure Design",
|
|
4021
|
+
rationale: "session.connectUrl is the WebSocket CDP URL returned by sessions.create() \u2014 it is self-authenticating; connecting to it grants full read/write control of the live browser (arbitrary JS execution, cookie/localStorage read, simulated input), equivalent to a bearer token. Putting it in a response body widens its exposure (network tab, browser extensions, error trackers, logs) for callers that almost never need it client-side \u2014 the intended sharable artifact is debuggerFullscreenUrl from sessions.debug().",
|
|
4022
|
+
docsUrl: "https://docs.browserbase.com/reference/api/create-a-session",
|
|
4023
|
+
recommended: true
|
|
4024
|
+
},
|
|
4025
|
+
messages: {
|
|
4026
|
+
connectUrlInResponse: "connectUrl is included in an HTTP response body. Keep it server-side only \u2014 it is a bearer credential for the live browser, not a sharable URL."
|
|
4027
|
+
},
|
|
4028
|
+
schema: []
|
|
4029
|
+
},
|
|
4030
|
+
create(context) {
|
|
4031
|
+
return {
|
|
4032
|
+
CallExpression(node) {
|
|
4033
|
+
if (!isResponseSendCall(node)) return;
|
|
4034
|
+
const arg = node.arguments?.[0];
|
|
4035
|
+
if (arg?.type !== "ObjectExpression") return;
|
|
4036
|
+
if (findProperty2(arg, "connectUrl")) {
|
|
4037
|
+
context.report({ node, messageId: "connectUrlInResponse" });
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
};
|
|
4043
|
+
var browserbaseNoConnectUrlInApiResponseRule = rule55;
|
|
4044
|
+
|
|
4045
|
+
// src/providers/browserbase/rules/session-id-requires-ownership-check.ts
|
|
4046
|
+
function isOwnershipCheckCall(node) {
|
|
4047
|
+
if (node?.type !== "CallExpression") return false;
|
|
4048
|
+
const callee = node.callee;
|
|
4049
|
+
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier" ? callee.property.name : void 0;
|
|
4050
|
+
if (!name) return false;
|
|
4051
|
+
return /owner|belongsto|hasaccess|authorize|verifyaccess|checkaccess|findproject|getproject/i.test(name);
|
|
4052
|
+
}
|
|
4053
|
+
function isSessionIdLikeArg(node) {
|
|
4054
|
+
if (node?.type === "Identifier") return /sessionid/i.test(node.name);
|
|
4055
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
4056
|
+
return /sessionid/i.test(node.property.name);
|
|
4057
|
+
}
|
|
4058
|
+
return false;
|
|
4059
|
+
}
|
|
4060
|
+
var rule56 = {
|
|
4061
|
+
meta: {
|
|
4062
|
+
type: "suggestion",
|
|
4063
|
+
docs: {
|
|
4064
|
+
description: "Session id lookups must verify ownership before returning live-view data",
|
|
4065
|
+
category: "security",
|
|
4066
|
+
cwe: "CWE-862",
|
|
4067
|
+
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/features/session-live-view",
|
|
4069
|
+
recommended: true
|
|
4070
|
+
},
|
|
4071
|
+
messages: {
|
|
4072
|
+
missingOwnershipCheck: "sessionId is passed directly into a Browserbase live-view/recording call with no ownership check beforehand."
|
|
4073
|
+
},
|
|
4074
|
+
schema: []
|
|
4075
|
+
},
|
|
4076
|
+
create(context) {
|
|
4077
|
+
const scopes = [];
|
|
4078
|
+
const sensitiveCalls = [];
|
|
4079
|
+
function registerScope(node) {
|
|
4080
|
+
scopes.push({ node, start: startOffset3(node), end: endOffset3(node), sawOwnershipCheck: false });
|
|
4081
|
+
}
|
|
4082
|
+
function innermostScope(pos) {
|
|
4083
|
+
let best;
|
|
4084
|
+
for (const scope of scopes) {
|
|
4085
|
+
if (pos < scope.start || pos > scope.end) continue;
|
|
4086
|
+
if (!best || scope.end - scope.start < best.end - best.start) best = scope;
|
|
4087
|
+
}
|
|
4088
|
+
return best;
|
|
4089
|
+
}
|
|
4090
|
+
return {
|
|
4091
|
+
FunctionDeclaration(node) {
|
|
4092
|
+
registerScope(node);
|
|
4093
|
+
},
|
|
4094
|
+
FunctionExpression(node) {
|
|
4095
|
+
registerScope(node);
|
|
4096
|
+
},
|
|
4097
|
+
ArrowFunctionExpression(node) {
|
|
4098
|
+
registerScope(node);
|
|
4099
|
+
},
|
|
4100
|
+
CallExpression(node) {
|
|
4101
|
+
const pos = startOffset3(node);
|
|
4102
|
+
const scope = innermostScope(pos);
|
|
4103
|
+
if (isOwnershipCheckCall(node)) {
|
|
4104
|
+
for (const s of scopes) {
|
|
4105
|
+
if (pos >= s.start && pos <= s.end) s.sawOwnershipCheck = true;
|
|
4106
|
+
}
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
4109
|
+
if (isSessionsCall(node, "debug") || isSessionsRecordingCall(node, "retrieve")) {
|
|
4110
|
+
const sessionIdArg = (node.arguments ?? []).some(isSessionIdLikeArg);
|
|
4111
|
+
if (sessionIdArg) sensitiveCalls.push({ node, scope });
|
|
4112
|
+
}
|
|
4113
|
+
},
|
|
4114
|
+
"Program:exit"() {
|
|
4115
|
+
for (const { node, scope } of sensitiveCalls) {
|
|
4116
|
+
if (!scope || !scope.sawOwnershipCheck) {
|
|
4117
|
+
context.report({ node, messageId: "missingOwnershipCheck" });
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
};
|
|
4122
|
+
}
|
|
4123
|
+
};
|
|
4124
|
+
var browserbaseSessionIdRequiresOwnershipCheckRule = rule56;
|
|
4125
|
+
|
|
4126
|
+
// src/providers/browserbase/rules/no-concurrent-shared-context.ts
|
|
4127
|
+
function isPromiseAllCall2(node) {
|
|
4128
|
+
return node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && node.callee.object?.type === "Identifier" && node.callee.object.name === "Promise" && node.callee.property?.type === "Identifier" && node.callee.property.name === "all";
|
|
4129
|
+
}
|
|
4130
|
+
function isMapCall2(node) {
|
|
4131
|
+
return memberPropName3(node) === "map";
|
|
4132
|
+
}
|
|
4133
|
+
function callbackParamNames(callback) {
|
|
4134
|
+
const names = /* @__PURE__ */ new Set();
|
|
4135
|
+
const param = callback?.params?.[0];
|
|
4136
|
+
if (param?.type === "Identifier") {
|
|
4137
|
+
names.add(param.name);
|
|
4138
|
+
} else if (param?.type === "ObjectPattern") {
|
|
4139
|
+
for (const p of param.properties ?? []) {
|
|
4140
|
+
if (p?.type === "Property" && p.value?.type === "Identifier") names.add(p.value.name);
|
|
4141
|
+
else if (p?.type === "RestElement" && p.argument?.type === "Identifier") names.add(p.argument.name);
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
return names;
|
|
4145
|
+
}
|
|
4146
|
+
function contextIdValueIsShared(callback, contextIdNode) {
|
|
4147
|
+
if (contextIdNode?.type !== "Identifier") return false;
|
|
4148
|
+
const params = callbackParamNames(callback);
|
|
4149
|
+
if (params.has(contextIdNode.name)) return false;
|
|
4150
|
+
return true;
|
|
4151
|
+
}
|
|
4152
|
+
function findSharedContextCreateCall(callback) {
|
|
4153
|
+
const body = callback?.body;
|
|
4154
|
+
if (!body) return null;
|
|
4155
|
+
let found = null;
|
|
4156
|
+
function visit(n) {
|
|
4157
|
+
if (found || !n || typeof n !== "object") return;
|
|
4158
|
+
if (Array.isArray(n)) {
|
|
4159
|
+
for (const item of n) visit(item);
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
if (typeof n.type !== "string") return;
|
|
4163
|
+
if (isSessionsCall(n, "create")) {
|
|
4164
|
+
const optsArg = n.arguments?.[0];
|
|
4165
|
+
const browserSettings = findProperty2(optsArg, "browserSettings")?.value;
|
|
4166
|
+
const ctxProp = findProperty2(browserSettings, "context")?.value;
|
|
4167
|
+
const idProp = findProperty2(ctxProp, "id");
|
|
4168
|
+
if (idProp && contextIdValueIsShared(callback, idProp.value)) {
|
|
4169
|
+
found = n;
|
|
4170
|
+
return;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
for (const key of Object.keys(n)) {
|
|
4174
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
4175
|
+
visit(n[key]);
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
visit(body);
|
|
4179
|
+
return found;
|
|
4180
|
+
}
|
|
4181
|
+
var rule57 = {
|
|
4182
|
+
meta: {
|
|
4183
|
+
type: "problem",
|
|
4184
|
+
docs: {
|
|
4185
|
+
description: "A Browserbase Context must not be attached to concurrent sessions",
|
|
4186
|
+
category: "correctness",
|
|
4187
|
+
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",
|
|
4189
|
+
recommended: true
|
|
4190
|
+
},
|
|
4191
|
+
messages: {
|
|
4192
|
+
concurrentSharedContext: "The same browserSettings.context.id is passed into every concurrent sessions.create() call in this Promise.all batch. Sites may force a log out when 2+ sessions share a Context at once."
|
|
4193
|
+
},
|
|
4194
|
+
schema: []
|
|
4195
|
+
},
|
|
4196
|
+
create(context) {
|
|
4197
|
+
const mapCallsByVarName = /* @__PURE__ */ new Map();
|
|
4198
|
+
function checkMapCall(mapCallNode) {
|
|
4199
|
+
if (!isMapCall2(mapCallNode)) return;
|
|
4200
|
+
const callback = mapCallNode.arguments?.[mapCallNode.arguments.length - 1];
|
|
4201
|
+
if (!callback) return;
|
|
4202
|
+
const sharedCall = findSharedContextCreateCall(callback);
|
|
4203
|
+
if (sharedCall) {
|
|
4204
|
+
context.report({ node: sharedCall, messageId: "concurrentSharedContext" });
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
return {
|
|
4208
|
+
VariableDeclarator(node) {
|
|
4209
|
+
if (node.id?.type === "Identifier" && isMapCall2(node.init)) {
|
|
4210
|
+
mapCallsByVarName.set(node.id.name, node.init);
|
|
4211
|
+
}
|
|
4212
|
+
},
|
|
4213
|
+
CallExpression(node) {
|
|
4214
|
+
if (!isPromiseAllCall2(node)) return;
|
|
4215
|
+
const arg = node.arguments?.[0];
|
|
4216
|
+
if (isMapCall2(arg)) {
|
|
4217
|
+
checkMapCall(arg);
|
|
4218
|
+
return;
|
|
4219
|
+
}
|
|
4220
|
+
if (arg?.type === "Identifier") {
|
|
4221
|
+
const mapCall = mapCallsByVarName.get(arg.name);
|
|
4222
|
+
if (mapCall) checkMapCall(mapCall);
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
};
|
|
4226
|
+
}
|
|
4227
|
+
};
|
|
4228
|
+
var browserbaseNoConcurrentSharedContextRule = rule57;
|
|
4229
|
+
|
|
4230
|
+
// src/providers/browserbase/rules/mobile-device-requires-os-setting.ts
|
|
4231
|
+
function isMobileLiteral(node) {
|
|
4232
|
+
return node?.type === "Literal" && (node.value === "mobile" || node.value === "tablet");
|
|
4233
|
+
}
|
|
4234
|
+
function testComparesToMobile(test) {
|
|
4235
|
+
if (test?.type !== "BinaryExpression") return false;
|
|
4236
|
+
if (test.operator !== "===" && test.operator !== "==") return false;
|
|
4237
|
+
return isMobileLiteral(test.left) || isMobileLiteral(test.right);
|
|
4238
|
+
}
|
|
4239
|
+
function isViewportReference(node) {
|
|
4240
|
+
if (node?.type === "Identifier") return /viewport/i.test(node.name);
|
|
4241
|
+
if (node?.type === "MemberExpression" && !node.computed && node.property?.type === "Identifier") {
|
|
4242
|
+
return /viewport|width|height/i.test(node.property.name);
|
|
4243
|
+
}
|
|
4244
|
+
if (node?.type === "Property") {
|
|
4245
|
+
return node.key?.type === "Identifier" && /viewport|width|height/i.test(node.key.name);
|
|
4246
|
+
}
|
|
4247
|
+
return false;
|
|
4248
|
+
}
|
|
4249
|
+
function setsViewport(node) {
|
|
4250
|
+
return someDescendant2(node, isViewportReference);
|
|
4251
|
+
}
|
|
4252
|
+
function isOsMobileSetting(node) {
|
|
4253
|
+
if (node?.type === "Property" && node.key?.type === "Identifier" && node.key.name === "os" && isMobileLiteral(node.value)) {
|
|
4254
|
+
return true;
|
|
4255
|
+
}
|
|
4256
|
+
if (node?.type === "AssignmentExpression" && node.left?.type === "MemberExpression" && !node.left.computed && node.left.property?.type === "Identifier" && node.left.property.name === "os" && isMobileLiteral(node.right)) {
|
|
4257
|
+
return true;
|
|
4258
|
+
}
|
|
4259
|
+
return false;
|
|
4260
|
+
}
|
|
4261
|
+
var rule58 = {
|
|
4262
|
+
meta: {
|
|
4263
|
+
type: "problem",
|
|
4264
|
+
docs: {
|
|
4265
|
+
description: "Mobile device combos must set browserSettings.os, not just resize the viewport",
|
|
4266
|
+
category: "correctness",
|
|
4267
|
+
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/features/stealth-mode",
|
|
4269
|
+
recommended: true
|
|
4270
|
+
},
|
|
4271
|
+
messages: {
|
|
4272
|
+
missingOsSetting: 'A "mobile" branch resizes the viewport but never sets browserSettings.os to "mobile"/"tablet". Without it this is still a desktop browser in a small window.'
|
|
4273
|
+
},
|
|
4274
|
+
schema: []
|
|
4275
|
+
},
|
|
4276
|
+
create(context) {
|
|
4277
|
+
const mobileViewportBranches = [];
|
|
4278
|
+
let sawOsMobileSetting = false;
|
|
4279
|
+
return {
|
|
4280
|
+
IfStatement(node) {
|
|
4281
|
+
if (testComparesToMobile(node.test) && setsViewport(node.consequent)) {
|
|
4282
|
+
mobileViewportBranches.push(node);
|
|
4283
|
+
}
|
|
4284
|
+
},
|
|
4285
|
+
Property(node) {
|
|
4286
|
+
if (isOsMobileSetting(node)) sawOsMobileSetting = true;
|
|
4287
|
+
},
|
|
4288
|
+
AssignmentExpression(node) {
|
|
4289
|
+
if (isOsMobileSetting(node)) sawOsMobileSetting = true;
|
|
4290
|
+
},
|
|
4291
|
+
"Program:exit"() {
|
|
4292
|
+
if (sawOsMobileSetting) return;
|
|
4293
|
+
for (const branch of mobileViewportBranches) {
|
|
4294
|
+
context.report({ node: branch, messageId: "missingOsSetting" });
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4299
|
+
};
|
|
4300
|
+
var browserbaseMobileDeviceRequiresOsSettingRule = rule58;
|
|
4301
|
+
|
|
4302
|
+
// src/providers/browserbase/rules/use-typed-exception-status-not-substring.ts
|
|
4303
|
+
function isStringifiedErrorSource(node, errName) {
|
|
4304
|
+
if (node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String") {
|
|
4305
|
+
const arg = node.arguments?.[0];
|
|
4306
|
+
return arg?.type === "Identifier" && arg.name === errName;
|
|
4307
|
+
}
|
|
4308
|
+
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression") {
|
|
4309
|
+
const obj = node.callee.object;
|
|
4310
|
+
const prop = node.callee.property;
|
|
4311
|
+
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "toString") {
|
|
4312
|
+
return true;
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
if (node?.type === "MemberExpression" && !node.computed) {
|
|
4316
|
+
const obj = node.object;
|
|
4317
|
+
const prop = node.property;
|
|
4318
|
+
if (obj?.type === "Identifier" && obj.name === errName && prop?.type === "Identifier" && prop.name === "message") {
|
|
4319
|
+
return true;
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
return false;
|
|
4323
|
+
}
|
|
4324
|
+
function unwrapToLowerCase(node) {
|
|
4325
|
+
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "toLowerCase") {
|
|
4326
|
+
return node.callee.object;
|
|
4327
|
+
}
|
|
4328
|
+
return node;
|
|
4329
|
+
}
|
|
4330
|
+
function usesTypedStatus(body, errName) {
|
|
4331
|
+
return someDescendant2(body, (n) => {
|
|
4332
|
+
if (n.type === "MemberExpression" && !n.computed && n.object?.type === "Identifier" && n.object.name === errName) {
|
|
4333
|
+
return n.property?.type === "Identifier" && n.property.name === "status";
|
|
4334
|
+
}
|
|
4335
|
+
if (n.type === "BinaryExpression" && n.operator === "instanceof" && n.left?.type === "Identifier" && n.left.name === errName) {
|
|
4336
|
+
return true;
|
|
4337
|
+
}
|
|
4338
|
+
return false;
|
|
4339
|
+
});
|
|
4340
|
+
}
|
|
4341
|
+
function usesSubstringMatch(body, errName, stringifiedVars) {
|
|
4342
|
+
return someDescendant2(body, (n) => {
|
|
4343
|
+
if (n.type !== "CallExpression") return false;
|
|
4344
|
+
if (n.callee?.type !== "MemberExpression" || n.callee.computed) return false;
|
|
4345
|
+
if (n.callee.property?.type !== "Identifier" || n.callee.property.name !== "includes") return false;
|
|
4346
|
+
const source = unwrapToLowerCase(n.callee.object);
|
|
4347
|
+
if (isStringifiedErrorSource(source, errName)) return true;
|
|
4348
|
+
return source?.type === "Identifier" && stringifiedVars.has(source.name);
|
|
4349
|
+
});
|
|
4350
|
+
}
|
|
4351
|
+
function collectStringifiedVars(body, errName) {
|
|
4352
|
+
const names = /* @__PURE__ */ new Set();
|
|
4353
|
+
someDescendant2(body, (n) => {
|
|
4354
|
+
if (n.type === "VariableDeclarator" && n.id?.type === "Identifier" && n.init) {
|
|
4355
|
+
const source = unwrapToLowerCase(n.init);
|
|
4356
|
+
if (isStringifiedErrorSource(source, errName)) names.add(n.id.name);
|
|
4357
|
+
}
|
|
4358
|
+
return false;
|
|
4359
|
+
});
|
|
4360
|
+
return names;
|
|
4361
|
+
}
|
|
4362
|
+
var rule59 = {
|
|
4363
|
+
meta: {
|
|
4364
|
+
type: "suggestion",
|
|
4365
|
+
docs: {
|
|
4366
|
+
description: "Branch on err.status, not a substring match of the stringified error",
|
|
4367
|
+
category: "correctness",
|
|
4368
|
+
rationale: 'The SDK raises typed Browserbase.APIError (and subclasses like NotFoundError) carrying a real .status: number and .headers. Matching err.message or String(err) against substrings like "410" or "Session stopped" is fragile \u2014 that human-readable text is an unversioned implementation detail the provider can change at any time without notice. Check err.status (or `instanceof`) instead.',
|
|
4369
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
4370
|
+
recommended: true
|
|
4371
|
+
},
|
|
4372
|
+
messages: {
|
|
4373
|
+
substringStatusMatch: "This catch block matches a substring of the stringified error instead of checking err.status or `instanceof Browserbase.APIError`."
|
|
4374
|
+
},
|
|
4375
|
+
schema: []
|
|
4376
|
+
},
|
|
4377
|
+
create(context) {
|
|
4378
|
+
return {
|
|
4379
|
+
CatchClause(node) {
|
|
4380
|
+
const param = node.param;
|
|
4381
|
+
const errName = param?.type === "Identifier" ? param.name : void 0;
|
|
4382
|
+
if (!errName || !node.body) return;
|
|
4383
|
+
if (usesTypedStatus(node.body, errName)) return;
|
|
4384
|
+
const stringifiedVars = collectStringifiedVars(node.body, errName);
|
|
4385
|
+
if (usesSubstringMatch(node.body, errName, stringifiedVars)) {
|
|
4386
|
+
context.report({ node, messageId: "substringStatusMatch" });
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
};
|
|
4390
|
+
}
|
|
4391
|
+
};
|
|
4392
|
+
var browserbaseUseTypedExceptionStatusNotSubstringRule = rule59;
|
|
4393
|
+
|
|
4394
|
+
// src/providers/browserbase/rules/release-session-on-connect-failure.ts
|
|
4395
|
+
function isSessionsCreateAwait(node) {
|
|
4396
|
+
return node?.type === "AwaitExpression" && isSessionsCall(node.argument, "create");
|
|
4397
|
+
}
|
|
4398
|
+
function isConnectCall(node) {
|
|
4399
|
+
if (node?.type !== "CallExpression") return false;
|
|
4400
|
+
const callee = node.callee;
|
|
4401
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
4402
|
+
return !!name && /connect/i.test(name);
|
|
4403
|
+
}
|
|
4404
|
+
function isReleaseCall(node) {
|
|
4405
|
+
if (node?.type !== "CallExpression") return false;
|
|
4406
|
+
if (isSessionsCall(node, "update")) {
|
|
4407
|
+
const optsArg = node.arguments?.[1];
|
|
4408
|
+
const statusProp = findProperty2(optsArg, "status");
|
|
4409
|
+
if (statusProp?.value?.type === "Literal" && statusProp.value.value === "REQUEST_RELEASE") return true;
|
|
4410
|
+
}
|
|
4411
|
+
const callee = node.callee;
|
|
4412
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
4413
|
+
return !!name && /requeststop|releasesession|release_session/i.test(name);
|
|
4414
|
+
}
|
|
4415
|
+
var rule60 = {
|
|
4416
|
+
meta: {
|
|
4417
|
+
type: "problem",
|
|
4418
|
+
docs: {
|
|
4419
|
+
description: "A failed connect after session creation must release the session",
|
|
4420
|
+
category: "reliability",
|
|
4421
|
+
rationale: `If the CDP connect call raises after sessions.create() already succeeded on Browserbase's side, the session stays RUNNING and only terminates once its timeout elapses (up to 6h) unless something explicitly calls sessions.update(id, { status: 'REQUEST_RELEASE' }). The SDK's own docs warn to do this before the timeout "to avoid additional charges." A connect call with no try/catch (or a catch that doesn't release) leaks the session on every connect failure.`,
|
|
4422
|
+
docsUrl: "https://docs.browserbase.com/reference/api/update-a-session",
|
|
4423
|
+
recommended: true
|
|
4424
|
+
},
|
|
4425
|
+
messages: {
|
|
4426
|
+
sessionNotReleasedOnFailure: 'A session was created but this connect call has no catch that releases it (sessions.update with status: "REQUEST_RELEASE") on failure. A failed connect will leak the session until its timeout.'
|
|
4427
|
+
},
|
|
4428
|
+
schema: []
|
|
4429
|
+
},
|
|
4430
|
+
create(context) {
|
|
4431
|
+
let sawSessionCreate = false;
|
|
4432
|
+
const connectCalls = [];
|
|
4433
|
+
const tryStatements = [];
|
|
4434
|
+
return {
|
|
4435
|
+
AwaitExpression(node) {
|
|
4436
|
+
if (isSessionsCreateAwait(node)) sawSessionCreate = true;
|
|
4437
|
+
},
|
|
4438
|
+
TryStatement(node) {
|
|
4439
|
+
tryStatements.push(node);
|
|
4440
|
+
},
|
|
4441
|
+
CallExpression(node) {
|
|
4442
|
+
if (isConnectCall(node)) connectCalls.push(node);
|
|
4443
|
+
},
|
|
4444
|
+
"Program:exit"() {
|
|
4445
|
+
if (!sawSessionCreate) return;
|
|
4446
|
+
for (const call of connectCalls) {
|
|
4447
|
+
const enclosingTry = tryStatements.find((t) => t.block && contains3(t.block, call));
|
|
4448
|
+
const handled = !!enclosingTry?.handler && someDescendant2(enclosingTry.handler, isReleaseCall);
|
|
4449
|
+
if (!handled) {
|
|
4450
|
+
context.report({ node: call, messageId: "sessionNotReleasedOnFailure" });
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
};
|
|
4455
|
+
}
|
|
4456
|
+
};
|
|
4457
|
+
var browserbaseReleaseSessionOnConnectFailureRule = rule60;
|
|
4458
|
+
|
|
4459
|
+
// src/providers/browserbase/rules/dont-stack-custom-retry-on-sdk-retry.ts
|
|
4460
|
+
var rule61 = {
|
|
4461
|
+
meta: {
|
|
4462
|
+
type: "suggestion",
|
|
4463
|
+
docs: {
|
|
4464
|
+
description: "A custom retry loop around sessions.create must disable the SDK's own retry",
|
|
4465
|
+
category: "reliability",
|
|
4466
|
+
rationale: "The Browserbase client already retries internally (default maxRetries: 2, retrying 408/409/429/5xx with Retry-After-aware exponential backoff) before ever raising an exception back to the caller. A hand-rolled retry loop around sessions.create() that does not pass { maxRetries: 0 } stacks two independent, uncoordinated backoff schedules \u2014 a sustained 429 can trigger N outer attempts x (1 + 2 SDK-internal retries), multiplying worst-case latency well beyond what either layer alone would produce.",
|
|
4467
|
+
docsUrl: "https://docs.browserbase.com/optimizations/concurrency/overview",
|
|
4468
|
+
recommended: true
|
|
4469
|
+
},
|
|
4470
|
+
messages: {
|
|
4471
|
+
stackedRetry: "sessions.create() is called inside a custom retry loop with no { maxRetries: 0 } override \u2014 the SDK retries this call internally too, stacking two backoff schedules."
|
|
4472
|
+
},
|
|
4473
|
+
schema: []
|
|
4474
|
+
},
|
|
4475
|
+
create(context) {
|
|
4476
|
+
const loopRanges = [];
|
|
4477
|
+
return {
|
|
4478
|
+
ForStatement(node) {
|
|
4479
|
+
loopRanges.push(node);
|
|
4480
|
+
},
|
|
4481
|
+
WhileStatement(node) {
|
|
4482
|
+
loopRanges.push(node);
|
|
4483
|
+
},
|
|
4484
|
+
DoWhileStatement(node) {
|
|
4485
|
+
loopRanges.push(node);
|
|
4486
|
+
},
|
|
4487
|
+
CallExpression(node) {
|
|
4488
|
+
if (!isSessionsCall(node, "create")) return;
|
|
4489
|
+
const insideLoop = loopRanges.some((loop) => {
|
|
4490
|
+
const start = loop.range?.[0] ?? loop.start;
|
|
4491
|
+
const end = loop.range?.[1] ?? loop.end;
|
|
4492
|
+
const pos = node.range?.[0] ?? node.start;
|
|
4493
|
+
return pos >= start && pos <= end;
|
|
4494
|
+
});
|
|
4495
|
+
if (!insideLoop) return;
|
|
4496
|
+
const optsArg = node.arguments?.[1];
|
|
4497
|
+
const hasMaxRetries = optsArg?.type === "ObjectExpression" && !!findProperty2(optsArg, "maxRetries");
|
|
4498
|
+
if (!hasMaxRetries) {
|
|
4499
|
+
context.report({ node, messageId: "stackedRetry" });
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
var browserbaseDontStackCustomRetryOnSdkRetryRule = rule61;
|
|
4506
|
+
|
|
4507
|
+
// src/providers/browserbase/rules/no-overbroad-error-substring-match.ts
|
|
4508
|
+
var OVERBROAD_TERMS = /* @__PURE__ */ new Set(["session", "context", "browser", "browserbase"]);
|
|
4509
|
+
function collectIncludesLiterals(test, literals) {
|
|
4510
|
+
if (!test) return;
|
|
4511
|
+
if (test.type === "LogicalExpression" && test.operator === "||") {
|
|
4512
|
+
collectIncludesLiterals(test.left, literals);
|
|
4513
|
+
collectIncludesLiterals(test.right, literals);
|
|
4514
|
+
return;
|
|
4515
|
+
}
|
|
4516
|
+
if (test.type === "CallExpression" && memberPropName3(test) === "includes") {
|
|
4517
|
+
const arg = test.arguments?.[0];
|
|
4518
|
+
if (arg?.type === "Literal" && typeof arg.value === "string") literals.push(arg.value.toLowerCase());
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
function isCleanupCall(node) {
|
|
4522
|
+
if (node?.type !== "CallExpression") return false;
|
|
4523
|
+
const callee = node.callee;
|
|
4524
|
+
const name = callee?.type === "Identifier" ? callee.name : memberPropName3(node);
|
|
4525
|
+
return !!name && /release|remove|cleanup|stop|teardown/i.test(name);
|
|
4526
|
+
}
|
|
4527
|
+
var rule62 = {
|
|
4528
|
+
meta: {
|
|
4529
|
+
type: "problem",
|
|
4530
|
+
docs: {
|
|
4531
|
+
description: "Error-message checks must not OR in a single generic resource-name substring",
|
|
4532
|
+
category: "reliability",
|
|
4533
|
+
rationale: 'A condition like `err.includes("not found") || err.includes("404") || err.includes("session")` looks like it is narrowing to a confirmed session-ended error, but the last clause matches a single generic word \u2014 the name of the resource being polled. Virtually any exception raised from a sessions.* call (a connection reset, an SSL error, a generic timeout that echoes the request URL) is likely to contain that substring too. A coincidental match then triggers the same destructive cleanup as a real 404/410, tearing down a healthy session on a transient blip.',
|
|
4534
|
+
docsUrl: "https://docs.browserbase.com/reference/api/session-live-urls",
|
|
4535
|
+
recommended: true
|
|
4536
|
+
},
|
|
4537
|
+
messages: {
|
|
4538
|
+
overbroadMatch: 'This OR-chain includes a generic substring check ("{{term}}") that matches almost any error from a sessions.* call, not just a confirmed session-ended response.'
|
|
4539
|
+
},
|
|
4540
|
+
schema: []
|
|
4541
|
+
},
|
|
4542
|
+
create(context) {
|
|
4543
|
+
return {
|
|
4544
|
+
IfStatement(node) {
|
|
4545
|
+
const literals = [];
|
|
4546
|
+
collectIncludesLiterals(node.test, literals);
|
|
4547
|
+
if (literals.length < 2) return;
|
|
4548
|
+
const overbroad = literals.find((l) => OVERBROAD_TERMS.has(l));
|
|
4549
|
+
if (!overbroad) return;
|
|
4550
|
+
if (someDescendant2(node.consequent, isCleanupCall)) {
|
|
4551
|
+
context.report({ node, messageId: "overbroadMatch", data: { term: overbroad } });
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
};
|
|
4555
|
+
}
|
|
4556
|
+
};
|
|
4557
|
+
var browserbaseNoOverbroadErrorSubstringMatchRule = rule62;
|
|
4558
|
+
|
|
4559
|
+
// src/providers/browserbase/rules/use-sdk-not-raw-requests.ts
|
|
4560
|
+
var BROWSERBASE_HOST = "api.browserbase.com";
|
|
4561
|
+
function templateLiteralContainsHost(node) {
|
|
4562
|
+
return (node.quasis ?? []).some((q) => {
|
|
4563
|
+
const text = q?.value?.cooked ?? q?.value?.raw ?? "";
|
|
4564
|
+
return typeof text === "string" && text.includes(BROWSERBASE_HOST);
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
function urlArgContainsHost(node) {
|
|
4568
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value.includes(BROWSERBASE_HOST);
|
|
4569
|
+
if (node?.type === "TemplateLiteral") return templateLiteralContainsHost(node);
|
|
4570
|
+
return false;
|
|
4571
|
+
}
|
|
4572
|
+
function isRawHttpCall(node) {
|
|
4573
|
+
if (node?.type !== "CallExpression") return { isCall: false, urlArg: void 0 };
|
|
4574
|
+
const callee = node.callee;
|
|
4575
|
+
if (callee?.type === "Identifier" && callee.name === "fetch") {
|
|
4576
|
+
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
4577
|
+
}
|
|
4578
|
+
if (callee?.type === "MemberExpression" && !callee.computed && callee.property?.type === "Identifier") {
|
|
4579
|
+
const objName = callee.object?.type === "Identifier" ? callee.object.name : void 0;
|
|
4580
|
+
const methodName = callee.property.name;
|
|
4581
|
+
if (objName && /^(axios|http|https)$/i.test(objName) && /^(get|post|put|patch|delete|request)$/i.test(methodName)) {
|
|
4582
|
+
return { isCall: true, urlArg: node.arguments?.[0] };
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
if (callee?.type === "Identifier" && callee.name === "axios") {
|
|
4586
|
+
const arg = node.arguments?.[0];
|
|
4587
|
+
if (arg?.type === "ObjectExpression") {
|
|
4588
|
+
const urlProp = arg.properties?.find(
|
|
4589
|
+
(p) => p?.type === "Property" && p.key?.type === "Identifier" && p.key.name === "url"
|
|
4590
|
+
);
|
|
4591
|
+
return { isCall: true, urlArg: urlProp?.value };
|
|
4592
|
+
}
|
|
4593
|
+
return { isCall: true, urlArg: arg };
|
|
4594
|
+
}
|
|
4595
|
+
return { isCall: false, urlArg: void 0 };
|
|
4596
|
+
}
|
|
4597
|
+
var rule63 = {
|
|
4598
|
+
meta: {
|
|
4599
|
+
type: "suggestion",
|
|
4600
|
+
docs: {
|
|
4601
|
+
description: "Use the installed Browserbase SDK instead of hand-rolled HTTP requests",
|
|
4602
|
+
category: "integration",
|
|
4603
|
+
rationale: "A raw fetch/axios/http call to an api.browserbase.com endpoint duplicates a method the installed SDK already exposes (e.g. sessions.recording.retrieve()), forfeiting its typed exceptions, built-in retry/backoff, and consistent error handling \u2014 and risks drifting on auth header naming or response shape as the API evolves.",
|
|
4604
|
+
docsUrl: "https://docs.browserbase.com/reference/sdk/nodejs",
|
|
4605
|
+
recommended: true
|
|
4606
|
+
},
|
|
4607
|
+
messages: {
|
|
4608
|
+
rawRequestToBrowserbase: "This makes a raw HTTP request to a Browserbase API endpoint instead of using the installed SDK."
|
|
4609
|
+
},
|
|
4610
|
+
schema: []
|
|
4611
|
+
},
|
|
4612
|
+
create(context) {
|
|
4613
|
+
const urlVars = /* @__PURE__ */ new Map();
|
|
4614
|
+
return {
|
|
4615
|
+
VariableDeclarator(node) {
|
|
4616
|
+
if (node.id?.type === "Identifier" && urlArgContainsHost(node.init)) {
|
|
4617
|
+
urlVars.set(node.id.name, node.init);
|
|
4618
|
+
}
|
|
4619
|
+
},
|
|
4620
|
+
CallExpression(node) {
|
|
4621
|
+
const { isCall, urlArg } = isRawHttpCall(node);
|
|
4622
|
+
if (!isCall || !urlArg) return;
|
|
4623
|
+
const resolved = urlArg.type === "Identifier" ? urlVars.get(urlArg.name) ?? urlArg : urlArg;
|
|
4624
|
+
if (urlArgContainsHost(resolved)) {
|
|
4625
|
+
context.report({ node, messageId: "rawRequestToBrowserbase" });
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
};
|
|
4629
|
+
}
|
|
4630
|
+
};
|
|
4631
|
+
var browserbaseUseSdkNotRawRequestsRule = rule63;
|
|
4632
|
+
|
|
4633
|
+
// src/providers/browserbase/rules/centralize-request-release.ts
|
|
4634
|
+
var rule64 = {
|
|
4635
|
+
meta: {
|
|
4636
|
+
type: "suggestion",
|
|
4637
|
+
docs: {
|
|
4638
|
+
description: "Route REQUEST_RELEASE through a single shared abstraction",
|
|
4639
|
+
category: "integration",
|
|
4640
|
+
rationale: 'sessions.update(id, { status: "REQUEST_RELEASE" }) hand-rolled inline at each call site, with each one carrying slightly different error handling, means an API change (a new required field, a renamed status value) requires fixing every call site instead of one \u2014 and the duplicated copies already drift in error-handling quality. Centralize behind one designated provider method (e.g. requestStop()/releaseSession()) and call that everywhere instead.',
|
|
4641
|
+
docsUrl: "https://docs.browserbase.com/reference/api/update-a-session",
|
|
4642
|
+
recommended: true
|
|
4643
|
+
},
|
|
4644
|
+
messages: {
|
|
4645
|
+
inlineRequestRelease: 'sessions.update(..., { status: "REQUEST_RELEASE" }) is hand-rolled inline here. Route this through a single shared release/provider abstraction instead.'
|
|
4646
|
+
},
|
|
4647
|
+
schema: []
|
|
4648
|
+
},
|
|
4649
|
+
create(context) {
|
|
4650
|
+
const filename = String(context.filename ?? "");
|
|
4651
|
+
if (isInsideTestFile3(filename) || /provider|browserbaseclient/i.test(filename)) {
|
|
4652
|
+
return {};
|
|
4653
|
+
}
|
|
4654
|
+
return {
|
|
4655
|
+
CallExpression(node) {
|
|
4656
|
+
if (!isSessionsCall(node, "update")) return;
|
|
4657
|
+
const optsArg = node.arguments?.[1];
|
|
4658
|
+
const statusProp = findProperty2(optsArg, "status");
|
|
4659
|
+
const val = statusProp?.value;
|
|
4660
|
+
if (val?.type === "Literal" && val.value === "REQUEST_RELEASE") {
|
|
4661
|
+
context.report({ node, messageId: "inlineRequestRelease" });
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
};
|
|
4665
|
+
}
|
|
4666
|
+
};
|
|
4667
|
+
var browserbaseCentralizeRequestReleaseRule = rule64;
|
|
4668
|
+
|
|
4669
|
+
// src/providers/openai-cua/rules/no-domain-allowlist.ts
|
|
4670
|
+
var ACTION_METHOD_NAMES = /* @__PURE__ */ new Set(["click", "type", "press", "move", "goto", "fill", "dblclick", "dragAndDrop"]);
|
|
4671
|
+
var rule65 = {
|
|
4672
|
+
meta: {
|
|
4673
|
+
type: "problem",
|
|
4674
|
+
docs: {
|
|
4675
|
+
description: "Computer-use action execution must check the page origin against an allowlist",
|
|
4676
|
+
category: "security",
|
|
4677
|
+
cwe: "CWE-284",
|
|
4678
|
+
owasp: "A01:2021 \u2013 Broken Access Control",
|
|
4679
|
+
rationale: `OpenAI's Computer Use guide instructs integrators to "keep an allow list of domains and actions your agent should use, and block everything else." Without an origin check before executing actions, a click that follows an off-domain redirect (phishing link, ad, malicious iframe) is executed exactly like any in-domain click \u2014 and a field-fill action has no origin awareness either, so credentials could be typed into a page the agent was never meant to reach.`,
|
|
4680
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4681
|
+
recommended: true
|
|
4682
|
+
},
|
|
4683
|
+
messages: {
|
|
4684
|
+
noOriginCheck: "This function executes computer-use actions on the current page with no origin/domain allowlist check anywhere in it."
|
|
4685
|
+
}
|
|
4686
|
+
},
|
|
4687
|
+
create(context) {
|
|
4688
|
+
const stack = [];
|
|
4689
|
+
const states = /* @__PURE__ */ new Map();
|
|
4690
|
+
function ensureState(fn) {
|
|
4691
|
+
let s = states.get(fn);
|
|
4692
|
+
if (!s) {
|
|
4693
|
+
s = { sawAction: false, actionNode: null, sawOriginCheck: false };
|
|
4694
|
+
states.set(fn, s);
|
|
4695
|
+
}
|
|
4696
|
+
return s;
|
|
4697
|
+
}
|
|
4698
|
+
function top() {
|
|
4699
|
+
return stack[stack.length - 1];
|
|
4700
|
+
}
|
|
4701
|
+
function pushScope(node) {
|
|
4702
|
+
stack.push(node);
|
|
4703
|
+
ensureState(node);
|
|
4704
|
+
}
|
|
4705
|
+
function popScope() {
|
|
4706
|
+
stack.pop();
|
|
4707
|
+
}
|
|
4708
|
+
function propName2(node) {
|
|
4709
|
+
if (!node) return void 0;
|
|
4710
|
+
if (node.type === "Identifier") return node.name;
|
|
4711
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4712
|
+
return void 0;
|
|
4713
|
+
}
|
|
4714
|
+
function memberChainNames2(node, names = []) {
|
|
4715
|
+
if (node?.type === "MemberExpression") {
|
|
4716
|
+
memberChainNames2(node.object, names);
|
|
4717
|
+
const n = propName2(node.property);
|
|
4718
|
+
if (n) names.push(n);
|
|
4719
|
+
} else if (node?.type === "Identifier") {
|
|
4720
|
+
names.push(node.name);
|
|
4721
|
+
}
|
|
4722
|
+
return names;
|
|
4723
|
+
}
|
|
4724
|
+
function isPageActionCall(node) {
|
|
4725
|
+
if (node?.type !== "CallExpression") return false;
|
|
4726
|
+
if (node.callee?.type !== "MemberExpression") return false;
|
|
4727
|
+
const chain = memberChainNames2(node.callee);
|
|
4728
|
+
if (chain.length === 0) return false;
|
|
4729
|
+
const last = chain[chain.length - 1];
|
|
4730
|
+
if (!ACTION_METHOD_NAMES.has(last)) return false;
|
|
4731
|
+
return chain.some((n) => /^page$/i.test(n) || /page$/i.test(n));
|
|
4732
|
+
}
|
|
4733
|
+
function markOriginCheckSeen() {
|
|
4734
|
+
const fn = top();
|
|
4735
|
+
if (fn) ensureState(fn).sawOriginCheck = true;
|
|
4736
|
+
}
|
|
4737
|
+
return {
|
|
4738
|
+
Program(node) {
|
|
4739
|
+
pushScope(node);
|
|
4740
|
+
},
|
|
4741
|
+
"Program:exit"() {
|
|
4742
|
+
for (const state of states.values()) {
|
|
4743
|
+
if (state.sawAction && !state.sawOriginCheck) {
|
|
4744
|
+
context.report({ node: state.actionNode, messageId: "noOriginCheck" });
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
},
|
|
4748
|
+
FunctionDeclaration(node) {
|
|
4749
|
+
pushScope(node);
|
|
4750
|
+
},
|
|
4751
|
+
"FunctionDeclaration:exit"() {
|
|
4752
|
+
popScope();
|
|
4753
|
+
},
|
|
4754
|
+
FunctionExpression(node) {
|
|
4755
|
+
pushScope(node);
|
|
4756
|
+
},
|
|
4757
|
+
"FunctionExpression:exit"() {
|
|
4758
|
+
popScope();
|
|
4759
|
+
},
|
|
4760
|
+
ArrowFunctionExpression(node) {
|
|
4761
|
+
pushScope(node);
|
|
4762
|
+
},
|
|
4763
|
+
"ArrowFunctionExpression:exit"() {
|
|
4764
|
+
popScope();
|
|
4765
|
+
},
|
|
4766
|
+
CallExpression(node) {
|
|
4767
|
+
if (isPageActionCall(node)) {
|
|
4768
|
+
const fn = top();
|
|
4769
|
+
if (fn) {
|
|
4770
|
+
const state = ensureState(fn);
|
|
4771
|
+
if (!state.sawAction) {
|
|
4772
|
+
state.sawAction = true;
|
|
4773
|
+
state.actionNode = node;
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
},
|
|
4778
|
+
NewExpression(node) {
|
|
4779
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "URL") {
|
|
4780
|
+
markOriginCheckSeen();
|
|
4781
|
+
}
|
|
4782
|
+
},
|
|
4783
|
+
MemberExpression(node) {
|
|
4784
|
+
const name = propName2(node.property);
|
|
4785
|
+
if (name === "hostname" || name === "origin") {
|
|
4786
|
+
markOriginCheckSeen();
|
|
4787
|
+
}
|
|
4788
|
+
},
|
|
4789
|
+
Identifier(node) {
|
|
4790
|
+
if (/allow.?list/i.test(node.name) || /allowed.?domain/i.test(node.name)) {
|
|
4791
|
+
markOriginCheckSeen();
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
}
|
|
4796
|
+
};
|
|
4797
|
+
var openaiCuaNoDomainAllowlistRule = rule65;
|
|
4798
|
+
|
|
4799
|
+
// src/providers/openai-cua/rules/scroll-delta-default-zero.ts
|
|
4800
|
+
var VERTICAL_DELTA_NAME_PATTERN = /^(dy|delta_?y|scroll_?y)$/i;
|
|
4801
|
+
var rule66 = {
|
|
4802
|
+
meta: {
|
|
4803
|
+
type: "problem",
|
|
4804
|
+
docs: {
|
|
4805
|
+
description: "A missing vertical scroll delta must default to 0, not an arbitrary non-zero value",
|
|
4806
|
+
category: "correctness",
|
|
4807
|
+
rationale: "The reference scroll handler in OpenAI's Computer Use guide defaults a missing scroll delta to 0 on both axes. Defaulting the vertical delta to any other value injects an unintended scroll the model never asked for whenever it omits that field, desyncing the model's mental model of the page from the page's actual state on the next turn.",
|
|
4808
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4809
|
+
recommended: true
|
|
4810
|
+
},
|
|
4811
|
+
messages: {
|
|
4812
|
+
nonZeroDefault: "A missing vertical scroll delta defaults to {{value}} here instead of 0, injecting an unintended scroll the model never requested."
|
|
4813
|
+
}
|
|
4814
|
+
},
|
|
4815
|
+
create(context) {
|
|
4816
|
+
function propName2(node) {
|
|
4817
|
+
if (!node) return void 0;
|
|
4818
|
+
if (node.type === "Identifier") return node.name;
|
|
4819
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4820
|
+
return void 0;
|
|
4821
|
+
}
|
|
4822
|
+
function targetName(node) {
|
|
4823
|
+
if (node?.type === "Identifier") return node.name;
|
|
4824
|
+
if (node?.type === "MemberExpression") return propName2(node.property);
|
|
4825
|
+
return void 0;
|
|
4826
|
+
}
|
|
4827
|
+
function isNonZeroNumberLiteral(node) {
|
|
4828
|
+
return node?.type === "Literal" && typeof node.value === "number" && node.value !== 0;
|
|
4829
|
+
}
|
|
4830
|
+
function reportIfBadDefault(targetNode, valueNode, reportNode) {
|
|
4831
|
+
const name = targetName(targetNode);
|
|
4832
|
+
if (!name || !VERTICAL_DELTA_NAME_PATTERN.test(name)) return;
|
|
4833
|
+
if (!isNonZeroNumberLiteral(valueNode)) return;
|
|
4834
|
+
context.report({ node: reportNode, messageId: "nonZeroDefault", data: { value: String(valueNode.value) } });
|
|
4835
|
+
}
|
|
4836
|
+
function findDirectAssignment(stmt) {
|
|
4837
|
+
let inner = stmt;
|
|
4838
|
+
if (inner?.type === "BlockStatement") {
|
|
4839
|
+
const exprStmts = (inner.body ?? []).filter((s) => s.type === "ExpressionStatement");
|
|
4840
|
+
if (exprStmts.length !== 1) return void 0;
|
|
4841
|
+
inner = exprStmts[0];
|
|
4842
|
+
}
|
|
4843
|
+
if (inner?.type !== "ExpressionStatement") return void 0;
|
|
4844
|
+
const expr = inner.expression;
|
|
4845
|
+
if (expr?.type !== "AssignmentExpression" || expr.operator !== "=") return void 0;
|
|
4846
|
+
return { target: expr.left, value: expr.right };
|
|
4847
|
+
}
|
|
4848
|
+
function undefinedCheckTargetName(test) {
|
|
4849
|
+
if (test?.type !== "BinaryExpression") return void 0;
|
|
4850
|
+
if (test.operator !== "===" && test.operator !== "==") return void 0;
|
|
4851
|
+
const sides = [test.left, test.right];
|
|
4852
|
+
const undefinedSide = sides.find(
|
|
4853
|
+
(s) => s?.type === "Identifier" && s.name === "undefined" || s?.type === "Literal" && s.value === null || s?.type === "UnaryExpression" && s.operator === "typeof"
|
|
4854
|
+
);
|
|
4855
|
+
const otherSide = sides.find((s) => s !== undefinedSide);
|
|
4856
|
+
if (!undefinedSide || !otherSide) return void 0;
|
|
4857
|
+
if (undefinedSide.type === "UnaryExpression") {
|
|
4858
|
+
return targetName(undefinedSide.argument);
|
|
4859
|
+
}
|
|
4860
|
+
return targetName(otherSide);
|
|
4861
|
+
}
|
|
4862
|
+
return {
|
|
4863
|
+
LogicalExpression(node) {
|
|
4864
|
+
if (node.operator !== "??") return;
|
|
4865
|
+
reportIfBadDefault(node.left, node.right, node);
|
|
4866
|
+
},
|
|
4867
|
+
IfStatement(node) {
|
|
4868
|
+
const name = undefinedCheckTargetName(node.test);
|
|
4869
|
+
if (!name) return;
|
|
4870
|
+
const assignment = findDirectAssignment(node.consequent);
|
|
4871
|
+
if (!assignment) return;
|
|
4872
|
+
reportIfBadDefault(assignment.target, assignment.value, node);
|
|
4873
|
+
}
|
|
4874
|
+
};
|
|
4875
|
+
}
|
|
4876
|
+
};
|
|
4877
|
+
var openaiCuaScrollDeltaDefaultZeroRule = rule66;
|
|
4878
|
+
|
|
4879
|
+
// src/providers/openai-cua/rules/structured-step-metadata-not-text-json.ts
|
|
4880
|
+
var BRACE_SEARCH_METHODS = /* @__PURE__ */ new Set(["indexOf", "lastIndexOf"]);
|
|
4881
|
+
var SLICE_METHODS = /* @__PURE__ */ new Set(["slice", "substring", "substr"]);
|
|
4882
|
+
var rule67 = {
|
|
4883
|
+
meta: {
|
|
4884
|
+
type: "suggestion",
|
|
4885
|
+
docs: {
|
|
4886
|
+
description: "Step metadata must come from structured tool output, not brace-hunting in free text",
|
|
4887
|
+
category: "correctness",
|
|
4888
|
+
rationale: "Manually locating a JSON object inside a free-text model message (via indexOf/lastIndexOf plus a slice) is fragile by construction: it breaks if the model adds trailing commentary, wraps the JSON in a markdown fence, or reorders fields. The Responses API supports function tools and structured output specifically so required metadata is schema-validated by the API instead of regex/brace-scraped out of arbitrary text.",
|
|
4889
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4890
|
+
recommended: true
|
|
4891
|
+
},
|
|
4892
|
+
messages: {
|
|
4893
|
+
textJsonExtraction: "This function locates a JSON object in free text via indexOf/lastIndexOf and parses a slice of it \u2014 use a function tool or structured output instead of brace-hunting in the model's text message."
|
|
4894
|
+
}
|
|
4895
|
+
},
|
|
4896
|
+
create(context) {
|
|
4897
|
+
const stack = [];
|
|
4898
|
+
const states = /* @__PURE__ */ new Map();
|
|
4899
|
+
function ensureState(fn) {
|
|
4900
|
+
let s = states.get(fn);
|
|
4901
|
+
if (!s) {
|
|
4902
|
+
s = { sawBraceSearch: false, sawJsonParseOnSlice: false, reportNode: null };
|
|
4903
|
+
states.set(fn, s);
|
|
4904
|
+
}
|
|
4905
|
+
return s;
|
|
4906
|
+
}
|
|
4907
|
+
function top() {
|
|
4908
|
+
return stack[stack.length - 1];
|
|
4909
|
+
}
|
|
4910
|
+
function pushScope(node) {
|
|
4911
|
+
stack.push(node);
|
|
4912
|
+
ensureState(node);
|
|
4913
|
+
}
|
|
4914
|
+
function popScope() {
|
|
4915
|
+
stack.pop();
|
|
4916
|
+
}
|
|
4917
|
+
function isBraceSearchCall(node) {
|
|
4918
|
+
if (node?.type !== "CallExpression") return false;
|
|
4919
|
+
const callee = node.callee;
|
|
4920
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
4921
|
+
if (!BRACE_SEARCH_METHODS.has(callee.property?.name)) return false;
|
|
4922
|
+
const arg = node.arguments?.[0];
|
|
4923
|
+
return arg?.type === "Literal" && typeof arg.value === "string" && arg.value.includes("{");
|
|
4924
|
+
}
|
|
4925
|
+
function isJsonParseOnSliceCall(node) {
|
|
4926
|
+
if (node?.type !== "CallExpression") return false;
|
|
4927
|
+
const callee = node.callee;
|
|
4928
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
4929
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "JSON") return false;
|
|
4930
|
+
if (callee.property?.name !== "parse") return false;
|
|
4931
|
+
const arg = node.arguments?.[0];
|
|
4932
|
+
if (arg?.type !== "CallExpression") return false;
|
|
4933
|
+
const argCallee = arg.callee;
|
|
4934
|
+
return argCallee?.type === "MemberExpression" && SLICE_METHODS.has(argCallee.property?.name);
|
|
4935
|
+
}
|
|
4936
|
+
return {
|
|
4937
|
+
Program(node) {
|
|
4938
|
+
pushScope(node);
|
|
4939
|
+
},
|
|
4940
|
+
"Program:exit"() {
|
|
4941
|
+
for (const state of states.values()) {
|
|
4942
|
+
if (state.sawBraceSearch && state.sawJsonParseOnSlice) {
|
|
4943
|
+
context.report({ node: state.reportNode, messageId: "textJsonExtraction" });
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
},
|
|
4947
|
+
FunctionDeclaration(node) {
|
|
4948
|
+
pushScope(node);
|
|
4949
|
+
},
|
|
4950
|
+
"FunctionDeclaration:exit"() {
|
|
4951
|
+
popScope();
|
|
4952
|
+
},
|
|
4953
|
+
FunctionExpression(node) {
|
|
4954
|
+
pushScope(node);
|
|
4955
|
+
},
|
|
4956
|
+
"FunctionExpression:exit"() {
|
|
4957
|
+
popScope();
|
|
4958
|
+
},
|
|
4959
|
+
ArrowFunctionExpression(node) {
|
|
4960
|
+
pushScope(node);
|
|
4961
|
+
},
|
|
4962
|
+
"ArrowFunctionExpression:exit"() {
|
|
4963
|
+
popScope();
|
|
4964
|
+
},
|
|
4965
|
+
CallExpression(node) {
|
|
4966
|
+
const fn = top();
|
|
4967
|
+
if (!fn) return;
|
|
4968
|
+
const state = ensureState(fn);
|
|
4969
|
+
if (isBraceSearchCall(node)) {
|
|
4970
|
+
state.sawBraceSearch = true;
|
|
4971
|
+
if (!state.reportNode) state.reportNode = node;
|
|
4972
|
+
}
|
|
4973
|
+
if (isJsonParseOnSliceCall(node)) {
|
|
4974
|
+
state.sawJsonParseOnSlice = true;
|
|
4975
|
+
if (!state.reportNode) state.reportNode = node;
|
|
4976
|
+
}
|
|
4977
|
+
}
|
|
4978
|
+
};
|
|
4979
|
+
}
|
|
4980
|
+
};
|
|
4981
|
+
var openaiCuaStructuredStepMetadataNotTextJsonRule = rule67;
|
|
4982
|
+
|
|
4983
|
+
// src/providers/openai-cua/rules/no-blind-safety-check-ack.ts
|
|
4984
|
+
var rule68 = {
|
|
4985
|
+
meta: {
|
|
4986
|
+
type: "suggestion",
|
|
4987
|
+
docs: {
|
|
4988
|
+
description: "Acknowledging pending safety checks must evaluate each check, not blanket-pass them",
|
|
4989
|
+
category: "correctness",
|
|
4990
|
+
rationale: "`acknowledged_safety_checks` exists specifically so a developer can selectively confirm checks they have actually reviewed \u2014 each entry carries an id/code/message. A filter that only checks the row's shape (e.g. that it is an object) and never inspects `.code` or `.message` acknowledges every check present, defeating the purpose of the field entirely.",
|
|
4991
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4992
|
+
recommended: true
|
|
4993
|
+
},
|
|
4994
|
+
messages: {
|
|
4995
|
+
blindAck: "This filter never inspects .code or .message on each safety check \u2014 it acknowledges every check present instead of evaluating it against a policy."
|
|
4996
|
+
}
|
|
4997
|
+
},
|
|
4998
|
+
create(context) {
|
|
4999
|
+
function propName2(node) {
|
|
5000
|
+
if (!node) return void 0;
|
|
5001
|
+
if (node.type === "Identifier") return node.name;
|
|
5002
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
5003
|
+
return void 0;
|
|
5004
|
+
}
|
|
5005
|
+
function sourceLooksLikeSafetyChecks(node) {
|
|
5006
|
+
if (!node) return false;
|
|
5007
|
+
const name = propName2(node) ?? (node.type === "MemberExpression" ? propName2(node.property) : void 0);
|
|
5008
|
+
if (name && /safety.?check/i.test(name)) return true;
|
|
5009
|
+
if (node.type === "LogicalExpression") {
|
|
5010
|
+
return sourceLooksLikeSafetyChecks(node.left) || sourceLooksLikeSafetyChecks(node.right);
|
|
5011
|
+
}
|
|
5012
|
+
if (node.type === "MemberExpression") {
|
|
5013
|
+
return sourceLooksLikeSafetyChecks(node.object) || sourceLooksLikeSafetyChecks(node.property);
|
|
5014
|
+
}
|
|
5015
|
+
return false;
|
|
5016
|
+
}
|
|
5017
|
+
function referencesCodeOrMessage(node, depth = 0) {
|
|
5018
|
+
if (!node || typeof node !== "object" || depth > 10) return false;
|
|
5019
|
+
if (Array.isArray(node)) return node.some((n) => referencesCodeOrMessage(n, depth + 1));
|
|
5020
|
+
if (node.type === "MemberExpression") {
|
|
5021
|
+
const name = propName2(node.property);
|
|
5022
|
+
if (name === "code" || name === "message") return true;
|
|
5023
|
+
}
|
|
5024
|
+
for (const key of Object.keys(node)) {
|
|
5025
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
5026
|
+
const val = node[key];
|
|
5027
|
+
if (val && typeof val === "object") {
|
|
5028
|
+
if (referencesCodeOrMessage(val, depth + 1)) return true;
|
|
5029
|
+
}
|
|
5030
|
+
}
|
|
5031
|
+
return false;
|
|
5032
|
+
}
|
|
5033
|
+
return {
|
|
5034
|
+
CallExpression(node) {
|
|
5035
|
+
const callee = node.callee;
|
|
5036
|
+
if (callee?.type !== "MemberExpression" || callee.property?.name !== "filter") return;
|
|
5037
|
+
if (!sourceLooksLikeSafetyChecks(callee.object)) return;
|
|
5038
|
+
const fn = node.arguments?.[0];
|
|
5039
|
+
if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return;
|
|
5040
|
+
if (!referencesCodeOrMessage(fn.body)) {
|
|
5041
|
+
context.report({ node, messageId: "blindAck" });
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
};
|
|
5045
|
+
}
|
|
5046
|
+
};
|
|
5047
|
+
var openaiCuaNoBlindSafetyCheckAckRule = rule68;
|
|
5048
|
+
|
|
5049
|
+
// src/providers/openai-cua/utils.ts
|
|
5050
|
+
function propName(node) {
|
|
5051
|
+
if (!node) return void 0;
|
|
5052
|
+
if (node.type === "Identifier") return node.name;
|
|
5053
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
5054
|
+
return void 0;
|
|
5055
|
+
}
|
|
5056
|
+
function memberChainNames(node, names = []) {
|
|
5057
|
+
if (node?.type === "MemberExpression") {
|
|
5058
|
+
memberChainNames(node.object, names);
|
|
5059
|
+
const n = propName(node.property);
|
|
5060
|
+
if (n) names.push(n);
|
|
5061
|
+
} else if (node?.type === "Identifier") {
|
|
5062
|
+
names.push(node.name);
|
|
5063
|
+
}
|
|
5064
|
+
return names;
|
|
5065
|
+
}
|
|
5066
|
+
function isResponsesCreateCall(node) {
|
|
5067
|
+
if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") return false;
|
|
5068
|
+
const chain = memberChainNames(node.callee);
|
|
5069
|
+
return chain.length >= 2 && chain[chain.length - 1] === "create" && chain[chain.length - 2] === "responses";
|
|
5070
|
+
}
|
|
5071
|
+
function findResponsesCreateCall(node, depth = 0) {
|
|
5072
|
+
if (!node || typeof node !== "object" || depth > 12) return null;
|
|
5073
|
+
if (Array.isArray(node)) {
|
|
5074
|
+
for (const n of node) {
|
|
5075
|
+
const found = findResponsesCreateCall(n, depth + 1);
|
|
5076
|
+
if (found) return found;
|
|
5077
|
+
}
|
|
5078
|
+
return null;
|
|
5079
|
+
}
|
|
5080
|
+
if (isResponsesCreateCall(node)) return node;
|
|
5081
|
+
for (const key of Object.keys(node)) {
|
|
5082
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
5083
|
+
const val = node[key];
|
|
5084
|
+
if (val && typeof val === "object") {
|
|
5085
|
+
const found = findResponsesCreateCall(val, depth + 1);
|
|
5086
|
+
if (found) return found;
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
return null;
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
// src/providers/openai-cua/rules/retry-transient-turn-errors.ts
|
|
5093
|
+
var rule69 = {
|
|
5094
|
+
meta: {
|
|
5095
|
+
type: "problem",
|
|
5096
|
+
docs: {
|
|
5097
|
+
description: "A responses.create() call must be retried at the turn level on transient errors",
|
|
5098
|
+
category: "reliability",
|
|
5099
|
+
rationale: "The SDK's own internal retry budget (DEFAULT_MAX_RETRIES) is finite. Treating any exception that survives it as fatal for the whole run \u2014 instead of retrying that one turn with backoff \u2014 means a single transient RateLimitError/APIConnectionError/InternalServerError can discard a long-running, multi-turn, expensive agent loop that may have already executed dozens of prior turns.",
|
|
5100
|
+
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
5101
|
+
recommended: true
|
|
5102
|
+
},
|
|
5103
|
+
messages: {
|
|
5104
|
+
noTurnRetry: "This responses.create() call has no turn-level retry \u2014 neither a surrounding loop nor a retry call in the catch block \u2014 so any error beyond the SDK's own retry budget ends the entire run."
|
|
5105
|
+
}
|
|
5106
|
+
},
|
|
5107
|
+
create(context) {
|
|
5108
|
+
let loopDepth = 0;
|
|
5109
|
+
function propName2(node) {
|
|
5110
|
+
if (!node) return void 0;
|
|
5111
|
+
if (node.type === "Identifier") return node.name;
|
|
5112
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
5113
|
+
return void 0;
|
|
5114
|
+
}
|
|
5115
|
+
function catchHasRetryCall(node, depth = 0) {
|
|
5116
|
+
if (!node || typeof node !== "object" || depth > 12) return false;
|
|
5117
|
+
if (Array.isArray(node)) return node.some((n) => catchHasRetryCall(n, depth + 1));
|
|
5118
|
+
if (node.type === "CallExpression") {
|
|
5119
|
+
const callee = node.callee;
|
|
5120
|
+
const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" ? propName2(callee.property) : void 0;
|
|
5121
|
+
if (name && /retry/i.test(name)) return true;
|
|
5122
|
+
}
|
|
5123
|
+
for (const key of Object.keys(node)) {
|
|
5124
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
5125
|
+
const val = node[key];
|
|
5126
|
+
if (val && typeof val === "object") {
|
|
5127
|
+
if (catchHasRetryCall(val, depth + 1)) return true;
|
|
4074
5128
|
}
|
|
4075
5129
|
}
|
|
5130
|
+
return false;
|
|
5131
|
+
}
|
|
5132
|
+
return {
|
|
5133
|
+
ForStatement() {
|
|
5134
|
+
loopDepth += 1;
|
|
5135
|
+
},
|
|
5136
|
+
"ForStatement:exit"() {
|
|
5137
|
+
loopDepth -= 1;
|
|
5138
|
+
},
|
|
5139
|
+
WhileStatement() {
|
|
5140
|
+
loopDepth += 1;
|
|
5141
|
+
},
|
|
5142
|
+
"WhileStatement:exit"() {
|
|
5143
|
+
loopDepth -= 1;
|
|
5144
|
+
},
|
|
5145
|
+
DoWhileStatement() {
|
|
5146
|
+
loopDepth += 1;
|
|
5147
|
+
},
|
|
5148
|
+
"DoWhileStatement:exit"() {
|
|
5149
|
+
loopDepth -= 1;
|
|
5150
|
+
},
|
|
5151
|
+
ForOfStatement() {
|
|
5152
|
+
loopDepth += 1;
|
|
5153
|
+
},
|
|
5154
|
+
"ForOfStatement:exit"() {
|
|
5155
|
+
loopDepth -= 1;
|
|
5156
|
+
},
|
|
5157
|
+
ForInStatement() {
|
|
5158
|
+
loopDepth += 1;
|
|
5159
|
+
},
|
|
5160
|
+
"ForInStatement:exit"() {
|
|
5161
|
+
loopDepth -= 1;
|
|
5162
|
+
},
|
|
5163
|
+
TryStatement(node) {
|
|
5164
|
+
const createCall = findResponsesCreateCall(node.block);
|
|
5165
|
+
if (!createCall) return;
|
|
5166
|
+
if (loopDepth > 0) return;
|
|
5167
|
+
const handler = node.handler;
|
|
5168
|
+
if (!handler) return;
|
|
5169
|
+
if (catchHasRetryCall(handler.body)) return;
|
|
5170
|
+
context.report({ node: handler, messageId: "noTurnRetry" });
|
|
5171
|
+
}
|
|
4076
5172
|
};
|
|
4077
5173
|
}
|
|
4078
5174
|
};
|
|
4079
|
-
var
|
|
5175
|
+
var openaiCuaRetryTransientTurnErrorsRule = rule69;
|
|
4080
5176
|
|
|
4081
|
-
// src/providers/openai-cua/rules/
|
|
4082
|
-
var
|
|
4083
|
-
var rule53 = {
|
|
5177
|
+
// src/providers/openai-cua/rules/check-response-status-incomplete.ts
|
|
5178
|
+
var rule70 = {
|
|
4084
5179
|
meta: {
|
|
4085
5180
|
type: "problem",
|
|
4086
5181
|
docs: {
|
|
4087
|
-
description:
|
|
4088
|
-
category: "
|
|
4089
|
-
|
|
4090
|
-
owasp: "A01:2021 \u2013 Broken Access Control",
|
|
4091
|
-
rationale: `OpenAI's Computer Use guide instructs integrators to "keep an allow list of domains and actions your agent should use, and block everything else." Without an origin check before executing actions, a click that follows an off-domain redirect (phishing link, ad, malicious iframe) is executed exactly like any in-domain click \u2014 and a field-fill action has no origin awareness either, so credentials could be typed into a page the agent was never meant to reach.`,
|
|
5182
|
+
description: 'A completion check must rule out response.status === "incomplete" before reporting success',
|
|
5183
|
+
category: "reliability",
|
|
5184
|
+
rationale: 'The Responses API can return status "incomplete" with zero visible message output when generation is cut off by the token budget \u2014 the same shape ("no computer_call, no message") a purely structural completion check treats as "the model voluntarily finished." Without checking response.status, a token-budget truncation can be silently reported as a successful task completion.',
|
|
4092
5185
|
docsUrl: "https://developers.openai.com/api/docs/guides/tools-computer-use",
|
|
4093
5186
|
recommended: true
|
|
4094
5187
|
},
|
|
4095
5188
|
messages: {
|
|
4096
|
-
|
|
5189
|
+
missingIncompleteCheck: 'This treats the absence of tool calls as a successful completion, but never checks response.status === "incomplete" \u2014 a token-budget truncation can be misreported as success.'
|
|
4097
5190
|
}
|
|
4098
5191
|
},
|
|
4099
5192
|
create(context) {
|
|
@@ -4102,7 +5195,7 @@ var rule53 = {
|
|
|
4102
5195
|
function ensureState(fn) {
|
|
4103
5196
|
let s = states.get(fn);
|
|
4104
5197
|
if (!s) {
|
|
4105
|
-
s = {
|
|
5198
|
+
s = { sawCreateCall: false, sawSuccessReturn: false, successNode: null, sawStatusCheck: false };
|
|
4106
5199
|
states.set(fn, s);
|
|
4107
5200
|
}
|
|
4108
5201
|
return s;
|
|
@@ -4123,28 +5216,13 @@ var rule53 = {
|
|
|
4123
5216
|
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4124
5217
|
return void 0;
|
|
4125
5218
|
}
|
|
4126
|
-
function
|
|
4127
|
-
if (node?.type
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
}
|
|
4134
|
-
return names;
|
|
4135
|
-
}
|
|
4136
|
-
function isPageActionCall(node) {
|
|
4137
|
-
if (node?.type !== "CallExpression") return false;
|
|
4138
|
-
if (node.callee?.type !== "MemberExpression") return false;
|
|
4139
|
-
const chain = memberChainNames2(node.callee);
|
|
4140
|
-
if (chain.length === 0) return false;
|
|
4141
|
-
const last = chain[chain.length - 1];
|
|
4142
|
-
if (!ACTION_METHOD_NAMES.has(last)) return false;
|
|
4143
|
-
return chain.some((n) => /^page$/i.test(n) || /page$/i.test(n));
|
|
4144
|
-
}
|
|
4145
|
-
function markOriginCheckSeen() {
|
|
4146
|
-
const fn = top();
|
|
4147
|
-
if (fn) ensureState(fn).sawOriginCheck = true;
|
|
5219
|
+
function isSuccessObjectLiteral(node) {
|
|
5220
|
+
if (node?.type !== "ObjectExpression") return false;
|
|
5221
|
+
return (node.properties ?? []).some((p) => {
|
|
5222
|
+
if (p?.type !== "Property") return false;
|
|
5223
|
+
const keyName = propName2(p.key);
|
|
5224
|
+
return (keyName === "success" || keyName === "completed") && p.value?.type === "Literal" && p.value.value === true;
|
|
5225
|
+
});
|
|
4148
5226
|
}
|
|
4149
5227
|
return {
|
|
4150
5228
|
Program(node) {
|
|
@@ -4152,8 +5230,8 @@ var rule53 = {
|
|
|
4152
5230
|
},
|
|
4153
5231
|
"Program:exit"() {
|
|
4154
5232
|
for (const state of states.values()) {
|
|
4155
|
-
if (state.
|
|
4156
|
-
context.report({ node: state.
|
|
5233
|
+
if (state.sawCreateCall && state.sawSuccessReturn && !state.sawStatusCheck) {
|
|
5234
|
+
context.report({ node: state.successNode, messageId: "missingIncompleteCheck" });
|
|
4157
5235
|
}
|
|
4158
5236
|
}
|
|
4159
5237
|
},
|
|
@@ -4176,52 +5254,51 @@ var rule53 = {
|
|
|
4176
5254
|
popScope();
|
|
4177
5255
|
},
|
|
4178
5256
|
CallExpression(node) {
|
|
4179
|
-
if (
|
|
5257
|
+
if (findResponsesCreateCall(node)) {
|
|
5258
|
+
const fn = top();
|
|
5259
|
+
if (fn) ensureState(fn).sawCreateCall = true;
|
|
5260
|
+
}
|
|
5261
|
+
},
|
|
5262
|
+
ObjectExpression(node) {
|
|
5263
|
+
if (isSuccessObjectLiteral(node)) {
|
|
4180
5264
|
const fn = top();
|
|
4181
5265
|
if (fn) {
|
|
4182
5266
|
const state = ensureState(fn);
|
|
4183
|
-
if (!state.
|
|
4184
|
-
state.
|
|
4185
|
-
state.
|
|
5267
|
+
if (!state.sawSuccessReturn) {
|
|
5268
|
+
state.sawSuccessReturn = true;
|
|
5269
|
+
state.successNode = node;
|
|
4186
5270
|
}
|
|
4187
5271
|
}
|
|
4188
5272
|
}
|
|
4189
5273
|
},
|
|
4190
|
-
|
|
4191
|
-
if (node.
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
markOriginCheckSeen();
|
|
4199
|
-
}
|
|
4200
|
-
},
|
|
4201
|
-
Identifier(node) {
|
|
4202
|
-
if (/allow.?list/i.test(node.name) || /allowed.?domain/i.test(node.name)) {
|
|
4203
|
-
markOriginCheckSeen();
|
|
5274
|
+
BinaryExpression(node) {
|
|
5275
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
5276
|
+
const sides = [node.left, node.right];
|
|
5277
|
+
const statusSide = sides.find((s) => s?.type === "MemberExpression" && propName2(s.property) === "status");
|
|
5278
|
+
const valueSide = sides.find((s) => s !== statusSide);
|
|
5279
|
+
if (statusSide && valueSide?.type === "Literal" && valueSide.value === "incomplete") {
|
|
5280
|
+
const fn = top();
|
|
5281
|
+
if (fn) ensureState(fn).sawStatusCheck = true;
|
|
4204
5282
|
}
|
|
4205
5283
|
}
|
|
4206
5284
|
};
|
|
4207
5285
|
}
|
|
4208
5286
|
};
|
|
4209
|
-
var
|
|
5287
|
+
var openaiCuaCheckResponseStatusIncompleteRule = rule70;
|
|
4210
5288
|
|
|
4211
|
-
// src/providers/openai-cua/rules/
|
|
4212
|
-
var
|
|
4213
|
-
var rule54 = {
|
|
5289
|
+
// src/providers/openai-cua/rules/set-safety-identifier.ts
|
|
5290
|
+
var rule71 = {
|
|
4214
5291
|
meta: {
|
|
4215
5292
|
type: "problem",
|
|
4216
5293
|
docs: {
|
|
4217
|
-
description: "
|
|
4218
|
-
category: "
|
|
4219
|
-
rationale: "
|
|
4220
|
-
docsUrl: "https://
|
|
5294
|
+
description: "responses.create() must set safety_identifier for per-end-user policy attribution",
|
|
5295
|
+
category: "integration",
|
|
5296
|
+
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://help.openai.com/en/articles/5428082-how-to-incorporate-a-safety-identifier",
|
|
4221
5298
|
recommended: true
|
|
4222
5299
|
},
|
|
4223
5300
|
messages: {
|
|
4224
|
-
|
|
5301
|
+
missingSafetyIdentifier: "This responses.create() call sets no safety_identifier (or user) \u2014 a policy violation here could be attributed to the entire shared API key instead of one end user."
|
|
4225
5302
|
}
|
|
4226
5303
|
},
|
|
4227
5304
|
create(context) {
|
|
@@ -4231,513 +5308,722 @@ var rule54 = {
|
|
|
4231
5308
|
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4232
5309
|
return void 0;
|
|
4233
5310
|
}
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
}
|
|
4248
|
-
function findDirectAssignment(stmt) {
|
|
4249
|
-
let inner = stmt;
|
|
4250
|
-
if (inner?.type === "BlockStatement") {
|
|
4251
|
-
const exprStmts = (inner.body ?? []).filter((s) => s.type === "ExpressionStatement");
|
|
4252
|
-
if (exprStmts.length !== 1) return void 0;
|
|
4253
|
-
inner = exprStmts[0];
|
|
5311
|
+
return {
|
|
5312
|
+
CallExpression(node) {
|
|
5313
|
+
if (!isResponsesCreateCall(node)) return;
|
|
5314
|
+
const optionsArg = node.arguments?.[0];
|
|
5315
|
+
if (optionsArg?.type !== "ObjectExpression") return;
|
|
5316
|
+
const hasIdentifier = (optionsArg.properties ?? []).some((p) => {
|
|
5317
|
+
if (p?.type !== "Property") return false;
|
|
5318
|
+
const keyName = propName2(p.key);
|
|
5319
|
+
return keyName === "safety_identifier" || keyName === "user";
|
|
5320
|
+
});
|
|
5321
|
+
if (!hasIdentifier) {
|
|
5322
|
+
context.report({ node, messageId: "missingSafetyIdentifier" });
|
|
5323
|
+
}
|
|
4254
5324
|
}
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
5325
|
+
};
|
|
5326
|
+
}
|
|
5327
|
+
};
|
|
5328
|
+
var openaiCuaSetSafetyIdentifierRule = rule71;
|
|
5329
|
+
|
|
5330
|
+
// src/providers/tiptap/rules/upload-validate-fn-void.ts
|
|
5331
|
+
var rule72 = {
|
|
5332
|
+
meta: {
|
|
5333
|
+
type: "problem",
|
|
5334
|
+
docs: {
|
|
5335
|
+
description: "validateFn must return boolean so callers can act on the result",
|
|
5336
|
+
category: "security",
|
|
5337
|
+
cwe: "CWE-20",
|
|
5338
|
+
owasp: "API3:2023 Broken Object Property Level Authorization",
|
|
5339
|
+
rationale: "When validateFn is typed as (file: File) => void, the return value is always discarded. A consumer that returns false to reject an oversized or wrong-type file will have that rejection silently ignored \u2014 the upload proceeds regardless. Changing the return type to boolean and guarding with if (validateFn && !validateFn(file)) return; ensures validation actually blocks uploads.",
|
|
5340
|
+
docsUrl: "https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views",
|
|
5341
|
+
recommended: true
|
|
5342
|
+
},
|
|
5343
|
+
messages: {
|
|
5344
|
+
validateFnVoid: "validateFn return value is discarded. Change the return type to boolean and guard the call: if (validateFn && !validateFn(file)) return;"
|
|
5345
|
+
},
|
|
5346
|
+
schema: []
|
|
5347
|
+
},
|
|
5348
|
+
create(context) {
|
|
5349
|
+
let importsTiptap = false;
|
|
5350
|
+
function isVoidReturnType(typeAnnotation) {
|
|
5351
|
+
if (!typeAnnotation) return false;
|
|
5352
|
+
const t = typeAnnotation.typeAnnotation ?? typeAnnotation;
|
|
5353
|
+
if (t?.type === "TSFunctionType") {
|
|
5354
|
+
const ret = t.returnType?.typeAnnotation ?? t.returnType;
|
|
5355
|
+
return ret?.type === "TSVoidKeyword";
|
|
4271
5356
|
}
|
|
4272
|
-
return
|
|
5357
|
+
return false;
|
|
5358
|
+
}
|
|
5359
|
+
function hasProperty(members, name) {
|
|
5360
|
+
return members?.some(
|
|
5361
|
+
(m) => m?.key?.type === "Identifier" && m.key.name === name || m?.key?.type === "Literal" && m.key.value === name
|
|
5362
|
+
) ?? false;
|
|
4273
5363
|
}
|
|
4274
5364
|
return {
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
5365
|
+
ImportDeclaration(node) {
|
|
5366
|
+
const src = node?.source?.value ?? "";
|
|
5367
|
+
if (src.startsWith("@tiptap/")) importsTiptap = true;
|
|
5368
|
+
},
|
|
5369
|
+
// Pattern (a): interface / type with validateFn: (...) => void + onUpload
|
|
5370
|
+
TSInterfaceDeclaration(node) {
|
|
5371
|
+
const members = node?.body?.body ?? [];
|
|
5372
|
+
if (!hasProperty(members, "onUpload")) return;
|
|
5373
|
+
for (const member of members) {
|
|
5374
|
+
const name = member?.key?.type === "Identifier" ? member.key.name : member?.key?.type === "Literal" ? member.key.value : null;
|
|
5375
|
+
if (name !== "validateFn") continue;
|
|
5376
|
+
const typeAnno = member.typeAnnotation;
|
|
5377
|
+
if (isVoidReturnType(typeAnno)) {
|
|
5378
|
+
context.report({ node: member, messageId: "validateFnVoid" });
|
|
5379
|
+
}
|
|
5380
|
+
}
|
|
4278
5381
|
},
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
const
|
|
4283
|
-
if (!
|
|
4284
|
-
|
|
5382
|
+
// Pattern (b): validateFn?.() or validateFn() used as a bare statement
|
|
5383
|
+
ExpressionStatement(node) {
|
|
5384
|
+
const expr = node.expression;
|
|
5385
|
+
const call = expr?.type === "CallExpression" ? expr : expr?.type === "ChainExpression" && expr.expression?.type === "CallExpression" ? expr.expression : null;
|
|
5386
|
+
if (!call) return;
|
|
5387
|
+
const callee = call.callee;
|
|
5388
|
+
const calleeName = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && callee.property?.type === "Identifier" ? callee.property.name : null;
|
|
5389
|
+
if (calleeName !== "validateFn") return;
|
|
5390
|
+
context.report({ node, messageId: "validateFnVoid" });
|
|
4285
5391
|
}
|
|
4286
5392
|
};
|
|
4287
5393
|
}
|
|
4288
5394
|
};
|
|
4289
|
-
var
|
|
5395
|
+
var tiptapUploadValidateFnVoidRule = rule72;
|
|
4290
5396
|
|
|
4291
|
-
// src/providers/
|
|
4292
|
-
var
|
|
4293
|
-
|
|
4294
|
-
|
|
5397
|
+
// src/providers/tiptap/rules/script-src-hardcoded-api-key.ts
|
|
5398
|
+
var rule73 = {
|
|
5399
|
+
meta: {
|
|
5400
|
+
type: "problem",
|
|
5401
|
+
docs: {
|
|
5402
|
+
description: "script.src must not contain a hardcoded API key",
|
|
5403
|
+
category: "security",
|
|
5404
|
+
cwe: "CWE-798",
|
|
5405
|
+
owasp: "API8:2023 Security Misconfiguration",
|
|
5406
|
+
rationale: "Hardcoding an API key directly in a script URL string commits the credential to version control and ships it in the client bundle. The key is trivially visible to anyone who views the page source. Read the key from an environment variable at runtime so it can be rotated without a code change and kept out of the repository.",
|
|
5407
|
+
docsUrl: "https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views",
|
|
5408
|
+
recommended: true
|
|
5409
|
+
},
|
|
5410
|
+
messages: {
|
|
5411
|
+
hardcodedApiKey: "script.src contains a hardcoded API key. Read the key from an environment variable instead."
|
|
5412
|
+
},
|
|
5413
|
+
schema: []
|
|
5414
|
+
},
|
|
5415
|
+
create(context) {
|
|
5416
|
+
function isSrcAssignment(node) {
|
|
5417
|
+
return node?.type === "AssignmentExpression" && node.left?.type === "MemberExpression" && node.left.property?.type === "Identifier" && node.left.property.name === "src";
|
|
5418
|
+
}
|
|
5419
|
+
function isEnvAccess(node) {
|
|
5420
|
+
if (node?.type !== "MemberExpression") return false;
|
|
5421
|
+
const obj = node.object;
|
|
5422
|
+
return obj?.type === "MemberExpression" && obj.object?.type === "Identifier" && obj.object.name === "process" && obj.property?.type === "Identifier" && obj.property.name === "env";
|
|
5423
|
+
}
|
|
5424
|
+
return {
|
|
5425
|
+
AssignmentExpression(node) {
|
|
5426
|
+
if (!isSrcAssignment(node)) return;
|
|
5427
|
+
const right = node.right;
|
|
5428
|
+
if (right?.type === "Literal" && typeof right.value === "string") {
|
|
5429
|
+
if (right.value.includes("apiKey=")) {
|
|
5430
|
+
context.report({ node, messageId: "hardcodedApiKey" });
|
|
5431
|
+
}
|
|
5432
|
+
return;
|
|
5433
|
+
}
|
|
5434
|
+
if (right?.type === "TemplateLiteral") {
|
|
5435
|
+
const quasis = right.quasis ?? [];
|
|
5436
|
+
const expressions = right.expressions ?? [];
|
|
5437
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
5438
|
+
const cooked = quasis[i]?.value?.cooked ?? "";
|
|
5439
|
+
if (cooked.includes("apiKey=")) {
|
|
5440
|
+
const nextExpr = expressions[i];
|
|
5441
|
+
if (!nextExpr) {
|
|
5442
|
+
context.report({ node, messageId: "hardcodedApiKey" });
|
|
5443
|
+
return;
|
|
5444
|
+
}
|
|
5445
|
+
if (!isEnvAccess(nextExpr)) {
|
|
5446
|
+
context.report({ node, messageId: "hardcodedApiKey" });
|
|
5447
|
+
return;
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5452
|
+
}
|
|
5453
|
+
};
|
|
5454
|
+
}
|
|
5455
|
+
};
|
|
5456
|
+
var tiptapScriptSrcHardcodedApiKeyRule = rule73;
|
|
5457
|
+
|
|
5458
|
+
// src/providers/tiptap/rules/dynamic-script-no-sri.ts
|
|
5459
|
+
var rule74 = {
|
|
4295
5460
|
meta: {
|
|
4296
|
-
type: "
|
|
5461
|
+
type: "problem",
|
|
4297
5462
|
docs: {
|
|
4298
|
-
description: "
|
|
4299
|
-
category: "
|
|
4300
|
-
|
|
4301
|
-
|
|
5463
|
+
description: "Dynamically injected script elements must include an SRI integrity attribute",
|
|
5464
|
+
category: "security",
|
|
5465
|
+
cwe: "CWE-829",
|
|
5466
|
+
owasp: "API8:2023 Security Misconfiguration",
|
|
5467
|
+
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",
|
|
4302
5469
|
recommended: true
|
|
4303
5470
|
},
|
|
4304
5471
|
messages: {
|
|
4305
|
-
|
|
4306
|
-
}
|
|
5472
|
+
missingIntegrity: 'Dynamically created script element is appended without an integrity (SRI) attribute. Add script.setAttribute("integrity", "sha384-...") before appending.'
|
|
5473
|
+
},
|
|
5474
|
+
schema: []
|
|
4307
5475
|
},
|
|
4308
5476
|
create(context) {
|
|
4309
|
-
const
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
let s = states.get(fn);
|
|
4313
|
-
if (!s) {
|
|
4314
|
-
s = { sawBraceSearch: false, sawJsonParseOnSlice: false, reportNode: null };
|
|
4315
|
-
states.set(fn, s);
|
|
4316
|
-
}
|
|
4317
|
-
return s;
|
|
4318
|
-
}
|
|
4319
|
-
function top() {
|
|
4320
|
-
return stack[stack.length - 1];
|
|
4321
|
-
}
|
|
4322
|
-
function pushScope(node) {
|
|
4323
|
-
stack.push(node);
|
|
4324
|
-
ensureState(node);
|
|
4325
|
-
}
|
|
4326
|
-
function popScope() {
|
|
4327
|
-
stack.pop();
|
|
4328
|
-
}
|
|
4329
|
-
function isBraceSearchCall(node) {
|
|
4330
|
-
if (node?.type !== "CallExpression") return false;
|
|
5477
|
+
const scriptVars = /* @__PURE__ */ new Map();
|
|
5478
|
+
function isCreateScriptCall(node) {
|
|
5479
|
+
if (node?.type !== "CallExpression") return null;
|
|
4331
5480
|
const callee = node.callee;
|
|
4332
|
-
if (callee?.type !== "MemberExpression") return
|
|
4333
|
-
if (
|
|
5481
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
5482
|
+
if (callee.property?.name !== "createElement") return null;
|
|
4334
5483
|
const arg = node.arguments?.[0];
|
|
4335
|
-
|
|
5484
|
+
if (arg?.type !== "Literal" || arg.value !== "script") return null;
|
|
5485
|
+
return "script";
|
|
4336
5486
|
}
|
|
4337
|
-
function
|
|
5487
|
+
function getVarNameFromDeclarator(node) {
|
|
5488
|
+
if (node?.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
5489
|
+
return node.id.name;
|
|
5490
|
+
}
|
|
5491
|
+
return null;
|
|
5492
|
+
}
|
|
5493
|
+
function isIntegritySet(node, varName) {
|
|
5494
|
+
if (node?.type === "CallExpression") {
|
|
5495
|
+
const callee = node.callee;
|
|
5496
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === varName && (callee.property?.name === "setAttribute" || callee.property?.value === "setAttribute")) {
|
|
5497
|
+
const firstArg = node.arguments?.[0];
|
|
5498
|
+
if (firstArg?.type === "Literal" && firstArg.value === "integrity") return true;
|
|
5499
|
+
}
|
|
5500
|
+
}
|
|
5501
|
+
if (node?.type === "AssignmentExpression" && node.left?.type === "MemberExpression" && node.left.object?.type === "Identifier" && node.left.object.name === varName && node.left.property?.name === "integrity") {
|
|
5502
|
+
return true;
|
|
5503
|
+
}
|
|
5504
|
+
return false;
|
|
5505
|
+
}
|
|
5506
|
+
function isAppendCall(node, varName) {
|
|
4338
5507
|
if (node?.type !== "CallExpression") return false;
|
|
4339
5508
|
const callee = node.callee;
|
|
4340
5509
|
if (callee?.type !== "MemberExpression") return false;
|
|
4341
|
-
|
|
4342
|
-
if (
|
|
4343
|
-
const
|
|
4344
|
-
|
|
4345
|
-
const argCallee = arg.callee;
|
|
4346
|
-
return argCallee?.type === "MemberExpression" && SLICE_METHODS.has(argCallee.property?.name);
|
|
5510
|
+
const methodName = callee.property?.name;
|
|
5511
|
+
if (!["appendChild", "append", "insertBefore", "prepend"].includes(methodName)) return false;
|
|
5512
|
+
const firstArg = node.arguments?.[0];
|
|
5513
|
+
return firstArg?.type === "Identifier" && firstArg.name === varName;
|
|
4347
5514
|
}
|
|
4348
5515
|
return {
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
if (state.sawBraceSearch && state.sawJsonParseOnSlice) {
|
|
4355
|
-
context.report({ node: state.reportNode, messageId: "textJsonExtraction" });
|
|
5516
|
+
VariableDeclarator(node) {
|
|
5517
|
+
if (node.init && isCreateScriptCall(node.init)) {
|
|
5518
|
+
const name = getVarNameFromDeclarator(node);
|
|
5519
|
+
if (name) {
|
|
5520
|
+
scriptVars.set(name, { createNode: node, appendNode: null, hasIntegrity: false });
|
|
4356
5521
|
}
|
|
4357
5522
|
}
|
|
4358
5523
|
},
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
},
|
|
4371
|
-
ArrowFunctionExpression(node) {
|
|
4372
|
-
pushScope(node);
|
|
4373
|
-
},
|
|
4374
|
-
"ArrowFunctionExpression:exit"() {
|
|
4375
|
-
popScope();
|
|
5524
|
+
ExpressionStatement(node) {
|
|
5525
|
+
const expr = node.expression;
|
|
5526
|
+
if (!expr) return;
|
|
5527
|
+
for (const [varName, info] of scriptVars) {
|
|
5528
|
+
if (isIntegritySet(expr, varName)) {
|
|
5529
|
+
info.hasIntegrity = true;
|
|
5530
|
+
}
|
|
5531
|
+
if (isAppendCall(expr, varName) && !info.appendNode) {
|
|
5532
|
+
info.appendNode = node;
|
|
5533
|
+
}
|
|
5534
|
+
}
|
|
4376
5535
|
},
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
const
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
5536
|
+
// Also catch assignments: e.g. document.body.innerHTML won't appear here,
|
|
5537
|
+
// but handle AssignmentExpression for integrity
|
|
5538
|
+
AssignmentExpression(node) {
|
|
5539
|
+
for (const [varName, info] of scriptVars) {
|
|
5540
|
+
if (isIntegritySet(node, varName)) {
|
|
5541
|
+
info.hasIntegrity = true;
|
|
5542
|
+
}
|
|
4384
5543
|
}
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
5544
|
+
},
|
|
5545
|
+
"Program:exit"() {
|
|
5546
|
+
for (const [, info] of scriptVars) {
|
|
5547
|
+
if (info.appendNode && !info.hasIntegrity) {
|
|
5548
|
+
context.report({ node: info.appendNode, messageId: "missingIntegrity" });
|
|
5549
|
+
}
|
|
4388
5550
|
}
|
|
4389
5551
|
}
|
|
4390
5552
|
};
|
|
4391
5553
|
}
|
|
4392
5554
|
};
|
|
4393
|
-
var
|
|
5555
|
+
var tiptapDynamicScriptNoSriRule = rule74;
|
|
4394
5556
|
|
|
4395
|
-
// src/providers/
|
|
4396
|
-
|
|
5557
|
+
// src/providers/tiptap/utils.ts
|
|
5558
|
+
function findProperty3(objectExpression, name) {
|
|
5559
|
+
if (objectExpression?.type !== "ObjectExpression") return void 0;
|
|
5560
|
+
return objectExpression.properties?.find(
|
|
5561
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === name || p.key?.type === "Literal" && p.key.value === name)
|
|
5562
|
+
);
|
|
5563
|
+
}
|
|
5564
|
+
function walk(node, visit) {
|
|
5565
|
+
if (!node || typeof node !== "object") return;
|
|
5566
|
+
const result = visit(node);
|
|
5567
|
+
if (result === false) return;
|
|
5568
|
+
for (const key of Object.keys(node)) {
|
|
5569
|
+
if (key === "parent") continue;
|
|
5570
|
+
const child = node[key];
|
|
5571
|
+
if (Array.isArray(child)) {
|
|
5572
|
+
for (const item of child) walk(item, visit);
|
|
5573
|
+
} else if (child && typeof child === "object" && child.type) {
|
|
5574
|
+
walk(child, visit);
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5579
|
+
// src/providers/tiptap/rules/addAttributes-missing-renderHTML.ts
|
|
5580
|
+
var rule75 = {
|
|
4397
5581
|
meta: {
|
|
4398
|
-
type: "
|
|
5582
|
+
type: "problem",
|
|
4399
5583
|
docs: {
|
|
4400
|
-
description: "
|
|
5584
|
+
description: "TipTap addAttributes descriptors with parseHTML must also define renderHTML",
|
|
4401
5585
|
category: "correctness",
|
|
4402
|
-
rationale:
|
|
4403
|
-
docsUrl: "https://
|
|
5586
|
+
rationale: 'When an attribute defines parseHTML: (el) => el.getAttribute("data-label") but no renderHTML, TipTap emits the attribute as a plain HTML attribute (label="...") instead of data-label="...". On re-parse, getAttribute("data-label") returns null and the value falls back to the default, silently discarding any customization across HTML export/import cycles.',
|
|
5587
|
+
docsUrl: "https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/node#attributes",
|
|
4404
5588
|
recommended: true
|
|
4405
5589
|
},
|
|
4406
5590
|
messages: {
|
|
4407
|
-
|
|
4408
|
-
}
|
|
5591
|
+
missingRenderHTML: 'Attribute defines parseHTML but no renderHTML. TipTap will serialize this attribute with the default name, breaking the HTML round-trip. Add renderHTML: (attrs) => ({ "data-<name>": attrs.<name> }).'
|
|
5592
|
+
},
|
|
5593
|
+
schema: []
|
|
4409
5594
|
},
|
|
4410
5595
|
create(context) {
|
|
4411
|
-
function
|
|
4412
|
-
if (
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
if (name && /safety.?check/i.test(name)) return true;
|
|
4421
|
-
if (node.type === "LogicalExpression") {
|
|
4422
|
-
return sourceLooksLikeSafetyChecks(node.left) || sourceLooksLikeSafetyChecks(node.right);
|
|
4423
|
-
}
|
|
4424
|
-
if (node.type === "MemberExpression") {
|
|
4425
|
-
return sourceLooksLikeSafetyChecks(node.object) || sourceLooksLikeSafetyChecks(node.property);
|
|
5596
|
+
function checkAddAttributesReturn(returnObj) {
|
|
5597
|
+
if (returnObj?.type !== "ObjectExpression") return;
|
|
5598
|
+
for (const prop of returnObj.properties ?? []) {
|
|
5599
|
+
if (prop?.type !== "Property") continue;
|
|
5600
|
+
const val = prop.value;
|
|
5601
|
+
if (val?.type !== "ObjectExpression") continue;
|
|
5602
|
+
if (findProperty3(val, "parseHTML") && !findProperty3(val, "renderHTML")) {
|
|
5603
|
+
context.report({ node: val, messageId: "missingRenderHTML" });
|
|
5604
|
+
}
|
|
4426
5605
|
}
|
|
4427
|
-
return false;
|
|
4428
5606
|
}
|
|
4429
|
-
function
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
5607
|
+
function checkAddAttributesFunction(fn) {
|
|
5608
|
+
walk(fn, (node) => {
|
|
5609
|
+
if (node?.type === "ReturnStatement" && node.argument) {
|
|
5610
|
+
checkAddAttributesReturn(node.argument);
|
|
5611
|
+
}
|
|
5612
|
+
});
|
|
5613
|
+
}
|
|
5614
|
+
return {
|
|
5615
|
+
// Handles: addAttributes() { return { ... } } as method shorthand
|
|
5616
|
+
// and addAttributes: function() { } / addAttributes: () => { }
|
|
5617
|
+
Property(node) {
|
|
5618
|
+
const keyName = node.key?.type === "Identifier" ? node.key.name : node.key?.type === "Literal" ? node.key.value : null;
|
|
5619
|
+
if (keyName !== "addAttributes") return;
|
|
5620
|
+
const val = node.value;
|
|
5621
|
+
if (!val) return;
|
|
5622
|
+
if (val.type === "FunctionExpression" || val.type === "ArrowFunctionExpression") {
|
|
5623
|
+
if (val.body?.type === "ObjectExpression") {
|
|
5624
|
+
checkAddAttributesReturn(val.body);
|
|
5625
|
+
} else {
|
|
5626
|
+
checkAddAttributesFunction(val.body);
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
4435
5629
|
}
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
5630
|
+
};
|
|
5631
|
+
}
|
|
5632
|
+
};
|
|
5633
|
+
var tiptapAddAttributesMissingRenderHTMLRule = rule75;
|
|
5634
|
+
|
|
5635
|
+
// src/providers/tiptap/rules/appendTransaction-add-to-history.ts
|
|
5636
|
+
var rule76 = {
|
|
5637
|
+
meta: {
|
|
5638
|
+
type: "suggestion",
|
|
5639
|
+
docs: {
|
|
5640
|
+
description: 'appendTransaction mutations must include tr.setMeta("addToHistory", false)',
|
|
5641
|
+
category: "correctness",
|
|
5642
|
+
rationale: 'ProseMirror records every transaction dispatched by appendTransaction into the undo history unless it is annotated with setMeta("addToHistory", false). When an auto-correction (e.g. inserting a space into an empty node) is recorded, pressing Ctrl-Z undoes the correction and re-enters the broken state, which the plugin immediately re-corrects \u2014 the undo and redo keys fight each other.',
|
|
5643
|
+
docsUrl: "https://prosemirror.net/docs/ref/#state.Transaction.setMeta",
|
|
5644
|
+
recommended: true
|
|
5645
|
+
},
|
|
5646
|
+
messages: {
|
|
5647
|
+
missingAddToHistory: 'appendTransaction mutates the transaction without setMeta("addToHistory", false). This pollutes the undo stack. Add tr.setMeta("addToHistory", false) to the transaction.'
|
|
5648
|
+
},
|
|
5649
|
+
schema: []
|
|
5650
|
+
},
|
|
5651
|
+
create(context) {
|
|
5652
|
+
function hasAddToHistoryMeta(fnBody) {
|
|
5653
|
+
let found = false;
|
|
5654
|
+
walk(fnBody, (node) => {
|
|
5655
|
+
if (found) return false;
|
|
5656
|
+
if (node?.type !== "CallExpression") return;
|
|
5657
|
+
const callee = node.callee;
|
|
5658
|
+
if (callee?.type !== "MemberExpression") return;
|
|
5659
|
+
if (callee.property?.name !== "setMeta") return;
|
|
5660
|
+
const firstArg = node.arguments?.[0];
|
|
5661
|
+
if (firstArg?.type === "Literal" && firstArg.value === "addToHistory") {
|
|
5662
|
+
found = true;
|
|
5663
|
+
}
|
|
5664
|
+
});
|
|
5665
|
+
return found;
|
|
5666
|
+
}
|
|
5667
|
+
function hasMutation(fnBody) {
|
|
5668
|
+
const mutatingMethods = /* @__PURE__ */ new Set([
|
|
5669
|
+
"insert",
|
|
5670
|
+
"insertText",
|
|
5671
|
+
"replace",
|
|
5672
|
+
"replaceWith",
|
|
5673
|
+
"replaceRange",
|
|
5674
|
+
"replaceRangeWith",
|
|
5675
|
+
"delete",
|
|
5676
|
+
"deleteRange",
|
|
5677
|
+
"addMark",
|
|
5678
|
+
"removeMark",
|
|
5679
|
+
"setNodeMarkup",
|
|
5680
|
+
"setNodeAttribute",
|
|
5681
|
+
"setDocAttribute"
|
|
5682
|
+
]);
|
|
5683
|
+
let found = false;
|
|
5684
|
+
walk(fnBody, (node) => {
|
|
5685
|
+
if (found) return false;
|
|
5686
|
+
if (node?.type !== "CallExpression") return;
|
|
5687
|
+
const callee = node.callee;
|
|
5688
|
+
if (callee?.type !== "MemberExpression") return;
|
|
5689
|
+
const methodName = callee.property?.name;
|
|
5690
|
+
if (methodName && mutatingMethods.has(methodName)) {
|
|
5691
|
+
found = true;
|
|
4441
5692
|
}
|
|
5693
|
+
});
|
|
5694
|
+
return found;
|
|
5695
|
+
}
|
|
5696
|
+
function checkAppendTransactionFn(fn, reportNode) {
|
|
5697
|
+
const body = fn?.body;
|
|
5698
|
+
if (!body) return;
|
|
5699
|
+
if (hasMutation(body) && !hasAddToHistoryMeta(body)) {
|
|
5700
|
+
context.report({ node: reportNode, messageId: "missingAddToHistory" });
|
|
4442
5701
|
}
|
|
4443
|
-
return false;
|
|
4444
5702
|
}
|
|
4445
5703
|
return {
|
|
4446
|
-
|
|
4447
|
-
const
|
|
4448
|
-
if (
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
if (!referencesCodeOrMessage(fn.body)) {
|
|
4453
|
-
context.report({ node, messageId: "blindAck" });
|
|
5704
|
+
Property(node) {
|
|
5705
|
+
const keyName = node.key?.type === "Identifier" ? node.key.name : node.key?.type === "Literal" ? node.key.value : null;
|
|
5706
|
+
if (keyName !== "appendTransaction") return;
|
|
5707
|
+
const val = node.value;
|
|
5708
|
+
if (val?.type === "FunctionExpression" || val?.type === "ArrowFunctionExpression") {
|
|
5709
|
+
checkAppendTransactionFn(val, node);
|
|
4454
5710
|
}
|
|
4455
5711
|
}
|
|
4456
5712
|
};
|
|
4457
5713
|
}
|
|
4458
5714
|
};
|
|
4459
|
-
var
|
|
5715
|
+
var tiptapAppendTransactionAddToHistoryRule = rule76;
|
|
4460
5716
|
|
|
4461
|
-
// src/providers/
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
function
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
5717
|
+
// src/providers/tiptap/rules/appendTransaction-full-scan.ts
|
|
5718
|
+
var rule77 = {
|
|
5719
|
+
meta: {
|
|
5720
|
+
type: "suggestion",
|
|
5721
|
+
docs: {
|
|
5722
|
+
description: "appendTransaction must guard doc.descendants() with a docChanged check",
|
|
5723
|
+
category: "reliability",
|
|
5724
|
+
rationale: "doc.descendants() visits every node in the document on every transaction \u2014 every keystroke, selection change, and formatting toggle. For documents with many nodes this is O(n) per transaction and causes visible input latency. ProseMirror provides the docChanged flag precisely to avoid this work when the document structure has not changed.",
|
|
5725
|
+
docsUrl: "https://prosemirror.net/docs/ref/#state.PluginSpec.appendTransaction",
|
|
5726
|
+
recommended: true
|
|
5727
|
+
},
|
|
5728
|
+
messages: {
|
|
5729
|
+
fullScanOnEveryTransaction: "appendTransaction calls doc.descendants() on every transaction. Check docChanged first: const docChanged = transactions.some(tr => tr.docChanged); if (!docChanged) return;"
|
|
5730
|
+
},
|
|
5731
|
+
schema: []
|
|
5732
|
+
},
|
|
5733
|
+
create(context) {
|
|
5734
|
+
function hasDocChangedGuard(fnBody) {
|
|
5735
|
+
let found = false;
|
|
5736
|
+
walk(fnBody, (node) => {
|
|
5737
|
+
if (found) return false;
|
|
5738
|
+
if (node?.type === "MemberExpression" && node.property?.name === "docChanged") {
|
|
5739
|
+
found = true;
|
|
5740
|
+
return false;
|
|
5741
|
+
}
|
|
5742
|
+
if (node?.type === "Identifier" && node.name === "docChanged") {
|
|
5743
|
+
found = true;
|
|
5744
|
+
return false;
|
|
5745
|
+
}
|
|
5746
|
+
});
|
|
5747
|
+
return found;
|
|
4489
5748
|
}
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
5749
|
+
function hasDescendantsCall(fnBody) {
|
|
5750
|
+
let found = false;
|
|
5751
|
+
walk(fnBody, (node) => {
|
|
5752
|
+
if (found) return false;
|
|
5753
|
+
if (node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.name === "descendants") {
|
|
5754
|
+
found = true;
|
|
5755
|
+
}
|
|
5756
|
+
});
|
|
5757
|
+
return found;
|
|
5758
|
+
}
|
|
5759
|
+
function checkAppendTransactionFn(fn, reportNode) {
|
|
5760
|
+
const body = fn?.body;
|
|
5761
|
+
if (!body) return;
|
|
5762
|
+
if (hasDescendantsCall(body) && !hasDocChangedGuard(body)) {
|
|
5763
|
+
context.report({ node: reportNode, messageId: "fullScanOnEveryTransaction" });
|
|
5764
|
+
}
|
|
4499
5765
|
}
|
|
5766
|
+
return {
|
|
5767
|
+
Property(node) {
|
|
5768
|
+
const keyName = node.key?.type === "Identifier" ? node.key.name : node.key?.type === "Literal" ? node.key.value : null;
|
|
5769
|
+
if (keyName !== "appendTransaction") return;
|
|
5770
|
+
const val = node.value;
|
|
5771
|
+
if (val?.type === "FunctionExpression" || val?.type === "ArrowFunctionExpression") {
|
|
5772
|
+
checkAppendTransactionFn(val, node);
|
|
5773
|
+
}
|
|
5774
|
+
}
|
|
5775
|
+
};
|
|
4500
5776
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
5777
|
+
};
|
|
5778
|
+
var tiptapAppendTransactionFullScanRule = rule77;
|
|
4503
5779
|
|
|
4504
|
-
// src/providers/
|
|
4505
|
-
var
|
|
5780
|
+
// src/providers/tiptap/rules/atom-node-wrap-in.ts
|
|
5781
|
+
var rule78 = {
|
|
4506
5782
|
meta: {
|
|
4507
5783
|
type: "problem",
|
|
4508
5784
|
docs: {
|
|
4509
|
-
description: "
|
|
4510
|
-
category: "
|
|
4511
|
-
rationale:
|
|
4512
|
-
docsUrl: "https://
|
|
5785
|
+
description: "wrapIn() must not be called with an atom node type",
|
|
5786
|
+
category: "correctness",
|
|
5787
|
+
rationale: 'Atom nodes have no content model \u2014 ProseMirror cannot wrap other nodes inside them. Calling wrapIn("atomNode") always returns false silently, making the command appear broken when triggered on selected text. Use replaceSelectionWith(node.create()) or insertContent() instead.',
|
|
5788
|
+
docsUrl: "https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/node",
|
|
4513
5789
|
recommended: true
|
|
4514
5790
|
},
|
|
4515
5791
|
messages: {
|
|
4516
|
-
|
|
4517
|
-
}
|
|
5792
|
+
wrapInAtomNode: "wrapIn() is called with an atom node type. Atom nodes cannot contain content, so wrapIn() always silently returns false. Replace the selection instead of wrapping it."
|
|
5793
|
+
},
|
|
5794
|
+
schema: []
|
|
4518
5795
|
},
|
|
4519
5796
|
create(context) {
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
if (
|
|
4524
|
-
|
|
4525
|
-
|
|
5797
|
+
const atomNodeNames = /* @__PURE__ */ new Set();
|
|
5798
|
+
const wrapInCalls = [];
|
|
5799
|
+
function extractNodeName(configObj) {
|
|
5800
|
+
if (configObj?.type !== "ObjectExpression") return null;
|
|
5801
|
+
const nameProp = configObj.properties?.find(
|
|
5802
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === "name" || p.key?.type === "Literal" && p.key.value === "name")
|
|
5803
|
+
);
|
|
5804
|
+
const nameVal = nameProp?.value;
|
|
5805
|
+
if (nameVal?.type === "Literal" && typeof nameVal.value === "string") {
|
|
5806
|
+
return nameVal.value;
|
|
5807
|
+
}
|
|
5808
|
+
return null;
|
|
4526
5809
|
}
|
|
4527
|
-
function
|
|
4528
|
-
if (
|
|
4529
|
-
|
|
4530
|
-
|
|
5810
|
+
function hasAtomTrue(configObj) {
|
|
5811
|
+
if (configObj?.type !== "ObjectExpression") return false;
|
|
5812
|
+
return configObj.properties?.some(
|
|
5813
|
+
(p) => p?.type === "Property" && (p.key?.type === "Identifier" && p.key.name === "atom" || p.key?.type === "Literal" && p.key.value === "atom") && p.value?.type === "Literal" && p.value.value === true
|
|
5814
|
+
) ?? false;
|
|
5815
|
+
}
|
|
5816
|
+
return {
|
|
5817
|
+
CallExpression(node) {
|
|
5818
|
+
if (node.callee?.type === "MemberExpression" && node.callee.property?.name === "create") {
|
|
5819
|
+
const configArg = node.arguments?.[0];
|
|
5820
|
+
if (configArg?.type === "ObjectExpression" && hasAtomTrue(configArg)) {
|
|
5821
|
+
const name = extractNodeName(configArg);
|
|
5822
|
+
if (name) atomNodeNames.add(name);
|
|
5823
|
+
}
|
|
5824
|
+
}
|
|
4531
5825
|
const callee = node.callee;
|
|
4532
|
-
const
|
|
4533
|
-
if (
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
5826
|
+
const isWrapIn = callee?.type === "Identifier" && callee.name === "wrapIn" || callee?.type === "MemberExpression" && callee.property?.name === "wrapIn";
|
|
5827
|
+
if (isWrapIn) {
|
|
5828
|
+
const firstArg = node.arguments?.[0];
|
|
5829
|
+
if (firstArg?.type === "Literal" && typeof firstArg.value === "string") {
|
|
5830
|
+
wrapInCalls.push({ node, name: firstArg.value });
|
|
5831
|
+
}
|
|
5832
|
+
}
|
|
5833
|
+
},
|
|
5834
|
+
"Program:exit"() {
|
|
5835
|
+
for (const { node, name } of wrapInCalls) {
|
|
5836
|
+
if (atomNodeNames.has(name)) {
|
|
5837
|
+
context.report({ node, messageId: "wrapInAtomNode" });
|
|
5838
|
+
}
|
|
4540
5839
|
}
|
|
4541
5840
|
}
|
|
4542
|
-
|
|
5841
|
+
};
|
|
5842
|
+
}
|
|
5843
|
+
};
|
|
5844
|
+
var tiptapAtomNodeWrapInRule = rule78;
|
|
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" });
|
|
4543
5867
|
}
|
|
4544
5868
|
return {
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
loopDepth -= 1;
|
|
4550
|
-
},
|
|
4551
|
-
WhileStatement() {
|
|
4552
|
-
loopDepth += 1;
|
|
4553
|
-
},
|
|
4554
|
-
"WhileStatement:exit"() {
|
|
4555
|
-
loopDepth -= 1;
|
|
4556
|
-
},
|
|
4557
|
-
DoWhileStatement() {
|
|
4558
|
-
loopDepth += 1;
|
|
4559
|
-
},
|
|
4560
|
-
"DoWhileStatement:exit"() {
|
|
4561
|
-
loopDepth -= 1;
|
|
4562
|
-
},
|
|
4563
|
-
ForOfStatement() {
|
|
4564
|
-
loopDepth += 1;
|
|
4565
|
-
},
|
|
4566
|
-
"ForOfStatement:exit"() {
|
|
4567
|
-
loopDepth -= 1;
|
|
4568
|
-
},
|
|
4569
|
-
ForInStatement() {
|
|
4570
|
-
loopDepth += 1;
|
|
4571
|
-
},
|
|
4572
|
-
"ForInStatement:exit"() {
|
|
4573
|
-
loopDepth -= 1;
|
|
5869
|
+
Literal(node) {
|
|
5870
|
+
if (node.regex?.pattern) {
|
|
5871
|
+
checkPattern(node.regex.pattern, node);
|
|
5872
|
+
}
|
|
4574
5873
|
},
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
context.report({ node: handler, messageId: "noTurnRetry" });
|
|
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
|
+
}
|
|
4583
5881
|
}
|
|
4584
5882
|
};
|
|
4585
5883
|
}
|
|
4586
5884
|
};
|
|
4587
|
-
var
|
|
5885
|
+
var tiptapTwitterUrlRegexRule = rule79;
|
|
4588
5886
|
|
|
4589
|
-
// src/providers/
|
|
4590
|
-
var
|
|
5887
|
+
// src/providers/tiptap/rules/drop-handler-pos-precedence.ts
|
|
5888
|
+
var rule80 = {
|
|
4591
5889
|
meta: {
|
|
4592
5890
|
type: "problem",
|
|
4593
5891
|
docs: {
|
|
4594
|
-
description:
|
|
4595
|
-
category: "
|
|
4596
|
-
rationale:
|
|
4597
|
-
docsUrl: "https://
|
|
5892
|
+
description: "x ?? 0 - 1 is parsed as x ?? (0 - 1) = x ?? -1 due to operator precedence",
|
|
5893
|
+
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`. When pos is null or undefined, the fallback is -1 instead of the intended -1 offset from 0. In ProseMirror drop handlers this produces a decoration at position -1, which corrupts the placeholder position map and can insert content at the document start.",
|
|
5895
|
+
docsUrl: "https://prosemirror.net/docs/ref/#view.EditorView.posAtCoords",
|
|
4598
5896
|
recommended: true
|
|
4599
5897
|
},
|
|
4600
5898
|
messages: {
|
|
4601
|
-
|
|
4602
|
-
}
|
|
5899
|
+
posNullCoalescePrecedence: 'Operator precedence bug: "x ?? 0 - 1" is parsed as "x ?? (0 - 1)" = "x ?? -1". Wrap the intended expression: "(x ?? 0) - 1".'
|
|
5900
|
+
},
|
|
5901
|
+
schema: []
|
|
4603
5902
|
},
|
|
4604
5903
|
create(context) {
|
|
4605
|
-
const stack = [];
|
|
4606
|
-
const states = /* @__PURE__ */ new Map();
|
|
4607
|
-
function ensureState(fn) {
|
|
4608
|
-
let s = states.get(fn);
|
|
4609
|
-
if (!s) {
|
|
4610
|
-
s = { sawCreateCall: false, sawSuccessReturn: false, successNode: null, sawStatusCheck: false };
|
|
4611
|
-
states.set(fn, s);
|
|
4612
|
-
}
|
|
4613
|
-
return s;
|
|
4614
|
-
}
|
|
4615
|
-
function top() {
|
|
4616
|
-
return stack[stack.length - 1];
|
|
4617
|
-
}
|
|
4618
|
-
function pushScope(node) {
|
|
4619
|
-
stack.push(node);
|
|
4620
|
-
ensureState(node);
|
|
4621
|
-
}
|
|
4622
|
-
function popScope() {
|
|
4623
|
-
stack.pop();
|
|
4624
|
-
}
|
|
4625
|
-
function propName2(node) {
|
|
4626
|
-
if (!node) return void 0;
|
|
4627
|
-
if (node.type === "Identifier") return node.name;
|
|
4628
|
-
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
4629
|
-
return void 0;
|
|
4630
|
-
}
|
|
4631
|
-
function isSuccessObjectLiteral(node) {
|
|
4632
|
-
if (node?.type !== "ObjectExpression") return false;
|
|
4633
|
-
return (node.properties ?? []).some((p) => {
|
|
4634
|
-
if (p?.type !== "Property") return false;
|
|
4635
|
-
const keyName = propName2(p.key);
|
|
4636
|
-
return (keyName === "success" || keyName === "completed") && p.value?.type === "Literal" && p.value.value === true;
|
|
4637
|
-
});
|
|
4638
|
-
}
|
|
4639
5904
|
return {
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
if (state.sawCreateCall && state.sawSuccessReturn && !state.sawStatusCheck) {
|
|
4646
|
-
context.report({ node: state.successNode, messageId: "missingIncompleteCheck" });
|
|
4647
|
-
}
|
|
5905
|
+
LogicalExpression(node) {
|
|
5906
|
+
if (node.operator !== "??") return;
|
|
5907
|
+
const right = node.right;
|
|
5908
|
+
if (right?.type === "BinaryExpression" && right.operator === "-" && right.left?.type === "Literal" && right.left.value === 0 && right.right?.type === "Literal" && right.right.value === 1) {
|
|
5909
|
+
context.report({ node, messageId: "posNullCoalescePrecedence" });
|
|
4648
5910
|
}
|
|
4649
|
-
}
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
5911
|
+
}
|
|
5912
|
+
};
|
|
5913
|
+
}
|
|
5914
|
+
};
|
|
5915
|
+
var tiptapDropHandlerPosPrecedenceRule = rule80;
|
|
5916
|
+
|
|
5917
|
+
// src/providers/tiptap/rules/prefer-table-kit.ts
|
|
5918
|
+
var INDIVIDUAL_TABLE_PACKAGES = /* @__PURE__ */ new Set([
|
|
5919
|
+
"@tiptap/extension-table-row",
|
|
5920
|
+
"@tiptap/extension-table-cell",
|
|
5921
|
+
"@tiptap/extension-table-header"
|
|
5922
|
+
]);
|
|
5923
|
+
var rule81 = {
|
|
5924
|
+
meta: {
|
|
5925
|
+
type: "suggestion",
|
|
5926
|
+
docs: {
|
|
5927
|
+
description: "Use TableKit from @tiptap/extension-table instead of individual table packages",
|
|
5928
|
+
category: "integration",
|
|
5929
|
+
rationale: "Importing table extensions individually from their separate packages bypasses the coordinated wiring that TableKit provides: shared configuration, the mergeOrSplit command, and consistent HTMLAttributes across all table elements. Individual imports also leave TableCell and TableHeader unconfigured by default.",
|
|
5930
|
+
docsUrl: "https://tiptap.dev/docs/editor/extensions/functionality/table-kit",
|
|
5931
|
+
recommended: true
|
|
5932
|
+
},
|
|
5933
|
+
messages: {
|
|
5934
|
+
preferTableKit: "Individual TipTap table extension imported. Use TableKit from @tiptap/extension-table for coordinated configuration and the mergeOrSplit command."
|
|
5935
|
+
},
|
|
5936
|
+
schema: []
|
|
5937
|
+
},
|
|
5938
|
+
create(context) {
|
|
5939
|
+
const individualImports = [];
|
|
5940
|
+
return {
|
|
5941
|
+
ImportDeclaration(node) {
|
|
5942
|
+
const src = node?.source?.value ?? "";
|
|
5943
|
+
if (INDIVIDUAL_TABLE_PACKAGES.has(src)) {
|
|
5944
|
+
individualImports.push(node);
|
|
4672
5945
|
}
|
|
4673
5946
|
},
|
|
4674
|
-
|
|
4675
|
-
if (
|
|
4676
|
-
const
|
|
4677
|
-
|
|
4678
|
-
const state = ensureState(fn);
|
|
4679
|
-
if (!state.sawSuccessReturn) {
|
|
4680
|
-
state.sawSuccessReturn = true;
|
|
4681
|
-
state.successNode = node;
|
|
4682
|
-
}
|
|
5947
|
+
"Program:exit"() {
|
|
5948
|
+
if (individualImports.length >= 2) {
|
|
5949
|
+
for (const node of individualImports) {
|
|
5950
|
+
context.report({ node, messageId: "preferTableKit" });
|
|
4683
5951
|
}
|
|
4684
5952
|
}
|
|
4685
|
-
},
|
|
4686
|
-
BinaryExpression(node) {
|
|
4687
|
-
if (node.operator !== "===" && node.operator !== "==") return;
|
|
4688
|
-
const sides = [node.left, node.right];
|
|
4689
|
-
const statusSide = sides.find((s) => s?.type === "MemberExpression" && propName2(s.property) === "status");
|
|
4690
|
-
const valueSide = sides.find((s) => s !== statusSide);
|
|
4691
|
-
if (statusSide && valueSide?.type === "Literal" && valueSide.value === "incomplete") {
|
|
4692
|
-
const fn = top();
|
|
4693
|
-
if (fn) ensureState(fn).sawStatusCheck = true;
|
|
4694
|
-
}
|
|
4695
5953
|
}
|
|
4696
5954
|
};
|
|
4697
5955
|
}
|
|
4698
5956
|
};
|
|
4699
|
-
var
|
|
5957
|
+
var tiptapPreferTableKitRule = rule81;
|
|
4700
5958
|
|
|
4701
|
-
// src/providers/
|
|
4702
|
-
var
|
|
5959
|
+
// src/providers/tiptap/rules/tiptap-markdown-missing-node-spec.ts
|
|
5960
|
+
var rule82 = {
|
|
4703
5961
|
meta: {
|
|
4704
|
-
type: "
|
|
5962
|
+
type: "suggestion",
|
|
4705
5963
|
docs: {
|
|
4706
|
-
description: "
|
|
5964
|
+
description: "TipTap nodes used with tiptap-markdown must define a markdown serialization spec",
|
|
4707
5965
|
category: "integration",
|
|
4708
|
-
rationale: "
|
|
4709
|
-
docsUrl: "https://
|
|
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 via MarkdownNodeSpec (in addStorage or addOptions) so content survives markdown export/import cycles.",
|
|
5967
|
+
docsUrl: "https://github.com/ueberdosis/tiptap-markdown",
|
|
4710
5968
|
recommended: true
|
|
4711
5969
|
},
|
|
4712
5970
|
messages: {
|
|
4713
|
-
|
|
4714
|
-
}
|
|
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. Add a MarkdownNodeSpec to addStorage or addOptions."
|
|
5972
|
+
},
|
|
5973
|
+
schema: []
|
|
4715
5974
|
},
|
|
4716
5975
|
create(context) {
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
return
|
|
5976
|
+
let importsTiptapCore = false;
|
|
5977
|
+
let importsTiptapMarkdown = false;
|
|
5978
|
+
const nodeCreateCalls = [];
|
|
5979
|
+
function hasMarkdownSpec(configObj) {
|
|
5980
|
+
if (configObj?.type !== "ObjectExpression") return false;
|
|
5981
|
+
let found = false;
|
|
5982
|
+
walk(configObj, (node) => {
|
|
5983
|
+
if (found) return false;
|
|
5984
|
+
if (node?.type === "Property") {
|
|
5985
|
+
const keyName = node.key?.type === "Identifier" ? node.key.name : node.key?.type === "Literal" ? String(node.key.value) : null;
|
|
5986
|
+
if (keyName === "markdown") {
|
|
5987
|
+
found = true;
|
|
5988
|
+
return false;
|
|
5989
|
+
}
|
|
5990
|
+
}
|
|
5991
|
+
if (node?.type === "Identifier" && node.name === "MarkdownNodeSpec") {
|
|
5992
|
+
found = true;
|
|
5993
|
+
return false;
|
|
5994
|
+
}
|
|
5995
|
+
});
|
|
5996
|
+
return found;
|
|
4722
5997
|
}
|
|
4723
5998
|
return {
|
|
5999
|
+
ImportDeclaration(node) {
|
|
6000
|
+
const src = node?.source?.value ?? "";
|
|
6001
|
+
if (src.startsWith("@tiptap/core")) importsTiptapCore = true;
|
|
6002
|
+
if (src === "tiptap-markdown" || src === "@tiptap/extension-markdown") {
|
|
6003
|
+
importsTiptapMarkdown = true;
|
|
6004
|
+
}
|
|
6005
|
+
},
|
|
4724
6006
|
CallExpression(node) {
|
|
4725
|
-
if (
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
6007
|
+
if (node.callee?.type === "MemberExpression" && node.callee.property?.name === "create") {
|
|
6008
|
+
const configArg = node.arguments?.[0];
|
|
6009
|
+
if (configArg?.type === "ObjectExpression") {
|
|
6010
|
+
nodeCreateCalls.push({ callNode: node, configObj: configArg });
|
|
6011
|
+
}
|
|
6012
|
+
}
|
|
6013
|
+
},
|
|
6014
|
+
// Also check for MarkdownNodeSpec at program level
|
|
6015
|
+
"Program:exit"() {
|
|
6016
|
+
if (!importsTiptapCore || !importsTiptapMarkdown) return;
|
|
6017
|
+
for (const { callNode, configObj } of nodeCreateCalls) {
|
|
6018
|
+
if (!hasMarkdownSpec(configObj)) {
|
|
6019
|
+
context.report({ node: callNode, messageId: "missingMarkdownNodeSpec" });
|
|
6020
|
+
}
|
|
4735
6021
|
}
|
|
4736
6022
|
}
|
|
4737
6023
|
};
|
|
4738
6024
|
}
|
|
4739
6025
|
};
|
|
4740
|
-
var
|
|
6026
|
+
var tiptapTiptapMarkdownMissingNodeSpecRule = rule82;
|
|
4741
6027
|
|
|
4742
6028
|
// src/plugin/index.ts
|
|
4743
6029
|
var plugin = {
|
|
@@ -4780,6 +6066,18 @@ var plugin = {
|
|
|
4780
6066
|
"firebase-rtdb-listener-error-not-handled": firebaseRtdbListenerErrorNotHandledRule,
|
|
4781
6067
|
"firebase-effect-deps-whole-user-object": firebaseEffectDepsWholeUserObjectRule,
|
|
4782
6068
|
"firebase-rtdb-write-promise-not-handled": firebaseRtdbWritePromiseNotHandledRule,
|
|
6069
|
+
"firebase-firestore-rules-expired": firebaseFirestoreRulesExpiredRule,
|
|
6070
|
+
"firebase-id-token-cookie-flags": firebaseIdTokenCookieFlagsRule,
|
|
6071
|
+
"firebase-middleware-token-not-verified": firebaseMiddlewareTokenNotVerifiedRule,
|
|
6072
|
+
"firebase-hardcoded-user-id": firebaseHardcodedUserIdRule,
|
|
6073
|
+
"firebase-auth-user-not-found-disclosure": firebaseAuthUserNotFoundDisclosureRule,
|
|
6074
|
+
"firebase-signup-password-confirm": firebaseSignupPasswordConfirmRule,
|
|
6075
|
+
"firebase-use-array-union-remove": firebaseUseArrayUnionRemoveRule,
|
|
6076
|
+
"firebase-duplicate-initialize-app": firebaseDuplicateInitializeAppRule,
|
|
6077
|
+
"firebase-onSnapshot-async-throw": firebaseOnSnapshotAsyncThrowRule,
|
|
6078
|
+
"firebase-onSnapshot-missing-error-callback": firebaseOnSnapshotMissingErrorCallbackRule,
|
|
6079
|
+
"firebase-firestore-document-size-guard": firebaseFirestoreDocumentSizeGuardRule,
|
|
6080
|
+
"firebase-use-timestamp-now": firebaseUseTimestampNowRule,
|
|
4783
6081
|
"lovable-no-client-side-secret-fetch": lovableNoClientSideSecretFetchRule,
|
|
4784
6082
|
"lovable-paid-flag-without-edge-function": lovablePaidFlagWithoutEdgeFunctionRule,
|
|
4785
6083
|
"lovable-expiry-column-never-checked": lovableExpiryColumnNeverCheckedRule,
|
|
@@ -4801,7 +6099,18 @@ var plugin = {
|
|
|
4801
6099
|
"openai-cua-no-blind-safety-check-ack": openaiCuaNoBlindSafetyCheckAckRule,
|
|
4802
6100
|
"openai-cua-retry-transient-turn-errors": openaiCuaRetryTransientTurnErrorsRule,
|
|
4803
6101
|
"openai-cua-check-response-status-incomplete": openaiCuaCheckResponseStatusIncompleteRule,
|
|
4804
|
-
"openai-cua-set-safety-identifier": openaiCuaSetSafetyIdentifierRule
|
|
6102
|
+
"openai-cua-set-safety-identifier": openaiCuaSetSafetyIdentifierRule,
|
|
6103
|
+
"tiptap-upload-validate-fn-void": tiptapUploadValidateFnVoidRule,
|
|
6104
|
+
"tiptap-script-src-hardcoded-api-key": tiptapScriptSrcHardcodedApiKeyRule,
|
|
6105
|
+
"tiptap-dynamic-script-no-sri": tiptapDynamicScriptNoSriRule,
|
|
6106
|
+
"tiptap-addAttributes-missing-renderHTML": tiptapAddAttributesMissingRenderHTMLRule,
|
|
6107
|
+
"tiptap-appendTransaction-add-to-history": tiptapAppendTransactionAddToHistoryRule,
|
|
6108
|
+
"tiptap-appendTransaction-full-scan": tiptapAppendTransactionFullScanRule,
|
|
6109
|
+
"tiptap-atom-node-wrap-in": tiptapAtomNodeWrapInRule,
|
|
6110
|
+
"tiptap-twitter-url-regex": tiptapTwitterUrlRegexRule,
|
|
6111
|
+
"tiptap-drop-handler-pos-precedence": tiptapDropHandlerPosPrecedenceRule,
|
|
6112
|
+
"tiptap-prefer-table-kit": tiptapPreferTableKitRule,
|
|
6113
|
+
"tiptap-tiptap-markdown-missing-node-spec": tiptapTiptapMarkdownMissingNodeSpecRule
|
|
4805
6114
|
}
|
|
4806
6115
|
};
|
|
4807
6116
|
var plugin_default = plugin;
|