oxlint-plugin-react-doctor 0.5.5 → 0.5.6-dev.0a7edbd
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/dist/index.d.ts +1293 -0
- package/dist/index.js +1118 -148
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -323,9 +323,18 @@ const BROWSER_ARTIFACT_PATH_PATTERNS = [
|
|
|
323
323
|
const AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN = /\b(?:exec|execSync|spawn|child_process|eval|new Function|vm\.run|readFile|writeFile|fs\.read|fs\.write|fetch|axios|http\.request|sandbox|runCode|executeCode)\b/;
|
|
324
324
|
//#endregion
|
|
325
325
|
//#region src/plugin/rules/security-scan/utils/is-browser-artifact-path.ts
|
|
326
|
-
const
|
|
326
|
+
const SERVER_BUILD_ROOT_SEGMENTS = new Set([".next", ".output"]);
|
|
327
|
+
const isNonShippedBuildArtifactPath = (relativePath) => {
|
|
328
|
+
const segments = relativePath.split("/");
|
|
329
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
330
|
+
if (!SERVER_BUILD_ROOT_SEGMENTS.has(segments[index])) continue;
|
|
331
|
+
if (segments[index] === ".next" && segments[index + 1] === "dev") return true;
|
|
332
|
+
if (segments[index + 1] === "server" && index + 2 < segments.length) return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
};
|
|
327
336
|
const isBrowserArtifactPath = (relativePath, isGeneratedBundle) => {
|
|
328
|
-
if (
|
|
337
|
+
if (isNonShippedBuildArtifactPath(relativePath)) return false;
|
|
329
338
|
if (isGeneratedBundle) return true;
|
|
330
339
|
if (relativePath.endsWith(".map")) return true;
|
|
331
340
|
return BROWSER_ARTIFACT_PATH_PATTERNS.some((pattern) => pattern.test(relativePath));
|
|
@@ -1108,6 +1117,11 @@ const getImportedNameFromModule = (contextNode, localIdentifierName, moduleSourc
|
|
|
1108
1117
|
if (info.source !== moduleSource) return null;
|
|
1109
1118
|
return info.imported;
|
|
1110
1119
|
};
|
|
1120
|
+
const getImportSourceForName = (contextNode, localIdentifierName) => {
|
|
1121
|
+
const lookup = getImportLookup(contextNode);
|
|
1122
|
+
if (!lookup) return null;
|
|
1123
|
+
return lookup.get(localIdentifierName)?.source ?? null;
|
|
1124
|
+
};
|
|
1111
1125
|
//#endregion
|
|
1112
1126
|
//#region src/plugin/utils/find-variable-initializer.ts
|
|
1113
1127
|
const FUNCTION_LIKE_TYPES$1 = new Set([
|
|
@@ -1847,7 +1861,7 @@ const anchorAmbiguousText = defineRule({
|
|
|
1847
1861
|
});
|
|
1848
1862
|
//#endregion
|
|
1849
1863
|
//#region src/plugin/rules/a11y/anchor-has-content.ts
|
|
1850
|
-
const MESSAGE$
|
|
1864
|
+
const MESSAGE$57 = "Blind users can't follow this link because screen readers announce nothing, so add visible text, `aria-label`, or `aria-labelledby`.";
|
|
1851
1865
|
const anchorHasContent = defineRule({
|
|
1852
1866
|
id: "anchor-has-content",
|
|
1853
1867
|
title: "Anchor has no content",
|
|
@@ -1863,7 +1877,7 @@ const anchorHasContent = defineRule({
|
|
|
1863
1877
|
for (const attribute of ["title", "aria-label"]) if (hasJsxPropIgnoreCase(opening.attributes, attribute)) return;
|
|
1864
1878
|
context.report({
|
|
1865
1879
|
node: opening.name,
|
|
1866
|
-
message: MESSAGE$
|
|
1880
|
+
message: MESSAGE$57
|
|
1867
1881
|
});
|
|
1868
1882
|
} })
|
|
1869
1883
|
});
|
|
@@ -2257,7 +2271,7 @@ const parseJsxValue = (value) => {
|
|
|
2257
2271
|
};
|
|
2258
2272
|
//#endregion
|
|
2259
2273
|
//#region src/plugin/rules/a11y/aria-activedescendant-has-tabindex.ts
|
|
2260
|
-
const MESSAGE$
|
|
2274
|
+
const MESSAGE$56 = "Keyboard users can't focus this element with `aria-activedescendant` because it isn't tabbable, so add `tabIndex={0}`.";
|
|
2261
2275
|
const ariaActivedescendantHasTabindex = defineRule({
|
|
2262
2276
|
id: "aria-activedescendant-has-tabindex",
|
|
2263
2277
|
title: "aria-activedescendant missing tabindex",
|
|
@@ -2275,14 +2289,14 @@ const ariaActivedescendantHasTabindex = defineRule({
|
|
|
2275
2289
|
if (tabIndexValue === null || tabIndexValue >= -1) return;
|
|
2276
2290
|
context.report({
|
|
2277
2291
|
node: node.name,
|
|
2278
|
-
message: MESSAGE$
|
|
2292
|
+
message: MESSAGE$56
|
|
2279
2293
|
});
|
|
2280
2294
|
return;
|
|
2281
2295
|
}
|
|
2282
2296
|
if (isInteractiveElement(tag, node)) return;
|
|
2283
2297
|
context.report({
|
|
2284
2298
|
node: node.name,
|
|
2285
|
-
message: MESSAGE$
|
|
2299
|
+
message: MESSAGE$56
|
|
2286
2300
|
});
|
|
2287
2301
|
} })
|
|
2288
2302
|
});
|
|
@@ -3090,6 +3104,76 @@ const AUTH_FUNCTION_NAMES = new Set([
|
|
|
3090
3104
|
"getAuth",
|
|
3091
3105
|
"validateSession"
|
|
3092
3106
|
]);
|
|
3107
|
+
const AUTH_STRONG_TOKEN_PATTERN = /^auth(?:n|z|ed|enticate[ds]?|enticating|entication|orize[ds]?|orizing|orization|orizer)?$/;
|
|
3108
|
+
const AUTH_STANDALONE_NOUN_TOKENS = new Set([
|
|
3109
|
+
"signedin",
|
|
3110
|
+
"loggedin",
|
|
3111
|
+
"signin"
|
|
3112
|
+
]);
|
|
3113
|
+
const AUTH_ASSERTIVE_VERB_TOKENS = new Set([
|
|
3114
|
+
"require",
|
|
3115
|
+
"ensure",
|
|
3116
|
+
"assert",
|
|
3117
|
+
"verify",
|
|
3118
|
+
"validate",
|
|
3119
|
+
"check",
|
|
3120
|
+
"protect",
|
|
3121
|
+
"enforce",
|
|
3122
|
+
"guard",
|
|
3123
|
+
"gate",
|
|
3124
|
+
"restrict",
|
|
3125
|
+
"is",
|
|
3126
|
+
"has",
|
|
3127
|
+
"can",
|
|
3128
|
+
"must"
|
|
3129
|
+
]);
|
|
3130
|
+
const AUTH_GETTER_VERB_TOKENS = new Set([
|
|
3131
|
+
"get",
|
|
3132
|
+
"fetch",
|
|
3133
|
+
"load",
|
|
3134
|
+
"read",
|
|
3135
|
+
"resolve",
|
|
3136
|
+
"retrieve",
|
|
3137
|
+
"use"
|
|
3138
|
+
]);
|
|
3139
|
+
const AUTH_QUALIFIER_TOKENS = new Set([
|
|
3140
|
+
"current",
|
|
3141
|
+
"my",
|
|
3142
|
+
"own"
|
|
3143
|
+
]);
|
|
3144
|
+
const AUTH_STRONG_NOUN_TOKENS = new Set([
|
|
3145
|
+
"session",
|
|
3146
|
+
"sessions",
|
|
3147
|
+
"login",
|
|
3148
|
+
"admin",
|
|
3149
|
+
"admins",
|
|
3150
|
+
"superadmin",
|
|
3151
|
+
"superuser",
|
|
3152
|
+
"role",
|
|
3153
|
+
"roles",
|
|
3154
|
+
"permission",
|
|
3155
|
+
"permissions",
|
|
3156
|
+
"jwt",
|
|
3157
|
+
"identity",
|
|
3158
|
+
"principal",
|
|
3159
|
+
"credential",
|
|
3160
|
+
"credentials"
|
|
3161
|
+
]);
|
|
3162
|
+
const AUTH_WEAK_NOUN_TOKENS = new Set([
|
|
3163
|
+
"user",
|
|
3164
|
+
"users",
|
|
3165
|
+
"account",
|
|
3166
|
+
"accounts",
|
|
3167
|
+
"token",
|
|
3168
|
+
"tokens",
|
|
3169
|
+
"access",
|
|
3170
|
+
"me",
|
|
3171
|
+
"viewer",
|
|
3172
|
+
"caller",
|
|
3173
|
+
"subject",
|
|
3174
|
+
"scope",
|
|
3175
|
+
"scopes"
|
|
3176
|
+
]);
|
|
3093
3177
|
const GENERIC_AUTH_METHOD_NAMES = new Set(["getUser"]);
|
|
3094
3178
|
const AUTH_OBJECT_PATTERN = /(?:^|[._])(?:auth|authn|authz|clerk|session|jwt|firebase|supabase|nextauth|kinde|workos|stytch|descope|cognito|propelauth|lucia)/i;
|
|
3095
3179
|
const SECRET_PATTERNS = [
|
|
@@ -4187,6 +4271,58 @@ const asyncParallel = defineRule({
|
|
|
4187
4271
|
}
|
|
4188
4272
|
});
|
|
4189
4273
|
//#endregion
|
|
4274
|
+
//#region src/plugin/rules/security/auth-token-in-web-storage.ts
|
|
4275
|
+
const MESSAGE$55 = "Storing an auth token in `localStorage`/`sessionStorage` exposes it to any XSS on the page: JavaScript can read web storage and exfiltrate the token. Keep tokens in an `HttpOnly`, `Secure`, `SameSite` cookie instead.";
|
|
4276
|
+
const STORAGE_NAMES = new Set(["localStorage", "sessionStorage"]);
|
|
4277
|
+
const STORAGE_GLOBALS = new Set([
|
|
4278
|
+
"window",
|
|
4279
|
+
"globalThis",
|
|
4280
|
+
"self"
|
|
4281
|
+
]);
|
|
4282
|
+
const SENSITIVE_KEY_PATTERN = /token|jwt|secret|password|passwd|credential|api[-_]?key|bearer|private[-_]?key/i;
|
|
4283
|
+
const isWebStorageObject = (node) => {
|
|
4284
|
+
if (isNodeOfType(node, "Identifier")) return STORAGE_NAMES.has(node.name);
|
|
4285
|
+
if (isNodeOfType(node, "MemberExpression") && !node.computed && isNodeOfType(node.object, "Identifier") && STORAGE_GLOBALS.has(node.object.name) && isNodeOfType(node.property, "Identifier")) return STORAGE_NAMES.has(node.property.name);
|
|
4286
|
+
return false;
|
|
4287
|
+
};
|
|
4288
|
+
const staticMemberName = (member) => {
|
|
4289
|
+
if (!member.computed && isNodeOfType(member.property, "Identifier")) return member.property.name;
|
|
4290
|
+
if (member.computed && isNodeOfType(member.property, "Literal") && typeof member.property.value === "string") return member.property.value;
|
|
4291
|
+
return null;
|
|
4292
|
+
};
|
|
4293
|
+
const authTokenInWebStorage = defineRule({
|
|
4294
|
+
id: "auth-token-in-web-storage",
|
|
4295
|
+
title: "Auth token in web storage",
|
|
4296
|
+
severity: "warn",
|
|
4297
|
+
recommendation: "Don't persist auth tokens (JWTs, access/refresh tokens, secrets) in `localStorage`/`sessionStorage`; they're readable by any XSS. Use an `HttpOnly` cookie set by the server.",
|
|
4298
|
+
create: (context) => ({
|
|
4299
|
+
CallExpression(node) {
|
|
4300
|
+
const callee = node.callee;
|
|
4301
|
+
if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
|
|
4302
|
+
if (!isNodeOfType(callee.property, "Identifier") || callee.property.name !== "setItem") return;
|
|
4303
|
+
if (!isWebStorageObject(callee.object)) return;
|
|
4304
|
+
const keyArgument = node.arguments?.[0];
|
|
4305
|
+
if (!keyArgument || !isNodeOfType(keyArgument, "Literal") || typeof keyArgument.value !== "string") return;
|
|
4306
|
+
if (!SENSITIVE_KEY_PATTERN.test(keyArgument.value)) return;
|
|
4307
|
+
context.report({
|
|
4308
|
+
node,
|
|
4309
|
+
message: MESSAGE$55
|
|
4310
|
+
});
|
|
4311
|
+
},
|
|
4312
|
+
AssignmentExpression(node) {
|
|
4313
|
+
const target = node.left;
|
|
4314
|
+
if (!isNodeOfType(target, "MemberExpression")) return;
|
|
4315
|
+
if (!isWebStorageObject(target.object)) return;
|
|
4316
|
+
const propertyName = staticMemberName(target);
|
|
4317
|
+
if (!propertyName || !SENSITIVE_KEY_PATTERN.test(propertyName)) return;
|
|
4318
|
+
context.report({
|
|
4319
|
+
node: target,
|
|
4320
|
+
message: MESSAGE$55
|
|
4321
|
+
});
|
|
4322
|
+
}
|
|
4323
|
+
})
|
|
4324
|
+
});
|
|
4325
|
+
//#endregion
|
|
4190
4326
|
//#region src/plugin/rules/a11y/autocomplete-valid.ts
|
|
4191
4327
|
const buildMessage$25 = (value) => `Users who rely on autofill can't fill this field because \`${value}\` isn't a known token, so use a valid \`autoComplete\` token.`;
|
|
4192
4328
|
const AUTOFILL_TOKENS = new Set([
|
|
@@ -4558,7 +4694,7 @@ const isPureEventBlockerHandler = (attribute) => {
|
|
|
4558
4694
|
//#endregion
|
|
4559
4695
|
//#region src/plugin/rules/a11y/click-events-have-key-events.ts
|
|
4560
4696
|
const PRESENTATION_ROLES$1 = new Set(["presentation", "none"]);
|
|
4561
|
-
const MESSAGE$
|
|
4697
|
+
const MESSAGE$54 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
|
|
4562
4698
|
const KEY_HANDLERS = [
|
|
4563
4699
|
"onKeyUp",
|
|
4564
4700
|
"onKeyDown",
|
|
@@ -4590,7 +4726,7 @@ const clickEventsHaveKeyEvents = defineRule({
|
|
|
4590
4726
|
if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler))) return;
|
|
4591
4727
|
context.report({
|
|
4592
4728
|
node: node.name,
|
|
4593
|
-
message: MESSAGE$
|
|
4729
|
+
message: MESSAGE$54
|
|
4594
4730
|
});
|
|
4595
4731
|
} };
|
|
4596
4732
|
}
|
|
@@ -4705,7 +4841,7 @@ const isReactComponentName = (name) => {
|
|
|
4705
4841
|
};
|
|
4706
4842
|
//#endregion
|
|
4707
4843
|
//#region src/plugin/rules/a11y/control-has-associated-label.ts
|
|
4708
|
-
const MESSAGE$
|
|
4844
|
+
const MESSAGE$53 = "Blind users can't tell what this control does because screen readers find no label, so add visible text, `aria-label`, or `aria-labelledby`.";
|
|
4709
4845
|
const DEFAULT_IGNORE_ELEMENTS = ["link", "canvas"];
|
|
4710
4846
|
const DEFAULT_LABELLING_PROPS = [
|
|
4711
4847
|
"alt",
|
|
@@ -4866,7 +5002,7 @@ const controlHasAssociatedLabel = defineRule({
|
|
|
4866
5002
|
for (const child of node.children) if (checkChildForLabel(child, 1, checkContext)) return;
|
|
4867
5003
|
context.report({
|
|
4868
5004
|
node: opening,
|
|
4869
|
-
message: MESSAGE$
|
|
5005
|
+
message: MESSAGE$53
|
|
4870
5006
|
});
|
|
4871
5007
|
} };
|
|
4872
5008
|
}
|
|
@@ -5292,6 +5428,38 @@ const noVagueButtonLabel = defineRule({
|
|
|
5292
5428
|
} })
|
|
5293
5429
|
});
|
|
5294
5430
|
//#endregion
|
|
5431
|
+
//#region src/plugin/utils/has-jsx-spread-attribute.ts
|
|
5432
|
+
const hasJsxSpreadAttribute$1 = (attributes) => attributes.some((attribute) => isNodeOfType(attribute, "JSXSpreadAttribute"));
|
|
5433
|
+
//#endregion
|
|
5434
|
+
//#region src/plugin/rules/a11y/dialog-has-accessible-name.ts
|
|
5435
|
+
const MESSAGE$52 = "This dialog has no accessible name, so screen readers announce it as just “dialog.” Add `aria-label` or point `aria-labelledby` at its heading.";
|
|
5436
|
+
const DIALOG_ROLES = new Set(["dialog", "alertdialog"]);
|
|
5437
|
+
const NAME_PROVIDING_ATTRIBUTES = [
|
|
5438
|
+
"aria-label",
|
|
5439
|
+
"aria-labelledby",
|
|
5440
|
+
"title"
|
|
5441
|
+
];
|
|
5442
|
+
const dialogHasAccessibleName = defineRule({
|
|
5443
|
+
id: "dialog-has-accessible-name",
|
|
5444
|
+
title: "Dialog without accessible name",
|
|
5445
|
+
severity: "warn",
|
|
5446
|
+
recommendation: "Give every `<dialog>` / `role=\"dialog\"` an accessible name with `aria-label` or `aria-labelledby` (referencing the dialog's title element).",
|
|
5447
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
5448
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
5449
|
+
const tagName = node.name.name;
|
|
5450
|
+
if (tagName[0] !== tagName[0]?.toLowerCase()) return;
|
|
5451
|
+
const roleAttribute = hasJsxPropIgnoreCase(node.attributes, "role");
|
|
5452
|
+
const roleValue = roleAttribute ? getJsxPropStringValue(roleAttribute) : null;
|
|
5453
|
+
if (!(tagName === "dialog" || roleValue !== null && DIALOG_ROLES.has(roleValue))) return;
|
|
5454
|
+
if (hasJsxSpreadAttribute$1(node.attributes)) return;
|
|
5455
|
+
if (NAME_PROVIDING_ATTRIBUTES.some((attribute) => hasJsxPropIgnoreCase(node.attributes, attribute))) return;
|
|
5456
|
+
context.report({
|
|
5457
|
+
node: node.name,
|
|
5458
|
+
message: MESSAGE$52
|
|
5459
|
+
});
|
|
5460
|
+
} })
|
|
5461
|
+
});
|
|
5462
|
+
//#endregion
|
|
5295
5463
|
//#region src/plugin/utils/is-es5-component.ts
|
|
5296
5464
|
const PRAGMA$2 = "React";
|
|
5297
5465
|
const CREATE_CLASS = "createReactClass";
|
|
@@ -5326,7 +5494,7 @@ const isEs6Component = (node) => {
|
|
|
5326
5494
|
};
|
|
5327
5495
|
//#endregion
|
|
5328
5496
|
//#region src/plugin/rules/react-builtins/display-name.ts
|
|
5329
|
-
const MESSAGE$
|
|
5497
|
+
const MESSAGE$51 = "This component shows up as Anonymous in React DevTools because it has no `displayName`.";
|
|
5330
5498
|
const DEFAULT_ADDITIONAL_HOCS = [
|
|
5331
5499
|
"observer",
|
|
5332
5500
|
"lazy",
|
|
@@ -5529,7 +5697,7 @@ const displayName = defineRule({
|
|
|
5529
5697
|
const reportAt = (node) => {
|
|
5530
5698
|
context.report({
|
|
5531
5699
|
node,
|
|
5532
|
-
message: MESSAGE$
|
|
5700
|
+
message: MESSAGE$51
|
|
5533
5701
|
});
|
|
5534
5702
|
};
|
|
5535
5703
|
return {
|
|
@@ -7677,7 +7845,7 @@ const forbidElements = defineRule({
|
|
|
7677
7845
|
});
|
|
7678
7846
|
//#endregion
|
|
7679
7847
|
//#region src/plugin/rules/react-builtins/forward-ref-uses-ref.ts
|
|
7680
|
-
const MESSAGE$
|
|
7848
|
+
const MESSAGE$50 = "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`.";
|
|
7681
7849
|
const forwardRefUsesRef = defineRule({
|
|
7682
7850
|
id: "forward-ref-uses-ref",
|
|
7683
7851
|
title: "forwardRef without ref parameter",
|
|
@@ -7697,7 +7865,7 @@ const forwardRefUsesRef = defineRule({
|
|
|
7697
7865
|
if (isNodeOfType(onlyParam, "RestElement")) return;
|
|
7698
7866
|
context.report({
|
|
7699
7867
|
node: inner,
|
|
7700
|
-
message: MESSAGE$
|
|
7868
|
+
message: MESSAGE$50
|
|
7701
7869
|
});
|
|
7702
7870
|
} })
|
|
7703
7871
|
});
|
|
@@ -7734,7 +7902,7 @@ const gitProviderUrlInjectionRisk = defineRule({
|
|
|
7734
7902
|
});
|
|
7735
7903
|
//#endregion
|
|
7736
7904
|
//#region src/plugin/rules/a11y/heading-has-content.ts
|
|
7737
|
-
const MESSAGE$
|
|
7905
|
+
const MESSAGE$49 = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`.";
|
|
7738
7906
|
const DEFAULT_HEADING_TAGS = [
|
|
7739
7907
|
"h1",
|
|
7740
7908
|
"h2",
|
|
@@ -7767,7 +7935,7 @@ const headingHasContent = defineRule({
|
|
|
7767
7935
|
if (isHiddenFromScreenReader(node, context.settings)) return;
|
|
7768
7936
|
context.report({
|
|
7769
7937
|
node,
|
|
7770
|
-
message: MESSAGE$
|
|
7938
|
+
message: MESSAGE$49
|
|
7771
7939
|
});
|
|
7772
7940
|
} };
|
|
7773
7941
|
}
|
|
@@ -7905,7 +8073,7 @@ const hooksNoNanInDeps = defineRule({
|
|
|
7905
8073
|
});
|
|
7906
8074
|
//#endregion
|
|
7907
8075
|
//#region src/plugin/rules/a11y/html-has-lang.ts
|
|
7908
|
-
const MESSAGE$
|
|
8076
|
+
const MESSAGE$48 = "Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.";
|
|
7909
8077
|
const resolveSettings$38 = (settings) => {
|
|
7910
8078
|
const reactDoctor = settings?.["react-doctor"];
|
|
7911
8079
|
return { htmlTags: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.htmlHasLang ?? {} : {}).htmlTags ?? ["html"] };
|
|
@@ -7953,7 +8121,7 @@ const htmlHasLang = defineRule({
|
|
|
7953
8121
|
if (!lang) {
|
|
7954
8122
|
context.report({
|
|
7955
8123
|
node: node.name,
|
|
7956
|
-
message: MESSAGE$
|
|
8124
|
+
message: MESSAGE$48
|
|
7957
8125
|
});
|
|
7958
8126
|
return;
|
|
7959
8127
|
}
|
|
@@ -7961,13 +8129,13 @@ const htmlHasLang = defineRule({
|
|
|
7961
8129
|
if (verdict === "missing" || verdict === "empty") {
|
|
7962
8130
|
context.report({
|
|
7963
8131
|
node: lang,
|
|
7964
|
-
message: MESSAGE$
|
|
8132
|
+
message: MESSAGE$48
|
|
7965
8133
|
});
|
|
7966
8134
|
return;
|
|
7967
8135
|
}
|
|
7968
8136
|
if (hasSpread && !lang) context.report({
|
|
7969
8137
|
node: node.name,
|
|
7970
|
-
message: MESSAGE$
|
|
8138
|
+
message: MESSAGE$48
|
|
7971
8139
|
});
|
|
7972
8140
|
} };
|
|
7973
8141
|
}
|
|
@@ -8181,7 +8349,7 @@ const htmlNoNestedInteractive = defineRule({
|
|
|
8181
8349
|
});
|
|
8182
8350
|
//#endregion
|
|
8183
8351
|
//#region src/plugin/rules/a11y/iframe-has-title.ts
|
|
8184
|
-
const MESSAGE$
|
|
8352
|
+
const MESSAGE$47 = "Screen reader users cannot identify this `<iframe>` because it has no title. Add a `title` that describes its content.";
|
|
8185
8353
|
const evaluateTitleValue = (value) => {
|
|
8186
8354
|
if (!value) return "missing";
|
|
8187
8355
|
if (isNodeOfType(value, "Literal")) {
|
|
@@ -8221,14 +8389,14 @@ const iframeHasTitle = defineRule({
|
|
|
8221
8389
|
if (!titleAttr) {
|
|
8222
8390
|
if (hasSpread || tag === "iframe") context.report({
|
|
8223
8391
|
node: node.name,
|
|
8224
|
-
message: MESSAGE$
|
|
8392
|
+
message: MESSAGE$47
|
|
8225
8393
|
});
|
|
8226
8394
|
return;
|
|
8227
8395
|
}
|
|
8228
8396
|
const verdict = evaluateTitleValue(titleAttr.value);
|
|
8229
8397
|
if (verdict === "missing" || verdict === "empty") context.report({
|
|
8230
8398
|
node: titleAttr,
|
|
8231
|
-
message: MESSAGE$
|
|
8399
|
+
message: MESSAGE$47
|
|
8232
8400
|
});
|
|
8233
8401
|
} })
|
|
8234
8402
|
});
|
|
@@ -8332,7 +8500,7 @@ const iframeMissingSandbox = defineRule({
|
|
|
8332
8500
|
});
|
|
8333
8501
|
//#endregion
|
|
8334
8502
|
//#region src/plugin/rules/a11y/img-redundant-alt.ts
|
|
8335
|
-
const MESSAGE$
|
|
8503
|
+
const MESSAGE$46 = "Screen reader users hear \"image\" or \"photo\" twice because they already announce it, so describe what the image shows instead.";
|
|
8336
8504
|
const DEFAULT_COMPONENTS = ["img"];
|
|
8337
8505
|
const DEFAULT_REDUNDANT_WORDS = [
|
|
8338
8506
|
"image",
|
|
@@ -8397,7 +8565,7 @@ const imgRedundantAlt = defineRule({
|
|
|
8397
8565
|
if (!altAttribute) return;
|
|
8398
8566
|
if (altValueRedundant(altAttribute, settings.words)) context.report({
|
|
8399
8567
|
node: altAttribute,
|
|
8400
|
-
message: MESSAGE$
|
|
8568
|
+
message: MESSAGE$46
|
|
8401
8569
|
});
|
|
8402
8570
|
} };
|
|
8403
8571
|
}
|
|
@@ -8495,6 +8663,136 @@ const insecureCryptoRisk = defineRule({
|
|
|
8495
8663
|
}
|
|
8496
8664
|
});
|
|
8497
8665
|
//#endregion
|
|
8666
|
+
//#region src/plugin/rules/security-scan/utils/find-matching-bracket.ts
|
|
8667
|
+
const findMatchingBracket = (content, openIndex) => {
|
|
8668
|
+
const open = content[openIndex];
|
|
8669
|
+
const close = open === "(" ? ")" : open === "{" ? "}" : open === "[" ? "]" : "";
|
|
8670
|
+
if (close === "") return -1;
|
|
8671
|
+
let depth = 0;
|
|
8672
|
+
let stringDelimiter = null;
|
|
8673
|
+
for (let index = openIndex; index < content.length; index += 1) {
|
|
8674
|
+
const character = content[index];
|
|
8675
|
+
if (stringDelimiter !== null) {
|
|
8676
|
+
if (character === "\\") index += 1;
|
|
8677
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
8678
|
+
continue;
|
|
8679
|
+
}
|
|
8680
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8681
|
+
else if (character === open) depth += 1;
|
|
8682
|
+
else if (character === close) {
|
|
8683
|
+
depth -= 1;
|
|
8684
|
+
if (depth === 0) return index;
|
|
8685
|
+
}
|
|
8686
|
+
}
|
|
8687
|
+
return -1;
|
|
8688
|
+
};
|
|
8689
|
+
//#endregion
|
|
8690
|
+
//#region src/plugin/rules/security-scan/insecure-session-cookie.ts
|
|
8691
|
+
const AUTH_COOKIE_NAME_TOKEN = `(?<![A-Za-z0-9])(?:session|sess|sid|connect\\.sid|auth|jwt|access[_-]?token|refresh[_-]?token|id[_-]?token)(?![A-Za-z0-9])`;
|
|
8692
|
+
const AUTH_COOKIE_NAME_LITERAL = `[\`"'][^\`"']*?${AUTH_COOKIE_NAME_TOKEN}[^\`"']*[\`"']`;
|
|
8693
|
+
const AUTH_COOKIE_SET_CALL_PATTERN = new RegExp(`(?:\\.cookies\\.set|cookies\\(\\s*\\)\\.set|\\.cookie)\\s*\\(\\s*${AUTH_COOKIE_NAME_LITERAL}`, "gi");
|
|
8694
|
+
const HTTP_ONLY_DISABLED_PATTERN = /httpOnly\s*:\s*false\b/i;
|
|
8695
|
+
const STRING_LITERAL_PATTERN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
8696
|
+
const blankStringContents = (text) => {
|
|
8697
|
+
const characters = text.split("");
|
|
8698
|
+
let index = 0;
|
|
8699
|
+
let stringDelimiter = null;
|
|
8700
|
+
while (index < text.length) {
|
|
8701
|
+
const character = text[index];
|
|
8702
|
+
if (stringDelimiter !== null) {
|
|
8703
|
+
if (character === "\\") {
|
|
8704
|
+
index += 2;
|
|
8705
|
+
continue;
|
|
8706
|
+
}
|
|
8707
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
8708
|
+
else if (character !== "\n") characters[index] = " ";
|
|
8709
|
+
index += 1;
|
|
8710
|
+
continue;
|
|
8711
|
+
}
|
|
8712
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8713
|
+
index += 1;
|
|
8714
|
+
}
|
|
8715
|
+
return characters.join("");
|
|
8716
|
+
};
|
|
8717
|
+
const COOKIE_CONFIG_OPENER_PATTERN = /cookie\s*:\s*\{/gi;
|
|
8718
|
+
const CLIENT_AUTH_COOKIE_WRITE_PATTERN = new RegExp(`document\\.cookie\\s*=\\s*[\`"'][^\`"'=;]*?${AUTH_COOKIE_NAME_TOKEN}[^\`"'=;]*=`, "gi");
|
|
8719
|
+
const countTopLevelArguments = (argumentsSource) => {
|
|
8720
|
+
if (argumentsSource.trim().length === 0) return 0;
|
|
8721
|
+
let depth = 0;
|
|
8722
|
+
let stringDelimiter = null;
|
|
8723
|
+
let count = 1;
|
|
8724
|
+
for (let index = 0; index < argumentsSource.length; index += 1) {
|
|
8725
|
+
const character = argumentsSource[index];
|
|
8726
|
+
if (stringDelimiter !== null) {
|
|
8727
|
+
if (character === "\\") index += 1;
|
|
8728
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
8729
|
+
continue;
|
|
8730
|
+
}
|
|
8731
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8732
|
+
else if (character === "(" || character === "[" || character === "{") depth += 1;
|
|
8733
|
+
else if (character === ")" || character === "]" || character === "}") depth -= 1;
|
|
8734
|
+
else if (character === "," && depth === 0) count += 1;
|
|
8735
|
+
}
|
|
8736
|
+
return count;
|
|
8737
|
+
};
|
|
8738
|
+
const addMatchFindings = (content, pattern, message, isInsecure, findings) => {
|
|
8739
|
+
pattern.lastIndex = 0;
|
|
8740
|
+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
|
|
8741
|
+
if (!isInsecure(match.index, match[0])) continue;
|
|
8742
|
+
const location = getLocationAtIndex(content, match.index);
|
|
8743
|
+
findings.push({
|
|
8744
|
+
message,
|
|
8745
|
+
line: location.line,
|
|
8746
|
+
column: location.column
|
|
8747
|
+
});
|
|
8748
|
+
}
|
|
8749
|
+
};
|
|
8750
|
+
const insecureSessionCookie = defineRule({
|
|
8751
|
+
id: "insecure-session-cookie",
|
|
8752
|
+
title: "Auth cookie missing HttpOnly protection",
|
|
8753
|
+
severity: "warn",
|
|
8754
|
+
recommendation: "Set auth/session cookies server-side with `httpOnly: true`, `secure: true`, and `sameSite`. Cookies set via `document.cookie` or with `httpOnly: false` are readable by any XSS payload and can be stolen.",
|
|
8755
|
+
scan: (file) => {
|
|
8756
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
8757
|
+
const content = getScannableContent(file);
|
|
8758
|
+
if (!/cookie/i.test(content)) return [];
|
|
8759
|
+
const findings = [];
|
|
8760
|
+
const message = "An auth/session cookie is exposed to JavaScript (set via document.cookie, with httpOnly: false, or without cookie options), letting an XSS payload steal it.";
|
|
8761
|
+
AUTH_COOKIE_SET_CALL_PATTERN.lastIndex = 0;
|
|
8762
|
+
for (let match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content); match !== null; match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content)) {
|
|
8763
|
+
const openParenIndex = match.index + match[0].lastIndexOf("(");
|
|
8764
|
+
const closeParenIndex = findMatchingBracket(content, openParenIndex);
|
|
8765
|
+
if (closeParenIndex < 0) continue;
|
|
8766
|
+
const argumentsSource = content.slice(openParenIndex + 1, closeParenIndex);
|
|
8767
|
+
const hasNoOptions = countTopLevelArguments(argumentsSource) < 3;
|
|
8768
|
+
const argumentsWithoutStrings = argumentsSource.replace(STRING_LITERAL_PATTERN, "");
|
|
8769
|
+
if (!hasNoOptions && !HTTP_ONLY_DISABLED_PATTERN.test(argumentsWithoutStrings)) continue;
|
|
8770
|
+
const location = getLocationAtIndex(content, match.index);
|
|
8771
|
+
findings.push({
|
|
8772
|
+
message,
|
|
8773
|
+
line: location.line,
|
|
8774
|
+
column: location.column
|
|
8775
|
+
});
|
|
8776
|
+
}
|
|
8777
|
+
const blankedContent = blankStringContents(content);
|
|
8778
|
+
COOKIE_CONFIG_OPENER_PATTERN.lastIndex = 0;
|
|
8779
|
+
for (let match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent); match !== null; match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent)) {
|
|
8780
|
+
const braceIndex = match.index + match[0].length - 1;
|
|
8781
|
+
const closeBraceIndex = findMatchingBracket(blankedContent, braceIndex);
|
|
8782
|
+
const block = closeBraceIndex >= 0 ? blankedContent.slice(braceIndex, closeBraceIndex) : blankedContent.slice(braceIndex, braceIndex + 400);
|
|
8783
|
+
if (!HTTP_ONLY_DISABLED_PATTERN.test(block)) continue;
|
|
8784
|
+
const location = getLocationAtIndex(blankedContent, match.index);
|
|
8785
|
+
findings.push({
|
|
8786
|
+
message,
|
|
8787
|
+
line: location.line,
|
|
8788
|
+
column: location.column
|
|
8789
|
+
});
|
|
8790
|
+
}
|
|
8791
|
+
addMatchFindings(content, CLIENT_AUTH_COOKIE_WRITE_PATTERN, message, () => true, findings);
|
|
8792
|
+
return findings;
|
|
8793
|
+
}
|
|
8794
|
+
});
|
|
8795
|
+
//#endregion
|
|
8498
8796
|
//#region src/plugin/constants/event-handlers.ts
|
|
8499
8797
|
const MOUSE_EVENT_HANDLERS = [
|
|
8500
8798
|
"onClick",
|
|
@@ -10624,7 +10922,7 @@ const jsxMaxDepth = defineRule({
|
|
|
10624
10922
|
});
|
|
10625
10923
|
//#endregion
|
|
10626
10924
|
//#region src/plugin/rules/react-builtins/jsx-no-comment-textnodes.ts
|
|
10627
|
-
const MESSAGE$
|
|
10925
|
+
const MESSAGE$45 = "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX.";
|
|
10628
10926
|
const LITERAL_TEXT_TAGS = new Set([
|
|
10629
10927
|
"code",
|
|
10630
10928
|
"pre",
|
|
@@ -10660,7 +10958,7 @@ const jsxNoCommentTextnodes = defineRule({
|
|
|
10660
10958
|
if (isInsideLiteralTextTag(node)) return;
|
|
10661
10959
|
context.report({
|
|
10662
10960
|
node,
|
|
10663
|
-
message: MESSAGE$
|
|
10961
|
+
message: MESSAGE$45
|
|
10664
10962
|
});
|
|
10665
10963
|
} })
|
|
10666
10964
|
});
|
|
@@ -10691,7 +10989,7 @@ const isInsideFunctionScope = (node) => {
|
|
|
10691
10989
|
};
|
|
10692
10990
|
//#endregion
|
|
10693
10991
|
//#region src/plugin/rules/react-builtins/jsx-no-constructed-context-values.ts
|
|
10694
|
-
const MESSAGE$
|
|
10992
|
+
const MESSAGE$44 = "Every reader of this context redraws on each render because you build its `value` inline.";
|
|
10695
10993
|
const CONTEXT_MODULES$1 = [
|
|
10696
10994
|
"react",
|
|
10697
10995
|
"use-context-selector",
|
|
@@ -10789,7 +11087,7 @@ const jsxNoConstructedContextValues = defineRule({
|
|
|
10789
11087
|
if (!isConstructedValue(innerExpression)) continue;
|
|
10790
11088
|
context.report({
|
|
10791
11089
|
node: attribute,
|
|
10792
|
-
message: MESSAGE$
|
|
11090
|
+
message: MESSAGE$44
|
|
10793
11091
|
});
|
|
10794
11092
|
}
|
|
10795
11093
|
}
|
|
@@ -10875,7 +11173,7 @@ const isJsxAttributeOnIntrinsicHtmlElement = (attribute) => {
|
|
|
10875
11173
|
};
|
|
10876
11174
|
//#endregion
|
|
10877
11175
|
//#region src/plugin/rules/react-builtins/jsx-no-jsx-as-prop.ts
|
|
10878
|
-
const MESSAGE$
|
|
11176
|
+
const MESSAGE$43 = "This child redraws every render because the prop gets brand new JSX each time.";
|
|
10879
11177
|
const KNOWN_SLOT_PROP_NAMES = new Set([
|
|
10880
11178
|
"icon",
|
|
10881
11179
|
"Icon",
|
|
@@ -11144,7 +11442,7 @@ const jsxNoJsxAsProp = defineRule({
|
|
|
11144
11442
|
if (!isJsxProducingExpression(expressionNode) && !followsRenderLocalJsxBinding(expressionNode, node)) return;
|
|
11145
11443
|
context.report({
|
|
11146
11444
|
node,
|
|
11147
|
-
message: MESSAGE$
|
|
11445
|
+
message: MESSAGE$43
|
|
11148
11446
|
});
|
|
11149
11447
|
}
|
|
11150
11448
|
};
|
|
@@ -11432,7 +11730,7 @@ const DATA_ARRAY_PROP_SUFFIXES = [
|
|
|
11432
11730
|
];
|
|
11433
11731
|
//#endregion
|
|
11434
11732
|
//#region src/plugin/rules/react-builtins/jsx-no-new-array-as-prop.ts
|
|
11435
|
-
const MESSAGE$
|
|
11733
|
+
const MESSAGE$42 = "This child redraws every render because the prop gets a brand new array each time.";
|
|
11436
11734
|
const isDataArrayPropName = (propName) => {
|
|
11437
11735
|
if (DATA_ARRAY_PROP_NAMES.has(propName)) return true;
|
|
11438
11736
|
for (const suffix of DATA_ARRAY_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
|
|
@@ -11516,7 +11814,7 @@ const jsxNoNewArrayAsProp = defineRule({
|
|
|
11516
11814
|
if (!isArrayProducingExpression(expressionNode) && !followsRenderLocalArrayBinding(expressionNode, node)) return;
|
|
11517
11815
|
context.report({
|
|
11518
11816
|
node,
|
|
11519
|
-
message: MESSAGE$
|
|
11817
|
+
message: MESSAGE$42
|
|
11520
11818
|
});
|
|
11521
11819
|
}
|
|
11522
11820
|
};
|
|
@@ -11774,7 +12072,7 @@ const SAFE_RECEIVER_NAMES = new Set([
|
|
|
11774
12072
|
]);
|
|
11775
12073
|
//#endregion
|
|
11776
12074
|
//#region src/plugin/rules/react-builtins/jsx-no-new-function-as-prop.ts
|
|
11777
|
-
const MESSAGE$
|
|
12075
|
+
const MESSAGE$41 = "This child redraws every render because the prop gets a brand new function each time.";
|
|
11778
12076
|
const isAccessorPredicateName = (propName) => {
|
|
11779
12077
|
for (const prefix of ACCESSOR_PREDICATE_PREFIXES) {
|
|
11780
12078
|
if (propName.length <= prefix.length) continue;
|
|
@@ -11980,7 +12278,7 @@ const jsxNoNewFunctionAsProp = defineRule({
|
|
|
11980
12278
|
if (!isFunctionProducingExpression(expressionNode) && !followsRenderLocalFunctionBinding(expressionNode, node)) return;
|
|
11981
12279
|
context.report({
|
|
11982
12280
|
node,
|
|
11983
|
-
message: MESSAGE$
|
|
12281
|
+
message: MESSAGE$41
|
|
11984
12282
|
});
|
|
11985
12283
|
}
|
|
11986
12284
|
};
|
|
@@ -12200,7 +12498,7 @@ const CONFIG_OBJECT_PROP_SUFFIXES = [
|
|
|
12200
12498
|
];
|
|
12201
12499
|
//#endregion
|
|
12202
12500
|
//#region src/plugin/rules/react-builtins/jsx-no-new-object-as-prop.ts
|
|
12203
|
-
const MESSAGE$
|
|
12501
|
+
const MESSAGE$40 = "This child redraws every render because the prop gets a brand new object each time.";
|
|
12204
12502
|
const isConfigObjectPropName = (propName) => {
|
|
12205
12503
|
if (CONFIG_OBJECT_PROP_NAMES.has(propName)) return true;
|
|
12206
12504
|
for (const suffix of CONFIG_OBJECT_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
|
|
@@ -12288,7 +12586,7 @@ const jsxNoNewObjectAsProp = defineRule({
|
|
|
12288
12586
|
if (!isObjectProducingExpression(expressionNode) && !followsRenderLocalObjectBinding(expressionNode, node)) return;
|
|
12289
12587
|
context.report({
|
|
12290
12588
|
node,
|
|
12291
|
-
message: MESSAGE$
|
|
12589
|
+
message: MESSAGE$40
|
|
12292
12590
|
});
|
|
12293
12591
|
}
|
|
12294
12592
|
};
|
|
@@ -12296,7 +12594,7 @@ const jsxNoNewObjectAsProp = defineRule({
|
|
|
12296
12594
|
});
|
|
12297
12595
|
//#endregion
|
|
12298
12596
|
//#region src/plugin/rules/react-builtins/jsx-no-script-url.ts
|
|
12299
|
-
const MESSAGE$
|
|
12597
|
+
const MESSAGE$39 = "A `javascript:` URL is an XSS hole that runs injected input as code.";
|
|
12300
12598
|
const JAVASCRIPT_URL_PATTERN = /j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;
|
|
12301
12599
|
const resolveSettings$28 = (settings) => {
|
|
12302
12600
|
const reactDoctor = settings?.["react-doctor"];
|
|
@@ -12337,7 +12635,7 @@ const jsxNoScriptUrl = defineRule({
|
|
|
12337
12635
|
if (!value || !isNodeOfType(value, "Literal") || typeof value.value !== "string") continue;
|
|
12338
12636
|
if (JAVASCRIPT_URL_PATTERN.test(value.value)) context.report({
|
|
12339
12637
|
node: attribute,
|
|
12340
|
-
message: MESSAGE$
|
|
12638
|
+
message: MESSAGE$39
|
|
12341
12639
|
});
|
|
12342
12640
|
}
|
|
12343
12641
|
} };
|
|
@@ -12652,7 +12950,7 @@ const jsxPropsNoSpreadMulti = defineRule({
|
|
|
12652
12950
|
});
|
|
12653
12951
|
//#endregion
|
|
12654
12952
|
//#region src/plugin/rules/react-builtins/jsx-props-no-spreading.ts
|
|
12655
|
-
const MESSAGE$
|
|
12953
|
+
const MESSAGE$38 = "You can't tell what props reach this element when you spread them.";
|
|
12656
12954
|
const resolveSettings$25 = (settings) => {
|
|
12657
12955
|
const reactDoctor = settings?.["react-doctor"];
|
|
12658
12956
|
const ruleSettings = typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.jsxPropsNoSpreading ?? {} : {};
|
|
@@ -12693,18 +12991,77 @@ const jsxPropsNoSpreading = defineRule({
|
|
|
12693
12991
|
}
|
|
12694
12992
|
context.report({
|
|
12695
12993
|
node: attribute,
|
|
12696
|
-
message: MESSAGE$
|
|
12994
|
+
message: MESSAGE$38
|
|
12697
12995
|
});
|
|
12698
12996
|
}
|
|
12699
12997
|
} };
|
|
12700
12998
|
}
|
|
12701
12999
|
});
|
|
12702
13000
|
//#endregion
|
|
13001
|
+
//#region src/plugin/rules/security-scan/jwt-insecure-verification.ts
|
|
13002
|
+
const NONE_ALGORITHM_PATTERN = /\b(?:alg|algorithms?)\s*:\s*\[?\s*["'`]none["'`]/gi;
|
|
13003
|
+
const isIndexInsideStringLiteral = (content, index) => {
|
|
13004
|
+
let stringDelimiter = null;
|
|
13005
|
+
const templateExpressionDepths = [];
|
|
13006
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
13007
|
+
const character = content[cursor];
|
|
13008
|
+
if (stringDelimiter === "`") {
|
|
13009
|
+
if (character === "\\") cursor += 1;
|
|
13010
|
+
else if (character === "`") stringDelimiter = null;
|
|
13011
|
+
else if (character === "$" && content[cursor + 1] === "{") {
|
|
13012
|
+
templateExpressionDepths.push(0);
|
|
13013
|
+
stringDelimiter = null;
|
|
13014
|
+
cursor += 1;
|
|
13015
|
+
}
|
|
13016
|
+
continue;
|
|
13017
|
+
}
|
|
13018
|
+
if (stringDelimiter !== null) {
|
|
13019
|
+
if (character === "\\") cursor += 1;
|
|
13020
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
13021
|
+
continue;
|
|
13022
|
+
}
|
|
13023
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
13024
|
+
else if (templateExpressionDepths.length > 0) {
|
|
13025
|
+
const top = templateExpressionDepths.length - 1;
|
|
13026
|
+
if (character === "{") templateExpressionDepths[top] += 1;
|
|
13027
|
+
else if (character === "}") if (templateExpressionDepths[top] === 0) {
|
|
13028
|
+
templateExpressionDepths.pop();
|
|
13029
|
+
stringDelimiter = "`";
|
|
13030
|
+
} else templateExpressionDepths[top] -= 1;
|
|
13031
|
+
}
|
|
13032
|
+
}
|
|
13033
|
+
return stringDelimiter !== null;
|
|
13034
|
+
};
|
|
13035
|
+
const jwtInsecureVerification = defineRule({
|
|
13036
|
+
id: "jwt-insecure-verification",
|
|
13037
|
+
title: "JWT verified with the 'none' algorithm",
|
|
13038
|
+
severity: "error",
|
|
13039
|
+
recommendation: "Never accept the `none` algorithm; it disables signature verification and lets any forged token through. Pin the real algorithm(s) explicitly (`jwt.verify(token, key, { algorithms: ['RS256'] })`).",
|
|
13040
|
+
scan: (file) => {
|
|
13041
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
13042
|
+
const content = getScannableContent(file);
|
|
13043
|
+
if (!/\bjwt\b|jsonwebtoken|\bjose\b/i.test(content)) return [];
|
|
13044
|
+
const findings = [];
|
|
13045
|
+
NONE_ALGORITHM_PATTERN.lastIndex = 0;
|
|
13046
|
+
for (let noneMatch = NONE_ALGORITHM_PATTERN.exec(content); noneMatch !== null; noneMatch = NONE_ALGORITHM_PATTERN.exec(content)) {
|
|
13047
|
+
if (isIndexInsideStringLiteral(content, noneMatch.index)) continue;
|
|
13048
|
+
const location = getLocationAtIndex(content, noneMatch.index);
|
|
13049
|
+
findings.push({
|
|
13050
|
+
message: "JWT is configured with the 'none' algorithm, which disables signature verification, so any forged token is accepted.",
|
|
13051
|
+
line: location.line,
|
|
13052
|
+
column: location.column
|
|
13053
|
+
});
|
|
13054
|
+
}
|
|
13055
|
+
return findings;
|
|
13056
|
+
}
|
|
13057
|
+
});
|
|
13058
|
+
//#endregion
|
|
12703
13059
|
//#region src/plugin/rules/security-scan/key-lifecycle-risk.ts
|
|
12704
13060
|
const keyLifecycleRisk = defineRule({
|
|
12705
13061
|
id: "key-lifecycle-risk",
|
|
12706
13062
|
title: "Long-lived key material in repository",
|
|
12707
13063
|
severity: "error",
|
|
13064
|
+
committedFilesOnly: true,
|
|
12708
13065
|
recommendation: "Remove private keys from source, rotate exposed credentials, prefer short-lived deploy credentials, and document revocation/expiry for release keys.",
|
|
12709
13066
|
scan: scanByPattern({
|
|
12710
13067
|
shouldScan: (file) => !TEST_CONTEXT_PATTERN.test(file.relativePath) && !DOCUMENTATION_CONTEXT_PATTERN.test(file.relativePath),
|
|
@@ -12862,7 +13219,7 @@ const labelHasAssociatedControl = defineRule({
|
|
|
12862
13219
|
});
|
|
12863
13220
|
//#endregion
|
|
12864
13221
|
//#region src/plugin/rules/a11y/lang.ts
|
|
12865
|
-
const MESSAGE$
|
|
13222
|
+
const MESSAGE$37 = "Screen readers can't pick the right voice because this `lang` isn't a real language code, so use a valid one like `en` or `en-US`.";
|
|
12866
13223
|
const COMMON_LANGUAGE_PRIMARY_TAGS = new Set([
|
|
12867
13224
|
"aa",
|
|
12868
13225
|
"ab",
|
|
@@ -13074,7 +13431,7 @@ const lang = defineRule({
|
|
|
13074
13431
|
if (expression.type === "Identifier" && expression.name === "undefined" || expression.type === "Literal" && expression.value === null) {
|
|
13075
13432
|
context.report({
|
|
13076
13433
|
node: langAttr,
|
|
13077
|
-
message: MESSAGE$
|
|
13434
|
+
message: MESSAGE$37
|
|
13078
13435
|
});
|
|
13079
13436
|
return;
|
|
13080
13437
|
}
|
|
@@ -13083,7 +13440,7 @@ const lang = defineRule({
|
|
|
13083
13440
|
if (value === null) return;
|
|
13084
13441
|
if (!isValidLangTag(value)) context.report({
|
|
13085
13442
|
node: langAttr,
|
|
13086
|
-
message: MESSAGE$
|
|
13443
|
+
message: MESSAGE$37
|
|
13087
13444
|
});
|
|
13088
13445
|
} })
|
|
13089
13446
|
});
|
|
@@ -13127,7 +13484,7 @@ const mdxSsrExecutionRisk = defineRule({
|
|
|
13127
13484
|
});
|
|
13128
13485
|
//#endregion
|
|
13129
13486
|
//#region src/plugin/rules/a11y/media-has-caption.ts
|
|
13130
|
-
const MESSAGE$
|
|
13487
|
+
const MESSAGE$36 = "Deaf and hard-of-hearing users need captions for this media. Add a `<track kind=\"captions\">` inside the `<audio>` or `<video>`.";
|
|
13131
13488
|
const DEFAULT_AUDIO = ["audio"];
|
|
13132
13489
|
const DEFAULT_VIDEO = ["video"];
|
|
13133
13490
|
const DEFAULT_TRACK = ["track"];
|
|
@@ -13168,7 +13525,7 @@ const mediaHasCaption = defineRule({
|
|
|
13168
13525
|
if (!parent || !isNodeOfType(parent, "JSXElement")) {
|
|
13169
13526
|
context.report({
|
|
13170
13527
|
node: node.name,
|
|
13171
|
-
message: MESSAGE$
|
|
13528
|
+
message: MESSAGE$36
|
|
13172
13529
|
});
|
|
13173
13530
|
return;
|
|
13174
13531
|
}
|
|
@@ -13185,7 +13542,7 @@ const mediaHasCaption = defineRule({
|
|
|
13185
13542
|
return kindValue.value.toLowerCase() === "captions";
|
|
13186
13543
|
})) context.report({
|
|
13187
13544
|
node: node.name,
|
|
13188
|
-
message: MESSAGE$
|
|
13545
|
+
message: MESSAGE$36
|
|
13189
13546
|
});
|
|
13190
13547
|
} };
|
|
13191
13548
|
}
|
|
@@ -14986,7 +15343,7 @@ const nextjsNoVercelOgImport = defineRule({
|
|
|
14986
15343
|
});
|
|
14987
15344
|
//#endregion
|
|
14988
15345
|
//#region src/plugin/rules/a11y/no-access-key.ts
|
|
14989
|
-
const MESSAGE$
|
|
15346
|
+
const MESSAGE$35 = "Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.";
|
|
14990
15347
|
const isUndefinedIdentifier = (expression) => isNodeOfType(expression, "Identifier") && expression.name === "undefined";
|
|
14991
15348
|
const noAccessKey = defineRule({
|
|
14992
15349
|
id: "no-access-key",
|
|
@@ -15003,7 +15360,7 @@ const noAccessKey = defineRule({
|
|
|
15003
15360
|
if (isNodeOfType(attributeValue, "Literal") && typeof attributeValue.value === "string") {
|
|
15004
15361
|
context.report({
|
|
15005
15362
|
node: accessKey,
|
|
15006
|
-
message: MESSAGE$
|
|
15363
|
+
message: MESSAGE$35
|
|
15007
15364
|
});
|
|
15008
15365
|
return;
|
|
15009
15366
|
}
|
|
@@ -15013,7 +15370,7 @@ const noAccessKey = defineRule({
|
|
|
15013
15370
|
if (isUndefinedIdentifier(expression)) return;
|
|
15014
15371
|
context.report({
|
|
15015
15372
|
node: accessKey,
|
|
15016
|
-
message: MESSAGE$
|
|
15373
|
+
message: MESSAGE$35
|
|
15017
15374
|
});
|
|
15018
15375
|
}
|
|
15019
15376
|
} })
|
|
@@ -15496,7 +15853,7 @@ const noAdjustStateOnPropChange = defineRule({
|
|
|
15496
15853
|
});
|
|
15497
15854
|
//#endregion
|
|
15498
15855
|
//#region src/plugin/rules/a11y/no-aria-hidden-on-focusable.ts
|
|
15499
|
-
const MESSAGE$
|
|
15856
|
+
const MESSAGE$34 = "Screen reader users tab to this focusable element but hear nothing because `aria-hidden` skips it, so remove `aria-hidden` or stop it being focusable.";
|
|
15500
15857
|
const noAriaHiddenOnFocusable = defineRule({
|
|
15501
15858
|
id: "no-aria-hidden-on-focusable",
|
|
15502
15859
|
title: "aria-hidden on focusable element",
|
|
@@ -15523,7 +15880,7 @@ const noAriaHiddenOnFocusable = defineRule({
|
|
|
15523
15880
|
const isImplicitlyFocusable = isInteractiveElement(tag, node);
|
|
15524
15881
|
if (isExplicitlyFocusable || isImplicitlyFocusable) context.report({
|
|
15525
15882
|
node: ariaHidden,
|
|
15526
|
-
message: MESSAGE$
|
|
15883
|
+
message: MESSAGE$34
|
|
15527
15884
|
});
|
|
15528
15885
|
} })
|
|
15529
15886
|
});
|
|
@@ -15891,7 +16248,7 @@ const noArrayIndexAsKey = defineRule({
|
|
|
15891
16248
|
});
|
|
15892
16249
|
//#endregion
|
|
15893
16250
|
//#region src/plugin/rules/react-builtins/no-array-index-key.ts
|
|
15894
|
-
const MESSAGE$
|
|
16251
|
+
const MESSAGE$33 = "Your users can see & submit the wrong data when this list reorders.";
|
|
15895
16252
|
const SECOND_INDEX_METHODS = new Set([
|
|
15896
16253
|
"every",
|
|
15897
16254
|
"filter",
|
|
@@ -16095,7 +16452,7 @@ const noArrayIndexKey = defineRule({
|
|
|
16095
16452
|
}
|
|
16096
16453
|
context.report({
|
|
16097
16454
|
node: keyAttribute,
|
|
16098
|
-
message: MESSAGE$
|
|
16455
|
+
message: MESSAGE$33
|
|
16099
16456
|
});
|
|
16100
16457
|
},
|
|
16101
16458
|
CallExpression(node) {
|
|
@@ -16115,15 +16472,35 @@ const noArrayIndexKey = defineRule({
|
|
|
16115
16472
|
if (propName !== "key") continue;
|
|
16116
16473
|
if (expressionUsesIndex(property.value, indexBinding.name)) context.report({
|
|
16117
16474
|
node: property,
|
|
16118
|
-
message: MESSAGE$
|
|
16475
|
+
message: MESSAGE$33
|
|
16119
16476
|
});
|
|
16120
16477
|
}
|
|
16121
16478
|
}
|
|
16122
16479
|
})
|
|
16123
16480
|
});
|
|
16124
16481
|
//#endregion
|
|
16482
|
+
//#region src/plugin/rules/state-and-effects/no-async-effect-callback.ts
|
|
16483
|
+
const MESSAGE$32 = "The `useEffect` callback is `async`, so it returns a Promise instead of a cleanup function. React calls that Promise as cleanup (a no-op) and the effect can race on unmount. Put the async work in an inner function and call it.";
|
|
16484
|
+
const noAsyncEffectCallback = defineRule({
|
|
16485
|
+
id: "no-async-effect-callback",
|
|
16486
|
+
title: "Async effect callback",
|
|
16487
|
+
severity: "warn",
|
|
16488
|
+
recommendation: "Don't make the effect callback `async`. Define an async function inside the effect and call it, then return a real cleanup function if you need one.",
|
|
16489
|
+
create: (context) => ({ CallExpression(node) {
|
|
16490
|
+
if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
|
|
16491
|
+
const callback = getEffectCallback(node);
|
|
16492
|
+
if (!callback) return;
|
|
16493
|
+
if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return;
|
|
16494
|
+
if (!callback.async) return;
|
|
16495
|
+
context.report({
|
|
16496
|
+
node: callback,
|
|
16497
|
+
message: MESSAGE$32
|
|
16498
|
+
});
|
|
16499
|
+
} })
|
|
16500
|
+
});
|
|
16501
|
+
//#endregion
|
|
16125
16502
|
//#region src/plugin/rules/a11y/no-autofocus.ts
|
|
16126
|
-
const MESSAGE$
|
|
16503
|
+
const MESSAGE$31 = "`autoFocus` moves focus on load, which can disrupt screen reader and keyboard users. Remove it and let users choose where to focus.";
|
|
16127
16504
|
const resolveSettings$21 = (settings) => {
|
|
16128
16505
|
const reactDoctor = settings?.["react-doctor"];
|
|
16129
16506
|
return { ignoreNonDOM: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noAutofocus ?? {} : {}).ignoreNonDOM ?? true };
|
|
@@ -16179,7 +16556,7 @@ const noAutofocus = defineRule({
|
|
|
16179
16556
|
}
|
|
16180
16557
|
context.report({
|
|
16181
16558
|
node: autoFocusAttribute,
|
|
16182
|
-
message: MESSAGE$
|
|
16559
|
+
message: MESSAGE$31
|
|
16183
16560
|
});
|
|
16184
16561
|
} };
|
|
16185
16562
|
}
|
|
@@ -16429,6 +16806,109 @@ const noBarrelImport = defineRule({
|
|
|
16429
16806
|
}
|
|
16430
16807
|
});
|
|
16431
16808
|
//#endregion
|
|
16809
|
+
//#region src/plugin/utils/function-contains-react-render-output.ts
|
|
16810
|
+
const NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES = new Set([
|
|
16811
|
+
"FunctionDeclaration",
|
|
16812
|
+
"FunctionExpression",
|
|
16813
|
+
"ArrowFunctionExpression",
|
|
16814
|
+
"ClassDeclaration",
|
|
16815
|
+
"ClassExpression"
|
|
16816
|
+
]);
|
|
16817
|
+
const isReactImport$1 = (symbol) => {
|
|
16818
|
+
let importDeclaration = symbol.declarationNode?.parent;
|
|
16819
|
+
while (importDeclaration && !isNodeOfType(importDeclaration, "ImportDeclaration")) importDeclaration = importDeclaration.parent ?? null;
|
|
16820
|
+
if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration")) return false;
|
|
16821
|
+
return importDeclaration.source.value === "react";
|
|
16822
|
+
};
|
|
16823
|
+
const getImportedName = (symbol) => {
|
|
16824
|
+
if (symbol.kind !== "import") return null;
|
|
16825
|
+
if (!isReactImport$1(symbol)) return null;
|
|
16826
|
+
return getImportedName$1(symbol.declarationNode) ?? null;
|
|
16827
|
+
};
|
|
16828
|
+
const isReactNamespaceImport = (symbol) => {
|
|
16829
|
+
if (symbol.kind !== "import") return false;
|
|
16830
|
+
if (!isReactImport$1(symbol)) return false;
|
|
16831
|
+
return isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier");
|
|
16832
|
+
};
|
|
16833
|
+
const isReactCreateElementIdentifierCall = (callee, scopes) => {
|
|
16834
|
+
if (!isNodeOfType(callee, "Identifier")) return false;
|
|
16835
|
+
const symbol = scopes.symbolFor(callee);
|
|
16836
|
+
return Boolean(symbol && getImportedName(symbol) === "createElement");
|
|
16837
|
+
};
|
|
16838
|
+
const isReactCreateElementMemberCall = (callee, scopes) => {
|
|
16839
|
+
if (!isNodeOfType(callee, "MemberExpression")) return false;
|
|
16840
|
+
if (callee.computed) return false;
|
|
16841
|
+
if (!isNodeOfType(callee.object, "Identifier")) return false;
|
|
16842
|
+
if (!isNodeOfType(callee.property, "Identifier")) return false;
|
|
16843
|
+
if (callee.property.name !== "createElement") return false;
|
|
16844
|
+
const symbol = scopes.symbolFor(callee.object);
|
|
16845
|
+
return Boolean(symbol && isReactNamespaceImport(symbol));
|
|
16846
|
+
};
|
|
16847
|
+
const isReactCreateElementCall = (node, scopes) => {
|
|
16848
|
+
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
16849
|
+
return isReactCreateElementIdentifierCall(node.callee, scopes) || isReactCreateElementMemberCall(node.callee, scopes);
|
|
16850
|
+
};
|
|
16851
|
+
const containsRenderOutput = (node, rootNode, scopes) => {
|
|
16852
|
+
if (node !== rootNode && NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES.has(node.type)) return false;
|
|
16853
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
16854
|
+
if (isReactCreateElementCall(node, scopes)) return true;
|
|
16855
|
+
const nodeRecord = node;
|
|
16856
|
+
for (const key of Object.keys(nodeRecord)) {
|
|
16857
|
+
if (key === "parent") continue;
|
|
16858
|
+
const child = nodeRecord[key];
|
|
16859
|
+
if (Array.isArray(child)) {
|
|
16860
|
+
for (const innerChild of child) if (isAstNode(innerChild) && containsRenderOutput(innerChild, rootNode, scopes)) return true;
|
|
16861
|
+
} else if (isAstNode(child) && containsRenderOutput(child, rootNode, scopes)) return true;
|
|
16862
|
+
}
|
|
16863
|
+
return false;
|
|
16864
|
+
};
|
|
16865
|
+
const functionContainsReactRenderOutput = (functionNode, scopes) => containsRenderOutput(functionNode, functionNode, scopes);
|
|
16866
|
+
//#endregion
|
|
16867
|
+
//#region src/plugin/utils/is-component-declaration.ts
|
|
16868
|
+
const isComponentDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && node.id !== null && Boolean(node.id?.name) && isUppercaseName(node.id.name);
|
|
16869
|
+
//#endregion
|
|
16870
|
+
//#region src/plugin/rules/react-builtins/no-call-component-as-function.ts
|
|
16871
|
+
const message = (name) => `\`${name}\` is a component, so calling it as a plain function (\`${name}(...)\`) runs it outside React: its hooks break, it gets no fiber/state, and memoization is lost. Render it as \`<${name} />\` instead.`;
|
|
16872
|
+
const symbolIsLocalComponent = (symbol, context) => {
|
|
16873
|
+
const declaration = symbol.declarationNode;
|
|
16874
|
+
if (isComponentDeclaration(declaration)) return functionContainsReactRenderOutput(declaration, context.scopes);
|
|
16875
|
+
if (isComponentAssignment(declaration) && symbol.initializer) return functionContainsReactRenderOutput(symbol.initializer, context.scopes);
|
|
16876
|
+
return false;
|
|
16877
|
+
};
|
|
16878
|
+
const noCallComponentAsFunction = defineRule({
|
|
16879
|
+
id: "no-call-component-as-function",
|
|
16880
|
+
title: "Component called as a function",
|
|
16881
|
+
severity: "warn",
|
|
16882
|
+
tags: ["test-noise"],
|
|
16883
|
+
recommendation: "Render components as JSX (`<Component />`), never call them like functions (`Component(props)`). A direct call runs the component outside React and breaks hooks, state, and memoization.",
|
|
16884
|
+
create: (context) => {
|
|
16885
|
+
const renderedJsxNames = /* @__PURE__ */ new Set();
|
|
16886
|
+
const candidateCalls = [];
|
|
16887
|
+
return {
|
|
16888
|
+
JSXOpeningElement(node) {
|
|
16889
|
+
if (isNodeOfType(node.name, "JSXIdentifier") && isUppercaseName(node.name.name)) renderedJsxNames.add(node.name.name);
|
|
16890
|
+
},
|
|
16891
|
+
CallExpression(node) {
|
|
16892
|
+
if (isNodeOfType(node.callee, "Identifier") && isUppercaseName(node.callee.name)) candidateCalls.push({
|
|
16893
|
+
node,
|
|
16894
|
+
callee: node.callee,
|
|
16895
|
+
name: node.callee.name
|
|
16896
|
+
});
|
|
16897
|
+
},
|
|
16898
|
+
"Program:exit"() {
|
|
16899
|
+
for (const candidate of candidateCalls) {
|
|
16900
|
+
const symbol = context.scopes.symbolFor(candidate.callee);
|
|
16901
|
+
if (!symbol) continue;
|
|
16902
|
+
if (symbolIsLocalComponent(symbol, context) || symbol.kind === "import" && renderedJsxNames.has(candidate.name)) context.report({
|
|
16903
|
+
node: candidate.node,
|
|
16904
|
+
message: message(candidate.name)
|
|
16905
|
+
});
|
|
16906
|
+
}
|
|
16907
|
+
}
|
|
16908
|
+
};
|
|
16909
|
+
}
|
|
16910
|
+
});
|
|
16911
|
+
//#endregion
|
|
16432
16912
|
//#region src/plugin/utils/is-setter-identifier.ts
|
|
16433
16913
|
const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
|
|
16434
16914
|
//#endregion
|
|
@@ -16580,7 +17060,7 @@ const noChainStateUpdates = defineRule({
|
|
|
16580
17060
|
});
|
|
16581
17061
|
//#endregion
|
|
16582
17062
|
//#region src/plugin/rules/react-builtins/no-children-prop.ts
|
|
16583
|
-
const MESSAGE$
|
|
17063
|
+
const MESSAGE$30 = "A `children` prop can override or hide nested children, so the component may render different content than the JSX shows.";
|
|
16584
17064
|
const noChildrenProp = defineRule({
|
|
16585
17065
|
id: "no-children-prop",
|
|
16586
17066
|
title: "Children passed as a prop",
|
|
@@ -16592,7 +17072,7 @@ const noChildrenProp = defineRule({
|
|
|
16592
17072
|
if (node.name.name !== "children") return;
|
|
16593
17073
|
context.report({
|
|
16594
17074
|
node: node.name,
|
|
16595
|
-
message: MESSAGE$
|
|
17075
|
+
message: MESSAGE$30
|
|
16596
17076
|
});
|
|
16597
17077
|
},
|
|
16598
17078
|
CallExpression(node) {
|
|
@@ -16605,7 +17085,7 @@ const noChildrenProp = defineRule({
|
|
|
16605
17085
|
const propertyKey = property.key;
|
|
16606
17086
|
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "children" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "children") context.report({
|
|
16607
17087
|
node: propertyKey,
|
|
16608
|
-
message: MESSAGE$
|
|
17088
|
+
message: MESSAGE$30
|
|
16609
17089
|
});
|
|
16610
17090
|
}
|
|
16611
17091
|
}
|
|
@@ -16613,7 +17093,7 @@ const noChildrenProp = defineRule({
|
|
|
16613
17093
|
});
|
|
16614
17094
|
//#endregion
|
|
16615
17095
|
//#region src/plugin/rules/react-builtins/no-clone-element.ts
|
|
16616
|
-
const MESSAGE$
|
|
17096
|
+
const MESSAGE$29 = "`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.";
|
|
16617
17097
|
const noCloneElement = defineRule({
|
|
16618
17098
|
id: "no-clone-element",
|
|
16619
17099
|
title: "cloneElement makes child props fragile",
|
|
@@ -16626,7 +17106,7 @@ const noCloneElement = defineRule({
|
|
|
16626
17106
|
if (isNodeOfType(callee, "Identifier") && callee.name === "cloneElement") {
|
|
16627
17107
|
if (isImportedFromModule(node, "cloneElement", "react")) context.report({
|
|
16628
17108
|
node: callee,
|
|
16629
|
-
message: MESSAGE$
|
|
17109
|
+
message: MESSAGE$29
|
|
16630
17110
|
});
|
|
16631
17111
|
return;
|
|
16632
17112
|
}
|
|
@@ -16639,7 +17119,7 @@ const noCloneElement = defineRule({
|
|
|
16639
17119
|
if (!isImportedFromModule(node, callee.object.name, "react")) return;
|
|
16640
17120
|
context.report({
|
|
16641
17121
|
node: callee,
|
|
16642
|
-
message: MESSAGE$
|
|
17122
|
+
message: MESSAGE$29
|
|
16643
17123
|
});
|
|
16644
17124
|
}
|
|
16645
17125
|
} })
|
|
@@ -16688,7 +17168,7 @@ const enclosingComponentOrHookName = (node) => {
|
|
|
16688
17168
|
};
|
|
16689
17169
|
//#endregion
|
|
16690
17170
|
//#region src/plugin/rules/state-and-effects/no-create-context-in-render.ts
|
|
16691
|
-
const MESSAGE$
|
|
17171
|
+
const MESSAGE$28 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
|
|
16692
17172
|
const CONTEXT_MODULES = [
|
|
16693
17173
|
"react",
|
|
16694
17174
|
"use-context-selector",
|
|
@@ -16724,7 +17204,32 @@ const noCreateContextInRender = defineRule({
|
|
|
16724
17204
|
if (!componentOrHookName) return;
|
|
16725
17205
|
context.report({
|
|
16726
17206
|
node,
|
|
16727
|
-
message: `${MESSAGE$
|
|
17207
|
+
message: `${MESSAGE$28} (called inside "${componentOrHookName}")`
|
|
17208
|
+
});
|
|
17209
|
+
} })
|
|
17210
|
+
});
|
|
17211
|
+
//#endregion
|
|
17212
|
+
//#region src/plugin/rules/react-builtins/no-create-ref-in-function-component.ts
|
|
17213
|
+
const MESSAGE$27 = "`createRef()` in a function component allocates a brand-new ref on every render, so it never holds a value between renders. Use the `useRef()` hook instead.";
|
|
17214
|
+
const noCreateRefInFunctionComponent = defineRule({
|
|
17215
|
+
id: "no-create-ref-in-function-component",
|
|
17216
|
+
title: "createRef in function component",
|
|
17217
|
+
severity: "warn",
|
|
17218
|
+
recommendation: "Replace `createRef()` with the `useRef()` hook inside function components and hooks. `createRef` is only for class components.",
|
|
17219
|
+
create: (context) => ({ CallExpression(node) {
|
|
17220
|
+
if (!isReactFunctionCall(node, "createRef")) return;
|
|
17221
|
+
if (isNodeOfType(node.callee, "Identifier")) {
|
|
17222
|
+
const symbol = context.scopes.symbolFor(node.callee);
|
|
17223
|
+
if (symbol && symbol.kind !== "import") return;
|
|
17224
|
+
}
|
|
17225
|
+
const enclosingFunction = nearestEnclosingFunction(node);
|
|
17226
|
+
if (!enclosingFunction) return;
|
|
17227
|
+
const displayName = componentOrHookDisplayNameForFunction(enclosingFunction);
|
|
17228
|
+
if (!displayName) return;
|
|
17229
|
+
if (!(isReactHookName(displayName) || functionContainsReactRenderOutput(enclosingFunction, context.scopes))) return;
|
|
17230
|
+
context.report({
|
|
17231
|
+
node,
|
|
17232
|
+
message: MESSAGE$27
|
|
16728
17233
|
});
|
|
16729
17234
|
} })
|
|
16730
17235
|
});
|
|
@@ -16864,7 +17369,7 @@ const noCreateStoreInRender = defineRule({
|
|
|
16864
17369
|
});
|
|
16865
17370
|
//#endregion
|
|
16866
17371
|
//#region src/plugin/rules/react-builtins/no-danger.ts
|
|
16867
|
-
const MESSAGE$
|
|
17372
|
+
const MESSAGE$26 = "`dangerouslySetInnerHTML` is an XSS hole that runs attacker-controlled HTML in your users' browsers.";
|
|
16868
17373
|
const noDanger = defineRule({
|
|
16869
17374
|
id: "no-danger",
|
|
16870
17375
|
title: "Raw HTML injection can run unsafe markup",
|
|
@@ -16877,7 +17382,7 @@ const noDanger = defineRule({
|
|
|
16877
17382
|
if (!propAttribute) return;
|
|
16878
17383
|
context.report({
|
|
16879
17384
|
node: propAttribute.name,
|
|
16880
|
-
message: MESSAGE$
|
|
17385
|
+
message: MESSAGE$26
|
|
16881
17386
|
});
|
|
16882
17387
|
},
|
|
16883
17388
|
CallExpression(node) {
|
|
@@ -16889,7 +17394,7 @@ const noDanger = defineRule({
|
|
|
16889
17394
|
const propertyKey = property.key;
|
|
16890
17395
|
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "dangerouslySetInnerHTML" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "dangerouslySetInnerHTML") context.report({
|
|
16891
17396
|
node: propertyKey,
|
|
16892
|
-
message: MESSAGE$
|
|
17397
|
+
message: MESSAGE$26
|
|
16893
17398
|
});
|
|
16894
17399
|
}
|
|
16895
17400
|
}
|
|
@@ -16897,7 +17402,7 @@ const noDanger = defineRule({
|
|
|
16897
17402
|
});
|
|
16898
17403
|
//#endregion
|
|
16899
17404
|
//#region src/plugin/rules/react-builtins/no-danger-with-children.ts
|
|
16900
|
-
const MESSAGE$
|
|
17405
|
+
const MESSAGE$25 = "React throws an error when you set both children & `dangerouslySetInnerHTML`.";
|
|
16901
17406
|
const isLineBreak = (child) => {
|
|
16902
17407
|
if (!isNodeOfType(child, "JSXText")) return false;
|
|
16903
17408
|
return child.value.trim().length === 0 && child.value.includes("\n");
|
|
@@ -16967,7 +17472,7 @@ const noDangerWithChildren = defineRule({
|
|
|
16967
17472
|
if (!hasChildrenProp && !hasNestedChildren) return;
|
|
16968
17473
|
if (hasJsxPropIgnoreCase(opening.attributes, "dangerouslySetInnerHTML") || spreadPropsShape.hasDangerously) context.report({
|
|
16969
17474
|
node: opening,
|
|
16970
|
-
message: MESSAGE$
|
|
17475
|
+
message: MESSAGE$25
|
|
16971
17476
|
});
|
|
16972
17477
|
},
|
|
16973
17478
|
CallExpression(node) {
|
|
@@ -16979,7 +17484,7 @@ const noDangerWithChildren = defineRule({
|
|
|
16979
17484
|
if (!propsShape.hasDangerously) return;
|
|
16980
17485
|
if (node.arguments.length >= 3 || propsShape.hasChildren) context.report({
|
|
16981
17486
|
node,
|
|
16982
|
-
message: MESSAGE$
|
|
17487
|
+
message: MESSAGE$25
|
|
16983
17488
|
});
|
|
16984
17489
|
}
|
|
16985
17490
|
})
|
|
@@ -17556,7 +18061,7 @@ const isSetStateCallInLifecycle = (setStateCall, lifecycleNames, options = {}) =
|
|
|
17556
18061
|
//#endregion
|
|
17557
18062
|
//#region src/plugin/rules/react-builtins/no-did-mount-set-state.ts
|
|
17558
18063
|
const LIFECYCLE_NAMES$2 = new Set(["componentDidMount"]);
|
|
17559
|
-
const MESSAGE$
|
|
18064
|
+
const MESSAGE$24 = "Your users see an extra render right after mount when you call `setState` in `componentDidMount`.";
|
|
17560
18065
|
const resolveSettings$20 = (settings) => {
|
|
17561
18066
|
const reactDoctor = settings?.["react-doctor"];
|
|
17562
18067
|
return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidMountSetState ?? {} : {}).mode ?? "allowed" };
|
|
@@ -17575,7 +18080,7 @@ const noDidMountSetState = defineRule({
|
|
|
17575
18080
|
if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$2, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
|
|
17576
18081
|
context.report({
|
|
17577
18082
|
node: node.callee,
|
|
17578
|
-
message: MESSAGE$
|
|
18083
|
+
message: MESSAGE$24
|
|
17579
18084
|
});
|
|
17580
18085
|
} };
|
|
17581
18086
|
}
|
|
@@ -17583,7 +18088,7 @@ const noDidMountSetState = defineRule({
|
|
|
17583
18088
|
//#endregion
|
|
17584
18089
|
//#region src/plugin/rules/react-builtins/no-did-update-set-state.ts
|
|
17585
18090
|
const LIFECYCLE_NAMES$1 = new Set(["componentDidUpdate"]);
|
|
17586
|
-
const MESSAGE$
|
|
18091
|
+
const MESSAGE$23 = "Calling setState in componentDidUpdate can trigger another update immediately, loop forever, and freeze the component.";
|
|
17587
18092
|
const resolveSettings$19 = (settings) => {
|
|
17588
18093
|
const reactDoctor = settings?.["react-doctor"];
|
|
17589
18094
|
return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidUpdateSetState ?? {} : {}).mode ?? "allowed" };
|
|
@@ -17602,7 +18107,7 @@ const noDidUpdateSetState = defineRule({
|
|
|
17602
18107
|
if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$1, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
|
|
17603
18108
|
context.report({
|
|
17604
18109
|
node: node.callee,
|
|
17605
|
-
message: MESSAGE$
|
|
18110
|
+
message: MESSAGE$23
|
|
17606
18111
|
});
|
|
17607
18112
|
} };
|
|
17608
18113
|
}
|
|
@@ -17625,7 +18130,7 @@ const isStateMemberExpression = (node) => {
|
|
|
17625
18130
|
};
|
|
17626
18131
|
//#endregion
|
|
17627
18132
|
//#region src/plugin/rules/react-builtins/no-direct-mutation-state.ts
|
|
17628
|
-
const MESSAGE$
|
|
18133
|
+
const MESSAGE$22 = "Your users see stale data because mutating `this.state` by hand never redraws & gets overwritten.";
|
|
17629
18134
|
const shouldIgnoreMutation = (node) => {
|
|
17630
18135
|
let isConstructor = false;
|
|
17631
18136
|
let isInsideCallExpression = false;
|
|
@@ -17647,7 +18152,7 @@ const reportIfStateMutation = (context, reportNode, target) => {
|
|
|
17647
18152
|
if (shouldIgnoreMutation(reportNode)) return;
|
|
17648
18153
|
context.report({
|
|
17649
18154
|
node: reportNode,
|
|
17650
|
-
message: MESSAGE$
|
|
18155
|
+
message: MESSAGE$22
|
|
17651
18156
|
});
|
|
17652
18157
|
};
|
|
17653
18158
|
const noDirectMutationState = defineRule({
|
|
@@ -19235,7 +19740,7 @@ const ALLOWED_NAMESPACES = new Set([
|
|
|
19235
19740
|
"ReactDOM",
|
|
19236
19741
|
"ReactDom"
|
|
19237
19742
|
]);
|
|
19238
|
-
const MESSAGE$
|
|
19743
|
+
const MESSAGE$21 = "`findDOMNode` crashes your app in React 19 because it was removed.";
|
|
19239
19744
|
const noFindDomNode = defineRule({
|
|
19240
19745
|
id: "no-find-dom-node",
|
|
19241
19746
|
title: "findDOMNode breaks component encapsulation",
|
|
@@ -19246,7 +19751,7 @@ const noFindDomNode = defineRule({
|
|
|
19246
19751
|
if (isNodeOfType(callee, "Identifier") && callee.name === "findDOMNode") {
|
|
19247
19752
|
context.report({
|
|
19248
19753
|
node: callee,
|
|
19249
|
-
message: MESSAGE$
|
|
19754
|
+
message: MESSAGE$21
|
|
19250
19755
|
});
|
|
19251
19756
|
return;
|
|
19252
19757
|
}
|
|
@@ -19257,7 +19762,7 @@ const noFindDomNode = defineRule({
|
|
|
19257
19762
|
if (callee.property.name !== "findDOMNode") return;
|
|
19258
19763
|
context.report({
|
|
19259
19764
|
node: callee.property,
|
|
19260
|
-
message: MESSAGE$
|
|
19765
|
+
message: MESSAGE$21
|
|
19261
19766
|
});
|
|
19262
19767
|
}
|
|
19263
19768
|
} })
|
|
@@ -19320,64 +19825,6 @@ const noGenericHandlerNames = defineRule({
|
|
|
19320
19825
|
} })
|
|
19321
19826
|
});
|
|
19322
19827
|
//#endregion
|
|
19323
|
-
//#region src/plugin/utils/function-contains-react-render-output.ts
|
|
19324
|
-
const NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES = new Set([
|
|
19325
|
-
"FunctionDeclaration",
|
|
19326
|
-
"FunctionExpression",
|
|
19327
|
-
"ArrowFunctionExpression",
|
|
19328
|
-
"ClassDeclaration",
|
|
19329
|
-
"ClassExpression"
|
|
19330
|
-
]);
|
|
19331
|
-
const isReactImport$1 = (symbol) => {
|
|
19332
|
-
let importDeclaration = symbol.declarationNode?.parent;
|
|
19333
|
-
while (importDeclaration && !isNodeOfType(importDeclaration, "ImportDeclaration")) importDeclaration = importDeclaration.parent ?? null;
|
|
19334
|
-
if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration")) return false;
|
|
19335
|
-
return importDeclaration.source.value === "react";
|
|
19336
|
-
};
|
|
19337
|
-
const getImportedName = (symbol) => {
|
|
19338
|
-
if (symbol.kind !== "import") return null;
|
|
19339
|
-
if (!isReactImport$1(symbol)) return null;
|
|
19340
|
-
return getImportedName$1(symbol.declarationNode) ?? null;
|
|
19341
|
-
};
|
|
19342
|
-
const isReactNamespaceImport = (symbol) => {
|
|
19343
|
-
if (symbol.kind !== "import") return false;
|
|
19344
|
-
if (!isReactImport$1(symbol)) return false;
|
|
19345
|
-
return isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier");
|
|
19346
|
-
};
|
|
19347
|
-
const isReactCreateElementIdentifierCall = (callee, scopes) => {
|
|
19348
|
-
if (!isNodeOfType(callee, "Identifier")) return false;
|
|
19349
|
-
const symbol = scopes.symbolFor(callee);
|
|
19350
|
-
return Boolean(symbol && getImportedName(symbol) === "createElement");
|
|
19351
|
-
};
|
|
19352
|
-
const isReactCreateElementMemberCall = (callee, scopes) => {
|
|
19353
|
-
if (!isNodeOfType(callee, "MemberExpression")) return false;
|
|
19354
|
-
if (callee.computed) return false;
|
|
19355
|
-
if (!isNodeOfType(callee.object, "Identifier")) return false;
|
|
19356
|
-
if (!isNodeOfType(callee.property, "Identifier")) return false;
|
|
19357
|
-
if (callee.property.name !== "createElement") return false;
|
|
19358
|
-
const symbol = scopes.symbolFor(callee.object);
|
|
19359
|
-
return Boolean(symbol && isReactNamespaceImport(symbol));
|
|
19360
|
-
};
|
|
19361
|
-
const isReactCreateElementCall = (node, scopes) => {
|
|
19362
|
-
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
19363
|
-
return isReactCreateElementIdentifierCall(node.callee, scopes) || isReactCreateElementMemberCall(node.callee, scopes);
|
|
19364
|
-
};
|
|
19365
|
-
const containsRenderOutput = (node, rootNode, scopes) => {
|
|
19366
|
-
if (node !== rootNode && NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES.has(node.type)) return false;
|
|
19367
|
-
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
19368
|
-
if (isReactCreateElementCall(node, scopes)) return true;
|
|
19369
|
-
const nodeRecord = node;
|
|
19370
|
-
for (const key of Object.keys(nodeRecord)) {
|
|
19371
|
-
if (key === "parent") continue;
|
|
19372
|
-
const child = nodeRecord[key];
|
|
19373
|
-
if (Array.isArray(child)) {
|
|
19374
|
-
for (const innerChild of child) if (isAstNode(innerChild) && containsRenderOutput(innerChild, rootNode, scopes)) return true;
|
|
19375
|
-
} else if (isAstNode(child) && containsRenderOutput(child, rootNode, scopes)) return true;
|
|
19376
|
-
}
|
|
19377
|
-
return false;
|
|
19378
|
-
};
|
|
19379
|
-
const functionContainsReactRenderOutput = (functionNode, scopes) => containsRenderOutput(functionNode, functionNode, scopes);
|
|
19380
|
-
//#endregion
|
|
19381
19828
|
//#region src/plugin/rules/architecture/no-giant-component.ts
|
|
19382
19829
|
const noGiantComponent = defineRule({
|
|
19383
19830
|
id: "no-giant-component",
|
|
@@ -19556,6 +20003,26 @@ const noGrayOnColoredBackground = defineRule({
|
|
|
19556
20003
|
} })
|
|
19557
20004
|
});
|
|
19558
20005
|
//#endregion
|
|
20006
|
+
//#region src/plugin/rules/performance/no-img-lazy-with-high-fetchpriority.ts
|
|
20007
|
+
const MESSAGE$20 = "`<img loading=\"lazy\">` defers the request while `fetchPriority=\"high\"` asks the browser to rush it, so the two directives contradict each other. Drop one: keep `fetchPriority=\"high\"` (and eager loading) for an LCP image, or `loading=\"lazy\"` for a below-the-fold one.";
|
|
20008
|
+
const noImgLazyWithHighFetchpriority = defineRule({
|
|
20009
|
+
id: "no-img-lazy-with-high-fetchpriority",
|
|
20010
|
+
title: "Lazy image with high fetchPriority",
|
|
20011
|
+
severity: "warn",
|
|
20012
|
+
recommendation: "Don't combine `loading=\"lazy\"` with `fetchPriority=\"high\"`. A high-priority image (usually the LCP) should load eagerly; a lazy image is by definition not high priority.",
|
|
20013
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
20014
|
+
if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "img") return;
|
|
20015
|
+
const loadingAttribute = hasJsxPropIgnoreCase(node.attributes, "loading");
|
|
20016
|
+
if (!loadingAttribute || getJsxPropStringValue(loadingAttribute)?.toLowerCase() !== "lazy") return;
|
|
20017
|
+
const fetchPriorityAttribute = hasJsxPropIgnoreCase(node.attributes, "fetchPriority");
|
|
20018
|
+
if (!fetchPriorityAttribute || getJsxPropStringValue(fetchPriorityAttribute)?.toLowerCase() !== "high") return;
|
|
20019
|
+
context.report({
|
|
20020
|
+
node: node.name,
|
|
20021
|
+
message: MESSAGE$20
|
|
20022
|
+
});
|
|
20023
|
+
} })
|
|
20024
|
+
});
|
|
20025
|
+
//#endregion
|
|
19559
20026
|
//#region src/plugin/rules/state-and-effects/no-initialize-state.ts
|
|
19560
20027
|
const noInitializeState = defineRule({
|
|
19561
20028
|
id: "no-initialize-state",
|
|
@@ -19785,6 +20252,29 @@ const noIsMounted = defineRule({
|
|
|
19785
20252
|
} })
|
|
19786
20253
|
});
|
|
19787
20254
|
//#endregion
|
|
20255
|
+
//#region src/plugin/rules/js-performance/no-json-parse-stringify-clone.ts
|
|
20256
|
+
const MESSAGE$19 = "`JSON.parse(JSON.stringify(x))` deep-clones by re-serializing: it is slow on large objects and silently drops `undefined`, functions, `Date`/`Map`/`Set`, and cyclic references. Use `structuredClone(x)`.";
|
|
20257
|
+
const isJsonMethodCall = (node, method) => {
|
|
20258
|
+
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
20259
|
+
const callee = node.callee;
|
|
20260
|
+
return isNodeOfType(callee, "MemberExpression") && !callee.computed && isNodeOfType(callee.object, "Identifier") && callee.object.name === "JSON" && isNodeOfType(callee.property, "Identifier") && callee.property.name === method;
|
|
20261
|
+
};
|
|
20262
|
+
const noJsonParseStringifyClone = defineRule({
|
|
20263
|
+
id: "no-json-parse-stringify-clone",
|
|
20264
|
+
title: "JSON parse/stringify deep clone",
|
|
20265
|
+
severity: "warn",
|
|
20266
|
+
recommendation: "Replace `JSON.parse(JSON.stringify(value))` with `structuredClone(value)`. It is faster and preserves Dates, Maps, Sets, and cyclic references.",
|
|
20267
|
+
create: (context) => ({ CallExpression(node) {
|
|
20268
|
+
if (!isJsonMethodCall(node, "parse")) return;
|
|
20269
|
+
const firstArgument = node.arguments?.[0];
|
|
20270
|
+
if (!firstArgument || !isJsonMethodCall(firstArgument, "stringify")) return;
|
|
20271
|
+
context.report({
|
|
20272
|
+
node,
|
|
20273
|
+
message: MESSAGE$19
|
|
20274
|
+
});
|
|
20275
|
+
} })
|
|
20276
|
+
});
|
|
20277
|
+
//#endregion
|
|
19788
20278
|
//#region src/plugin/rules/correctness/no-jsx-element-type.ts
|
|
19789
20279
|
const MESSAGE$18 = "`JSX.Element` is too narrow: it excludes `null`, strings, numbers, and fragments that components commonly return. Use `React.ReactNode` instead.";
|
|
19790
20280
|
const isJsxElementTypeReference = (node) => {
|
|
@@ -20107,9 +20597,6 @@ const noLongTransitionDuration = defineRule({
|
|
|
20107
20597
|
const BOOLEAN_PROP_PREFIX_PATTERN = /^(?:is|has|should|can|show|hide|enable|disable|with)[A-Z]/;
|
|
20108
20598
|
const isBooleanPrefixedPropName = (propName) => BOOLEAN_PROP_PREFIX_PATTERN.test(propName);
|
|
20109
20599
|
//#endregion
|
|
20110
|
-
//#region src/plugin/utils/is-component-declaration.ts
|
|
20111
|
-
const isComponentDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && node.id !== null && Boolean(node.id?.name) && isUppercaseName(node.id.name);
|
|
20112
|
-
//#endregion
|
|
20113
20600
|
//#region src/plugin/rules/architecture/no-many-boolean-props.ts
|
|
20114
20601
|
const collectBooleanLikePropsFromBody = (componentBody, propsParamName) => {
|
|
20115
20602
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -22527,11 +23014,17 @@ const classifySecretFileExposure = (filename, options = {}) => {
|
|
|
22527
23014
|
return "unknown";
|
|
22528
23015
|
};
|
|
22529
23016
|
//#endregion
|
|
22530
|
-
//#region src/plugin/utils/
|
|
22531
|
-
const
|
|
22532
|
-
|
|
23017
|
+
//#region src/plugin/utils/tokenize-identifier-words.ts
|
|
23018
|
+
const IDENTIFIER_WORD_PATTERN = /[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|\d+/g;
|
|
23019
|
+
const tokenizeIdentifierWords = (identifierName) => {
|
|
23020
|
+
const words = identifierName.match(IDENTIFIER_WORD_PATTERN);
|
|
23021
|
+
if (!words) return [];
|
|
23022
|
+
return words.map((word) => word.toLowerCase());
|
|
22533
23023
|
};
|
|
22534
23024
|
//#endregion
|
|
23025
|
+
//#region src/plugin/utils/get-identifier-trailing-word.ts
|
|
23026
|
+
const getIdentifierTrailingWord = (identifierName) => tokenizeIdentifierWords(identifierName).at(-1) ?? identifierName.toLowerCase();
|
|
23027
|
+
//#endregion
|
|
22535
23028
|
//#region src/plugin/constants/tanstack.ts
|
|
22536
23029
|
const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
|
|
22537
23030
|
const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
|
|
@@ -27035,6 +27528,8 @@ const publicEnvSecretName = defineRule({
|
|
|
27035
27528
|
});
|
|
27036
27529
|
//#endregion
|
|
27037
27530
|
//#region src/plugin/rules/tanstack-query/query-destructure-result.ts
|
|
27531
|
+
const TANSTACK_QUERY_PACKAGE_PATTERN = /^@tanstack\/[\w-]*query[\w-]*$/;
|
|
27532
|
+
const isTanstackQuerySource = (source) => TANSTACK_QUERY_PACKAGE_PATTERN.test(source) || source === "react-query";
|
|
27038
27533
|
const queryDestructureResult = defineRule({
|
|
27039
27534
|
id: "query-destructure-result",
|
|
27040
27535
|
title: "Whole query result subscribes to every field",
|
|
@@ -27047,6 +27542,8 @@ const queryDestructureResult = defineRule({
|
|
|
27047
27542
|
if (!node.init || !isNodeOfType(node.init, "CallExpression")) return;
|
|
27048
27543
|
const calleeName = isNodeOfType(node.init.callee, "Identifier") ? node.init.callee.name : null;
|
|
27049
27544
|
if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
|
|
27545
|
+
const importSource = getImportSourceForName(node, calleeName);
|
|
27546
|
+
if (importSource !== null && !isTanstackQuerySource(importSource)) return;
|
|
27050
27547
|
context.report({
|
|
27051
27548
|
node: node.id,
|
|
27052
27549
|
message: `Destructure ${calleeName}() results instead of assigning the whole query object, so TanStack Query only subscribes to the fields you use.`
|
|
@@ -27889,6 +28386,7 @@ const repositorySecretFile = defineRule({
|
|
|
27889
28386
|
id: "repository-secret-file",
|
|
27890
28387
|
title: "Secret file checked into repository",
|
|
27891
28388
|
severity: "error",
|
|
28389
|
+
committedFilesOnly: true,
|
|
27892
28390
|
recommendation: "Remove committed env files, service-account credentials, npm auth tokens, and webhook URLs; rotate exposed values and keep only redacted examples in source.",
|
|
27893
28391
|
scan: (file) => {
|
|
27894
28392
|
if (!isRepositorySecretFilePath(file.relativePath)) return [];
|
|
@@ -27905,6 +28403,20 @@ const repositorySecretFile = defineRule({
|
|
|
27905
28403
|
}
|
|
27906
28404
|
});
|
|
27907
28405
|
//#endregion
|
|
28406
|
+
//#region src/plugin/rules/security-scan/request-body-mass-assignment.ts
|
|
28407
|
+
const REQUEST_INPUT_SOURCE = "(?:req|request|ctx\\.req|ctx\\.request)\\.(?:body|query|params)|await\\s+(?:req|request)\\.json\\(\\s*\\)";
|
|
28408
|
+
const requestBodyMassAssignment = defineRule({
|
|
28409
|
+
id: "request-body-mass-assignment",
|
|
28410
|
+
title: "Request input spread without field allowlist",
|
|
28411
|
+
severity: "warn",
|
|
28412
|
+
recommendation: "Assign explicit, allowlisted fields (or validate with a strict schema and no `.passthrough()`) instead of spreading/merging request input. Otherwise the client can set ownership, role, or price columns (mass assignment) or pollute the prototype.",
|
|
28413
|
+
scan: scanByPattern({
|
|
28414
|
+
shouldScan: (file) => isProductionSourcePath(file.relativePath),
|
|
28415
|
+
pattern: [new RegExp(`\\.\\.\\.\\s*(?:${REQUEST_INPUT_SOURCE})`, "i"), new RegExp(`(?:Object\\.assign\\s*\\(|_\\.(?:merge|mergeWith|defaultsDeep)\\s*\\(|(?:^|[^.\\w])(?:merge|defaultsDeep)\\s*\\()[\\s\\S]{0,80}?(?:${REQUEST_INPUT_SOURCE})`, "i")],
|
|
28416
|
+
message: "Request input is spread or merged into an object without a field allowlist, enabling mass assignment (client-set owner/role/price fields) or prototype pollution."
|
|
28417
|
+
})
|
|
28418
|
+
});
|
|
28419
|
+
//#endregion
|
|
27908
28420
|
//#region src/plugin/utils/function-body-has-return-with-value.ts
|
|
27909
28421
|
const functionBodyHasReturnWithValue = (functionNode) => {
|
|
27910
28422
|
if (functionNode.type === "ArrowFunctionExpression" && "body" in functionNode) {
|
|
@@ -34330,6 +34842,17 @@ const scope = defineRule({
|
|
|
34330
34842
|
});
|
|
34331
34843
|
} })
|
|
34332
34844
|
});
|
|
34845
|
+
const secretInFallback = defineRule({
|
|
34846
|
+
id: "secret-in-fallback",
|
|
34847
|
+
title: "Hardcoded secret fallback for env var",
|
|
34848
|
+
severity: "error",
|
|
34849
|
+
recommendation: "Remove the literal fallback and fail closed (throw when the variable is unset). The hardcoded value is a committed secret, and the `??`/`||` default makes the app run with it in any environment that forgot to set the var.",
|
|
34850
|
+
scan: scanByPattern({
|
|
34851
|
+
shouldScan: (file) => isProductionSourcePath(file.relativePath),
|
|
34852
|
+
pattern: /\bprocess\.env\.(?![A-Z0-9_]*(?:PUBLIC|PUBLISHABLE|ANON)\b)[A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_?KEY|APIKEY|ACCESS_KEY|CLIENT_SECRET|CREDENTIAL|SIGNING_KEY|ENCRYPTION_KEY|WEBHOOK_SECRET|SERVICE_ROLE)[A-Z0-9_]*(?<!_(?:NAME|HEADER|ENDPOINT|URL|URI|ID|PREFIX|SUFFIX|PARAM|PARAMS|FIELD|ISSUER|AUDIENCE|ALGORITHM|ALG|REGION|BUCKET|HOST|HOSTNAME|PORT|PATH|VERSION|SCOPE|TYPE|FORMAT|EXPIRY|TTL))\s*(?:\?\?|\|\|)\s*(["'`])(?!(?:changeme|change[_-]?me|placeholder|your[_-]|example|sample|dummy|development|local|todo|replace[_-]?me|https?:\/\/|x{3,}|\*{3,}))[^"'`\n]{8,}\1/i,
|
|
34853
|
+
message: "A secret env var has a hardcoded string fallback: the literal is a committed secret and the app fails open (uses it) when the variable is unset."
|
|
34854
|
+
})
|
|
34855
|
+
});
|
|
34333
34856
|
//#endregion
|
|
34334
34857
|
//#region src/plugin/rules/react-builtins/self-closing-comp.ts
|
|
34335
34858
|
const MESSAGE$2 = "This tag has no children, so the closing tag adds noise without changing output.";
|
|
@@ -34448,6 +34971,47 @@ const serverAfterNonblocking = defineRule({
|
|
|
34448
34971
|
}
|
|
34449
34972
|
});
|
|
34450
34973
|
//#endregion
|
|
34974
|
+
//#region src/plugin/utils/is-auth-guard-name.ts
|
|
34975
|
+
const SIGNED_IN_HEAD_TOKENS = new Set([
|
|
34976
|
+
"signed",
|
|
34977
|
+
"logged",
|
|
34978
|
+
"sign"
|
|
34979
|
+
]);
|
|
34980
|
+
const mergeSignedInTokens = (tokens) => {
|
|
34981
|
+
const mergedTokens = [];
|
|
34982
|
+
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
|
|
34983
|
+
const currentToken = tokens[tokenIndex];
|
|
34984
|
+
if (SIGNED_IN_HEAD_TOKENS.has(currentToken) && tokens[tokenIndex + 1] === "in") {
|
|
34985
|
+
mergedTokens.push(`${currentToken}in`);
|
|
34986
|
+
tokenIndex += 1;
|
|
34987
|
+
continue;
|
|
34988
|
+
}
|
|
34989
|
+
mergedTokens.push(currentToken);
|
|
34990
|
+
}
|
|
34991
|
+
return mergedTokens;
|
|
34992
|
+
};
|
|
34993
|
+
const isAuthGuardName = (calleeName) => {
|
|
34994
|
+
const tokens = mergeSignedInTokens(tokenizeIdentifierWords(calleeName));
|
|
34995
|
+
if (tokens.length === 0) return false;
|
|
34996
|
+
let hasAssertiveVerb = false;
|
|
34997
|
+
let hasGetterVerb = false;
|
|
34998
|
+
let hasQualifier = false;
|
|
34999
|
+
let hasStrongNoun = false;
|
|
35000
|
+
let hasWeakNoun = false;
|
|
35001
|
+
for (const token of tokens) {
|
|
35002
|
+
if (AUTH_STRONG_TOKEN_PATTERN.test(token) || AUTH_STANDALONE_NOUN_TOKENS.has(token)) return true;
|
|
35003
|
+
if (AUTH_ASSERTIVE_VERB_TOKENS.has(token)) hasAssertiveVerb = true;
|
|
35004
|
+
if (AUTH_GETTER_VERB_TOKENS.has(token)) hasGetterVerb = true;
|
|
35005
|
+
if (AUTH_QUALIFIER_TOKENS.has(token)) hasQualifier = true;
|
|
35006
|
+
if (AUTH_STRONG_NOUN_TOKENS.has(token)) hasStrongNoun = true;
|
|
35007
|
+
if (AUTH_WEAK_NOUN_TOKENS.has(token)) hasWeakNoun = true;
|
|
35008
|
+
}
|
|
35009
|
+
if (hasAssertiveVerb && (hasStrongNoun || hasWeakNoun)) return true;
|
|
35010
|
+
if (hasGetterVerb && hasStrongNoun) return true;
|
|
35011
|
+
if (hasQualifier && hasWeakNoun) return true;
|
|
35012
|
+
return false;
|
|
35013
|
+
};
|
|
35014
|
+
//#endregion
|
|
34451
35015
|
//#region src/plugin/rules/server/server-auth-actions.ts
|
|
34452
35016
|
const isAsyncFunctionLikeNode = (node) => {
|
|
34453
35017
|
if (!node) return false;
|
|
@@ -34490,9 +35054,13 @@ const isMemberCallAuthRelated = (receiverNode, methodName, genericMethodNames) =
|
|
|
34490
35054
|
const getAuthCallName = (callExpression, allowedFunctionNames, genericMethodNames) => {
|
|
34491
35055
|
const calleeNode = unwrapTypeWrappedCallee(callExpression.callee);
|
|
34492
35056
|
if (!calleeNode) return null;
|
|
34493
|
-
if (isNodeOfType(calleeNode, "Identifier"))
|
|
35057
|
+
if (isNodeOfType(calleeNode, "Identifier")) {
|
|
35058
|
+
const calleeName = calleeNode.name;
|
|
35059
|
+
return allowedFunctionNames.has(calleeName) || isAuthGuardName(calleeName) ? calleeName : null;
|
|
35060
|
+
}
|
|
34494
35061
|
if (isNodeOfType(calleeNode, "MemberExpression") && isNodeOfType(calleeNode.property, "Identifier")) {
|
|
34495
35062
|
const methodName = calleeNode.property.name;
|
|
35063
|
+
if (isAuthGuardName(methodName)) return methodName;
|
|
34496
35064
|
if (!allowedFunctionNames.has(methodName)) return null;
|
|
34497
35065
|
if (!isMemberCallAuthRelated(calleeNode.object, methodName, genericMethodNames)) return null;
|
|
34498
35066
|
return methodName;
|
|
@@ -35139,8 +35707,11 @@ const supabaseClientOwnedAuthzField = defineRule({
|
|
|
35139
35707
|
})
|
|
35140
35708
|
});
|
|
35141
35709
|
//#endregion
|
|
35710
|
+
//#region src/plugin/rules/security-scan/utils/is-supabase-migration-path.ts
|
|
35711
|
+
const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
|
|
35712
|
+
//#endregion
|
|
35142
35713
|
//#region src/plugin/rules/security-scan/utils/is-sql-path.ts
|
|
35143
|
-
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") ||
|
|
35714
|
+
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
|
|
35144
35715
|
const supabaseRlsPolicyRisk = defineRule({
|
|
35145
35716
|
id: "supabase-rls-policy-risk",
|
|
35146
35717
|
title: "Permissive Supabase RLS policy",
|
|
@@ -35158,6 +35729,210 @@ const supabaseRlsPolicyRisk = defineRule({
|
|
|
35158
35729
|
})
|
|
35159
35730
|
});
|
|
35160
35731
|
//#endregion
|
|
35732
|
+
//#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
|
|
35733
|
+
const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
|
|
35734
|
+
const CODE_BODY_KEYWORDS = new Set([
|
|
35735
|
+
"do",
|
|
35736
|
+
"plpgsql",
|
|
35737
|
+
"sql",
|
|
35738
|
+
"plpython3u",
|
|
35739
|
+
"plpythonu",
|
|
35740
|
+
"plperl",
|
|
35741
|
+
"plperlu",
|
|
35742
|
+
"plv8"
|
|
35743
|
+
]);
|
|
35744
|
+
const precedingKeyword = (content, beforeIndex) => {
|
|
35745
|
+
let lookBack = beforeIndex - 1;
|
|
35746
|
+
while (lookBack >= 0 && /\s/.test(content[lookBack] ?? "")) lookBack -= 1;
|
|
35747
|
+
let wordStart = lookBack;
|
|
35748
|
+
while (wordStart >= 0 && /[A-Za-z0-9_]/.test(content[wordStart] ?? "")) wordStart -= 1;
|
|
35749
|
+
return content.slice(wordStart + 1, lookBack + 1).toLowerCase();
|
|
35750
|
+
};
|
|
35751
|
+
const blankCodeBodyInterior = (content, characters, start, end) => {
|
|
35752
|
+
let index = start;
|
|
35753
|
+
let inExecuteStatement = false;
|
|
35754
|
+
while (index < end) {
|
|
35755
|
+
const character = content[index];
|
|
35756
|
+
if (character === ";") {
|
|
35757
|
+
inExecuteStatement = false;
|
|
35758
|
+
index += 1;
|
|
35759
|
+
continue;
|
|
35760
|
+
}
|
|
35761
|
+
if (/[A-Za-z_]/.test(character)) {
|
|
35762
|
+
let wordEnd = index;
|
|
35763
|
+
while (wordEnd < end && /[A-Za-z0-9_]/.test(content[wordEnd] ?? "")) wordEnd += 1;
|
|
35764
|
+
if (content.slice(index, wordEnd).toLowerCase() === "execute") inExecuteStatement = true;
|
|
35765
|
+
index = wordEnd;
|
|
35766
|
+
continue;
|
|
35767
|
+
}
|
|
35768
|
+
if (character === "'") {
|
|
35769
|
+
const keepVisible = inExecuteStatement;
|
|
35770
|
+
if (!keepVisible) characters[index] = " ";
|
|
35771
|
+
index += 1;
|
|
35772
|
+
while (index < end) {
|
|
35773
|
+
if (content[index] === "'") {
|
|
35774
|
+
if (content[index + 1] === "'") {
|
|
35775
|
+
if (!keepVisible) {
|
|
35776
|
+
characters[index] = " ";
|
|
35777
|
+
characters[index + 1] = " ";
|
|
35778
|
+
}
|
|
35779
|
+
index += 2;
|
|
35780
|
+
continue;
|
|
35781
|
+
}
|
|
35782
|
+
if (!keepVisible) characters[index] = " ";
|
|
35783
|
+
index += 1;
|
|
35784
|
+
break;
|
|
35785
|
+
}
|
|
35786
|
+
if (!keepVisible && content[index] !== "\n") characters[index] = " ";
|
|
35787
|
+
index += 1;
|
|
35788
|
+
}
|
|
35789
|
+
continue;
|
|
35790
|
+
}
|
|
35791
|
+
if (character === "\"") {
|
|
35792
|
+
index += 1;
|
|
35793
|
+
while (index < end) {
|
|
35794
|
+
if (content[index] === "\"") {
|
|
35795
|
+
if (content[index + 1] === "\"") {
|
|
35796
|
+
index += 2;
|
|
35797
|
+
continue;
|
|
35798
|
+
}
|
|
35799
|
+
index += 1;
|
|
35800
|
+
break;
|
|
35801
|
+
}
|
|
35802
|
+
index += 1;
|
|
35803
|
+
}
|
|
35804
|
+
continue;
|
|
35805
|
+
}
|
|
35806
|
+
if (character === "-" && content[index + 1] === "-") {
|
|
35807
|
+
while (index < end && content[index] !== "\n") {
|
|
35808
|
+
characters[index] = " ";
|
|
35809
|
+
index += 1;
|
|
35810
|
+
}
|
|
35811
|
+
continue;
|
|
35812
|
+
}
|
|
35813
|
+
if (character === "/" && content[index + 1] === "*") {
|
|
35814
|
+
while (index < end) {
|
|
35815
|
+
if (content[index] === "*" && content[index + 1] === "/") {
|
|
35816
|
+
characters[index] = " ";
|
|
35817
|
+
characters[index + 1] = " ";
|
|
35818
|
+
index += 2;
|
|
35819
|
+
break;
|
|
35820
|
+
}
|
|
35821
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
35822
|
+
index += 1;
|
|
35823
|
+
}
|
|
35824
|
+
continue;
|
|
35825
|
+
}
|
|
35826
|
+
index += 1;
|
|
35827
|
+
}
|
|
35828
|
+
};
|
|
35829
|
+
const sanitizeSqlForScan = (content) => {
|
|
35830
|
+
const characters = content.split("");
|
|
35831
|
+
let index = 0;
|
|
35832
|
+
while (index < content.length) {
|
|
35833
|
+
const character = content[index];
|
|
35834
|
+
if (character === "-" && content[index + 1] === "-") {
|
|
35835
|
+
while (index < content.length && content[index] !== "\n") {
|
|
35836
|
+
characters[index] = " ";
|
|
35837
|
+
index += 1;
|
|
35838
|
+
}
|
|
35839
|
+
continue;
|
|
35840
|
+
}
|
|
35841
|
+
if (character === "/" && content[index + 1] === "*") {
|
|
35842
|
+
while (index < content.length) {
|
|
35843
|
+
if (content[index] === "*" && content[index + 1] === "/") {
|
|
35844
|
+
characters[index] = " ";
|
|
35845
|
+
characters[index + 1] = " ";
|
|
35846
|
+
index += 2;
|
|
35847
|
+
break;
|
|
35848
|
+
}
|
|
35849
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
35850
|
+
index += 1;
|
|
35851
|
+
}
|
|
35852
|
+
continue;
|
|
35853
|
+
}
|
|
35854
|
+
if (character === "'") {
|
|
35855
|
+
characters[index] = " ";
|
|
35856
|
+
index += 1;
|
|
35857
|
+
while (index < content.length) {
|
|
35858
|
+
if (content[index] === "'") {
|
|
35859
|
+
if (content[index + 1] === "'") {
|
|
35860
|
+
characters[index] = " ";
|
|
35861
|
+
characters[index + 1] = " ";
|
|
35862
|
+
index += 2;
|
|
35863
|
+
continue;
|
|
35864
|
+
}
|
|
35865
|
+
characters[index] = " ";
|
|
35866
|
+
index += 1;
|
|
35867
|
+
break;
|
|
35868
|
+
}
|
|
35869
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
35870
|
+
index += 1;
|
|
35871
|
+
}
|
|
35872
|
+
continue;
|
|
35873
|
+
}
|
|
35874
|
+
if (character === "$") {
|
|
35875
|
+
const tagMatch = DOLLAR_QUOTE_TAG_PATTERN.exec(content.slice(index));
|
|
35876
|
+
if (tagMatch !== null) {
|
|
35877
|
+
const tag = tagMatch[0];
|
|
35878
|
+
const closeIndex = content.indexOf(tag, index + tag.length);
|
|
35879
|
+
const endIndex = closeIndex < 0 ? content.length : closeIndex + tag.length;
|
|
35880
|
+
const keyword = precedingKeyword(content, index);
|
|
35881
|
+
if (CODE_BODY_KEYWORDS.has(keyword)) blankCodeBodyInterior(content, characters, index + tag.length, endIndex);
|
|
35882
|
+
else for (let blankIndex = index; blankIndex < endIndex; blankIndex += 1) if (content[blankIndex] !== "\n") characters[blankIndex] = " ";
|
|
35883
|
+
index = endIndex;
|
|
35884
|
+
continue;
|
|
35885
|
+
}
|
|
35886
|
+
}
|
|
35887
|
+
if (character === "\"") {
|
|
35888
|
+
index += 1;
|
|
35889
|
+
while (index < content.length) {
|
|
35890
|
+
if (content[index] === "\"") {
|
|
35891
|
+
if (content[index + 1] === "\"") {
|
|
35892
|
+
index += 2;
|
|
35893
|
+
continue;
|
|
35894
|
+
}
|
|
35895
|
+
index += 1;
|
|
35896
|
+
break;
|
|
35897
|
+
}
|
|
35898
|
+
index += 1;
|
|
35899
|
+
}
|
|
35900
|
+
continue;
|
|
35901
|
+
}
|
|
35902
|
+
index += 1;
|
|
35903
|
+
}
|
|
35904
|
+
return characters.join("");
|
|
35905
|
+
};
|
|
35906
|
+
//#endregion
|
|
35907
|
+
//#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
|
|
35908
|
+
const CREATE_PUBLIC_TABLE_PATTERN = /create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?(?!(?:auth|storage|realtime|vault|extensions|graphql|graphql_public|pgbouncer|net|supabase_functions|supabase_migrations|cron|pgsodium|pgmq|information_schema|pg_catalog|pg_temp|private|internal)\s*\.)(?:public\s*\.\s*)?["`]?([A-Za-z_][\w$]*)["`]?(?:\s*\(|\s+as\b)/gi;
|
|
35909
|
+
const enableRlsForTablePattern = (tableName) => new RegExp(`alter\\s+table\\s+(?:if\\s+exists\\s+)?(?:only\\s+)?(?:public\\s*\\.\\s*)?["\`]?${escapeRegExp(tableName)}["\`]?\\s+(?:force\\s+)?enable\\s+row\\s+level\\s+security`, "i");
|
|
35910
|
+
const supabaseTableMissingRls = defineRule({
|
|
35911
|
+
id: "supabase-table-missing-rls",
|
|
35912
|
+
title: "Supabase table created without Row Level Security",
|
|
35913
|
+
severity: "error",
|
|
35914
|
+
recommendation: "Enable RLS in the same migration (`alter table <name> enable row level security;`) and add `auth.uid()`-scoped policies for select/insert/update/delete. A public table without RLS is fully readable and writable with the public anon key.",
|
|
35915
|
+
scan: (file) => {
|
|
35916
|
+
if (!isSupabaseMigrationPath(file.relativePath)) return [];
|
|
35917
|
+
const content = sanitizeSqlForScan(file.content);
|
|
35918
|
+
if (!/create\s+(?:unlogged\s+)?table/i.test(content)) return [];
|
|
35919
|
+
const findings = [];
|
|
35920
|
+
CREATE_PUBLIC_TABLE_PATTERN.lastIndex = 0;
|
|
35921
|
+
for (let match = CREATE_PUBLIC_TABLE_PATTERN.exec(content); match !== null; match = CREATE_PUBLIC_TABLE_PATTERN.exec(content)) {
|
|
35922
|
+
const tableName = match[1];
|
|
35923
|
+
if (tableName === void 0) continue;
|
|
35924
|
+
if (enableRlsForTablePattern(tableName).test(content.slice(match.index))) continue;
|
|
35925
|
+
const location = getLocationAtIndex(content, match.index);
|
|
35926
|
+
findings.push({
|
|
35927
|
+
message: "Supabase migration creates a public table but never enables Row Level Security, leaving every row exposed to the anon key.",
|
|
35928
|
+
line: location.line,
|
|
35929
|
+
column: location.column
|
|
35930
|
+
});
|
|
35931
|
+
}
|
|
35932
|
+
return findings;
|
|
35933
|
+
}
|
|
35934
|
+
});
|
|
35935
|
+
//#endregion
|
|
35161
35936
|
//#region src/plugin/rules/security-scan/svg-filter-clickjacking-risk.ts
|
|
35162
35937
|
const svgFilterClickjackingRisk = defineRule({
|
|
35163
35938
|
id: "svg-filter-clickjacking-risk",
|
|
@@ -35856,6 +36631,47 @@ const tenantStaticProxyRisk = defineRule({
|
|
|
35856
36631
|
})
|
|
35857
36632
|
});
|
|
35858
36633
|
//#endregion
|
|
36634
|
+
//#region src/plugin/rules/security-scan/unsafe-json-in-html.ts
|
|
36635
|
+
const SINK_JSON_STRINGIFY_PATTERNS = [/dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:[\s\S]{0,300}?\bJSON\.stringify\s*\(/gi, /<script\b[^>]*>(?:(?!<\/script>)[\s\S]){0,300}?\bJSON\.stringify\s*\(/gi];
|
|
36636
|
+
const RETURN_ESCAPE_PATTERN = /^[\s)]*\.replace\s*\([^)]*(?:\\u003[cC]|<|<)/;
|
|
36637
|
+
const ESCAPE_WRAPPER_PATTERN = /(?:\b(?:escapeHtml|escapeJSON|escapeJson|htmlEscape|jsesc)|(?<![.\w])(?:serialize|serializeJavascript|devalue|uneval|superjson))\s*\(\s*$/i;
|
|
36638
|
+
const JSON_STRINGIFY_TOKEN_PATTERN = /\bJSON\.stringify\s*\($/i;
|
|
36639
|
+
const RETURN_LOOKAHEAD_CHARS = 160;
|
|
36640
|
+
const unsafeJsonInHtml = defineRule({
|
|
36641
|
+
id: "unsafe-json-in-html",
|
|
36642
|
+
title: "Unescaped JSON in HTML or script sink",
|
|
36643
|
+
severity: "warn",
|
|
36644
|
+
recommendation: "JSON.stringify does not HTML-escape, so a `<\/script>` (or `<`) in the data breaks out and becomes XSS. Use an HTML-safe serializer (serialize-javascript, devalue) or escape `<`, `>`, and `&`, or pass data via a JSON `<script type=\"application/json\">` read with JSON.parse.",
|
|
36645
|
+
scan: (file) => {
|
|
36646
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
36647
|
+
const content = getScannableContent(file);
|
|
36648
|
+
if (!content.includes("JSON.stringify")) return [];
|
|
36649
|
+
const findings = [];
|
|
36650
|
+
const seenIndices = /* @__PURE__ */ new Set();
|
|
36651
|
+
for (const pattern of SINK_JSON_STRINGIFY_PATTERNS) {
|
|
36652
|
+
pattern.lastIndex = 0;
|
|
36653
|
+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
|
|
36654
|
+
const beforeStringify = match[0].replace(JSON_STRINGIFY_TOKEN_PATTERN, "");
|
|
36655
|
+
if (ESCAPE_WRAPPER_PATTERN.test(beforeStringify)) continue;
|
|
36656
|
+
const closeParenIndex = findMatchingBracket(content, match.index + match[0].length - 1);
|
|
36657
|
+
if (closeParenIndex >= 0) {
|
|
36658
|
+
const afterReturn = content.slice(closeParenIndex + 1, closeParenIndex + 1 + RETURN_LOOKAHEAD_CHARS);
|
|
36659
|
+
if (RETURN_ESCAPE_PATTERN.test(afterReturn)) continue;
|
|
36660
|
+
}
|
|
36661
|
+
if (seenIndices.has(match.index)) continue;
|
|
36662
|
+
seenIndices.add(match.index);
|
|
36663
|
+
const location = getLocationAtIndex(content, match.index);
|
|
36664
|
+
findings.push({
|
|
36665
|
+
message: "JSON.stringify is embedded in HTML/script markup without HTML-escaping; data containing `<\/script>` or `<` breaks out and becomes XSS.",
|
|
36666
|
+
line: location.line,
|
|
36667
|
+
column: location.column
|
|
36668
|
+
});
|
|
36669
|
+
}
|
|
36670
|
+
}
|
|
36671
|
+
return findings;
|
|
36672
|
+
}
|
|
36673
|
+
});
|
|
36674
|
+
//#endregion
|
|
35859
36675
|
//#region src/plugin/rules/security-scan/untrusted-redirect-following.ts
|
|
35860
36676
|
const OUTBOUND_FETCH_CALL_PATTERN = /(?:(?<![.\w$])fetch|\baxios\.\s*(?:get|post|put|delete|head)|\bgot|\bgot\.\s*(?:get|post))\s*\(\s*([^,)]+)/;
|
|
35861
36677
|
const CALLER_STYLE_URL_NAME_PATTERN = /\b(?:url|targetUrl|callbackUrl|redirectUrl|webhookUrl|companyUrl|websiteUrl|domainUrl|imageUrl|fetchUrl|next|return_to|returnTo|destination|location)\b/i;
|
|
@@ -36003,7 +36819,7 @@ const voidDomElementsNoChildren = defineRule({
|
|
|
36003
36819
|
//#region src/plugin/rules/security-scan/webhook-signature-risk.ts
|
|
36004
36820
|
const WEBHOOK_HANDLER_PATTERN = /(?:^|\/)[^/]*webhook[^/]*\/|(?:^|\/)[^/]*webhook[^/]*\.[cm]?[jt]s$|\bwebhook\b/i;
|
|
36005
36821
|
const WEBHOOK_ENTRYPOINT_PATTERN = /\b(?:export\s+(?:async\s+)?function\s+POST|export\s+const\s+(?:POST|handler|webhook)|webhookHandler|webhookRoute)\b/i;
|
|
36006
|
-
const WEBHOOK_SIGNATURE_VERIFICATION_PATTERN = /verifySignature|verify.*signature|verify\w*(?:Webhook|Auth)|constructEvent|createHmac|timingSafeEqual|svix|webhookSecret|stripe\.webhooks|["'][\w-]*signature["']
|
|
36822
|
+
const WEBHOOK_SIGNATURE_VERIFICATION_PATTERN = new RegExp(`${/verifySignature|verify.*signature|verify\w*(?:Webhook|Auth)|constructEvent|createHmac|timingSafeEqual|svix|webhookSecret|stripe\.webhooks|["'][\w-]*signature["']/.source}|${/\b[A-Za-z]{0,40}(?:verif|valid|check|assert|authenticat|compare|guard)[A-Za-z]{0,40}(?:secret|signature|hmac|webhook|digest)[A-Za-z]{0,40}\s*\(/.source}`, "i");
|
|
36007
36823
|
const OUTBOUND_WEBHOOK_URL_MENTION_PATTERN = /webhook[\s_-]?ur[il]\w*/gi;
|
|
36008
36824
|
const OUTBOUND_WEBHOOK_CONFIG_PATTERN = /process\.env\.\w*WEBHOOK_URL|\b(?:send|post|dispatch|publish|notify)\w*Webhook/;
|
|
36009
36825
|
const REQUEST_READ_PATTERN = /\b(?:req|request)\b/;
|
|
@@ -36663,6 +37479,17 @@ const reactDoctorRules = [
|
|
|
36663
37479
|
category: "Performance"
|
|
36664
37480
|
}
|
|
36665
37481
|
},
|
|
37482
|
+
{
|
|
37483
|
+
key: "react-doctor/auth-token-in-web-storage",
|
|
37484
|
+
id: "auth-token-in-web-storage",
|
|
37485
|
+
source: "react-doctor",
|
|
37486
|
+
originallyExternal: false,
|
|
37487
|
+
rule: {
|
|
37488
|
+
...authTokenInWebStorage,
|
|
37489
|
+
framework: "global",
|
|
37490
|
+
category: "Security"
|
|
37491
|
+
}
|
|
37492
|
+
},
|
|
36666
37493
|
{
|
|
36667
37494
|
key: "react-doctor/autocomplete-valid",
|
|
36668
37495
|
id: "autocomplete-valid",
|
|
@@ -36879,6 +37706,18 @@ const reactDoctorRules = [
|
|
|
36879
37706
|
requires: [...new Set(["react", ...noVagueButtonLabel.requires ?? []])]
|
|
36880
37707
|
}
|
|
36881
37708
|
},
|
|
37709
|
+
{
|
|
37710
|
+
key: "react-doctor/dialog-has-accessible-name",
|
|
37711
|
+
id: "dialog-has-accessible-name",
|
|
37712
|
+
source: "react-doctor",
|
|
37713
|
+
originallyExternal: false,
|
|
37714
|
+
rule: {
|
|
37715
|
+
...dialogHasAccessibleName,
|
|
37716
|
+
framework: "global",
|
|
37717
|
+
category: "Accessibility",
|
|
37718
|
+
requires: [...new Set(["react", ...dialogHasAccessibleName.requires ?? []])]
|
|
37719
|
+
}
|
|
37720
|
+
},
|
|
36882
37721
|
{
|
|
36883
37722
|
key: "react-doctor/display-name",
|
|
36884
37723
|
id: "display-name",
|
|
@@ -37164,6 +38003,18 @@ const reactDoctorRules = [
|
|
|
37164
38003
|
tags: [...new Set(["security-scan", ...insecureCryptoRisk.tags ?? []])]
|
|
37165
38004
|
}
|
|
37166
38005
|
},
|
|
38006
|
+
{
|
|
38007
|
+
key: "react-doctor/insecure-session-cookie",
|
|
38008
|
+
id: "insecure-session-cookie",
|
|
38009
|
+
source: "react-doctor",
|
|
38010
|
+
originallyExternal: false,
|
|
38011
|
+
rule: {
|
|
38012
|
+
...insecureSessionCookie,
|
|
38013
|
+
framework: "global",
|
|
38014
|
+
category: "Security",
|
|
38015
|
+
tags: [...new Set(["security-scan", ...insecureSessionCookie.tags ?? []])]
|
|
38016
|
+
}
|
|
38017
|
+
},
|
|
37167
38018
|
{
|
|
37168
38019
|
key: "react-doctor/interactive-supports-focus",
|
|
37169
38020
|
id: "interactive-supports-focus",
|
|
@@ -37606,6 +38457,18 @@ const reactDoctorRules = [
|
|
|
37606
38457
|
requires: [...new Set(["react", ...jsxPropsNoSpreading.requires ?? []])]
|
|
37607
38458
|
}
|
|
37608
38459
|
},
|
|
38460
|
+
{
|
|
38461
|
+
key: "react-doctor/jwt-insecure-verification",
|
|
38462
|
+
id: "jwt-insecure-verification",
|
|
38463
|
+
source: "react-doctor",
|
|
38464
|
+
originallyExternal: false,
|
|
38465
|
+
rule: {
|
|
38466
|
+
...jwtInsecureVerification,
|
|
38467
|
+
framework: "global",
|
|
38468
|
+
category: "Security",
|
|
38469
|
+
tags: [...new Set(["security-scan", ...jwtInsecureVerification.tags ?? []])]
|
|
38470
|
+
}
|
|
38471
|
+
},
|
|
37609
38472
|
{
|
|
37610
38473
|
key: "react-doctor/key-lifecycle-risk",
|
|
37611
38474
|
id: "key-lifecycle-risk",
|
|
@@ -38014,6 +38877,18 @@ const reactDoctorRules = [
|
|
|
38014
38877
|
requires: [...new Set(["react", ...noArrayIndexKey.requires ?? []])]
|
|
38015
38878
|
}
|
|
38016
38879
|
},
|
|
38880
|
+
{
|
|
38881
|
+
key: "react-doctor/no-async-effect-callback",
|
|
38882
|
+
id: "no-async-effect-callback",
|
|
38883
|
+
source: "react-doctor",
|
|
38884
|
+
originallyExternal: false,
|
|
38885
|
+
rule: {
|
|
38886
|
+
...noAsyncEffectCallback,
|
|
38887
|
+
framework: "global",
|
|
38888
|
+
category: "Bugs",
|
|
38889
|
+
requires: [...new Set(["react", ...noAsyncEffectCallback.requires ?? []])]
|
|
38890
|
+
}
|
|
38891
|
+
},
|
|
38017
38892
|
{
|
|
38018
38893
|
key: "react-doctor/no-autofocus",
|
|
38019
38894
|
id: "no-autofocus",
|
|
@@ -38037,6 +38912,18 @@ const reactDoctorRules = [
|
|
|
38037
38912
|
category: "Performance"
|
|
38038
38913
|
}
|
|
38039
38914
|
},
|
|
38915
|
+
{
|
|
38916
|
+
key: "react-doctor/no-call-component-as-function",
|
|
38917
|
+
id: "no-call-component-as-function",
|
|
38918
|
+
source: "react-doctor",
|
|
38919
|
+
originallyExternal: false,
|
|
38920
|
+
rule: {
|
|
38921
|
+
...noCallComponentAsFunction,
|
|
38922
|
+
framework: "global",
|
|
38923
|
+
category: "Bugs",
|
|
38924
|
+
requires: [...new Set(["react", ...noCallComponentAsFunction.requires ?? []])]
|
|
38925
|
+
}
|
|
38926
|
+
},
|
|
38040
38927
|
{
|
|
38041
38928
|
key: "react-doctor/no-cascading-set-state",
|
|
38042
38929
|
id: "no-cascading-set-state",
|
|
@@ -38097,6 +38984,18 @@ const reactDoctorRules = [
|
|
|
38097
38984
|
requires: [...new Set(["react", ...noCreateContextInRender.requires ?? []])]
|
|
38098
38985
|
}
|
|
38099
38986
|
},
|
|
38987
|
+
{
|
|
38988
|
+
key: "react-doctor/no-create-ref-in-function-component",
|
|
38989
|
+
id: "no-create-ref-in-function-component",
|
|
38990
|
+
source: "react-doctor",
|
|
38991
|
+
originallyExternal: false,
|
|
38992
|
+
rule: {
|
|
38993
|
+
...noCreateRefInFunctionComponent,
|
|
38994
|
+
framework: "global",
|
|
38995
|
+
category: "Bugs",
|
|
38996
|
+
requires: [...new Set(["react", ...noCreateRefInFunctionComponent.requires ?? []])]
|
|
38997
|
+
}
|
|
38998
|
+
},
|
|
38100
38999
|
{
|
|
38101
39000
|
key: "react-doctor/no-create-store-in-render",
|
|
38102
39001
|
id: "no-create-store-in-render",
|
|
@@ -38471,6 +39370,18 @@ const reactDoctorRules = [
|
|
|
38471
39370
|
category: "Accessibility"
|
|
38472
39371
|
}
|
|
38473
39372
|
},
|
|
39373
|
+
{
|
|
39374
|
+
key: "react-doctor/no-img-lazy-with-high-fetchpriority",
|
|
39375
|
+
id: "no-img-lazy-with-high-fetchpriority",
|
|
39376
|
+
source: "react-doctor",
|
|
39377
|
+
originallyExternal: false,
|
|
39378
|
+
rule: {
|
|
39379
|
+
...noImgLazyWithHighFetchpriority,
|
|
39380
|
+
framework: "global",
|
|
39381
|
+
category: "Performance",
|
|
39382
|
+
requires: [...new Set(["react", ...noImgLazyWithHighFetchpriority.requires ?? []])]
|
|
39383
|
+
}
|
|
39384
|
+
},
|
|
38474
39385
|
{
|
|
38475
39386
|
key: "react-doctor/no-initialize-state",
|
|
38476
39387
|
id: "no-initialize-state",
|
|
@@ -38541,6 +39452,17 @@ const reactDoctorRules = [
|
|
|
38541
39452
|
requires: [...new Set(["react", ...noIsMounted.requires ?? []])]
|
|
38542
39453
|
}
|
|
38543
39454
|
},
|
|
39455
|
+
{
|
|
39456
|
+
key: "react-doctor/no-json-parse-stringify-clone",
|
|
39457
|
+
id: "no-json-parse-stringify-clone",
|
|
39458
|
+
source: "react-doctor",
|
|
39459
|
+
originallyExternal: false,
|
|
39460
|
+
rule: {
|
|
39461
|
+
...noJsonParseStringifyClone,
|
|
39462
|
+
framework: "global",
|
|
39463
|
+
category: "Performance"
|
|
39464
|
+
}
|
|
39465
|
+
},
|
|
38544
39466
|
{
|
|
38545
39467
|
key: "react-doctor/no-jsx-element-type",
|
|
38546
39468
|
id: "no-jsx-element-type",
|
|
@@ -39756,6 +40678,18 @@ const reactDoctorRules = [
|
|
|
39756
40678
|
tags: [...new Set(["security-scan", ...repositorySecretFile.tags ?? []])]
|
|
39757
40679
|
}
|
|
39758
40680
|
},
|
|
40681
|
+
{
|
|
40682
|
+
key: "react-doctor/request-body-mass-assignment",
|
|
40683
|
+
id: "request-body-mass-assignment",
|
|
40684
|
+
source: "react-doctor",
|
|
40685
|
+
originallyExternal: false,
|
|
40686
|
+
rule: {
|
|
40687
|
+
...requestBodyMassAssignment,
|
|
40688
|
+
framework: "global",
|
|
40689
|
+
category: "Security",
|
|
40690
|
+
tags: [...new Set(["security-scan", ...requestBodyMassAssignment.tags ?? []])]
|
|
40691
|
+
}
|
|
40692
|
+
},
|
|
39759
40693
|
{
|
|
39760
40694
|
key: "react-doctor/require-render-return",
|
|
39761
40695
|
id: "require-render-return",
|
|
@@ -40344,6 +41278,18 @@ const reactDoctorRules = [
|
|
|
40344
41278
|
requires: [...new Set(["react", ...scope.requires ?? []])]
|
|
40345
41279
|
}
|
|
40346
41280
|
},
|
|
41281
|
+
{
|
|
41282
|
+
key: "react-doctor/secret-in-fallback",
|
|
41283
|
+
id: "secret-in-fallback",
|
|
41284
|
+
source: "react-doctor",
|
|
41285
|
+
originallyExternal: false,
|
|
41286
|
+
rule: {
|
|
41287
|
+
...secretInFallback,
|
|
41288
|
+
framework: "global",
|
|
41289
|
+
category: "Security",
|
|
41290
|
+
tags: [...new Set(["security-scan", ...secretInFallback.tags ?? []])]
|
|
41291
|
+
}
|
|
41292
|
+
},
|
|
40347
41293
|
{
|
|
40348
41294
|
key: "react-doctor/self-closing-comp",
|
|
40349
41295
|
id: "self-closing-comp",
|
|
@@ -40500,6 +41446,18 @@ const reactDoctorRules = [
|
|
|
40500
41446
|
tags: [...new Set(["security-scan", ...supabaseRlsPolicyRisk.tags ?? []])]
|
|
40501
41447
|
}
|
|
40502
41448
|
},
|
|
41449
|
+
{
|
|
41450
|
+
key: "react-doctor/supabase-table-missing-rls",
|
|
41451
|
+
id: "supabase-table-missing-rls",
|
|
41452
|
+
source: "react-doctor",
|
|
41453
|
+
originallyExternal: false,
|
|
41454
|
+
rule: {
|
|
41455
|
+
...supabaseTableMissingRls,
|
|
41456
|
+
framework: "global",
|
|
41457
|
+
category: "Security",
|
|
41458
|
+
tags: [...new Set(["security-scan", ...supabaseTableMissingRls.tags ?? []])]
|
|
41459
|
+
}
|
|
41460
|
+
},
|
|
40503
41461
|
{
|
|
40504
41462
|
key: "react-doctor/svg-filter-clickjacking-risk",
|
|
40505
41463
|
id: "svg-filter-clickjacking-risk",
|
|
@@ -40690,6 +41648,18 @@ const reactDoctorRules = [
|
|
|
40690
41648
|
tags: [...new Set(["security-scan", ...tenantStaticProxyRisk.tags ?? []])]
|
|
40691
41649
|
}
|
|
40692
41650
|
},
|
|
41651
|
+
{
|
|
41652
|
+
key: "react-doctor/unsafe-json-in-html",
|
|
41653
|
+
id: "unsafe-json-in-html",
|
|
41654
|
+
source: "react-doctor",
|
|
41655
|
+
originallyExternal: false,
|
|
41656
|
+
rule: {
|
|
41657
|
+
...unsafeJsonInHtml,
|
|
41658
|
+
framework: "global",
|
|
41659
|
+
category: "Security",
|
|
41660
|
+
tags: [...new Set(["security-scan", ...unsafeJsonInHtml.tags ?? []])]
|
|
41661
|
+
}
|
|
41662
|
+
},
|
|
40693
41663
|
{
|
|
40694
41664
|
key: "react-doctor/untrusted-redirect-following",
|
|
40695
41665
|
id: "untrusted-redirect-following",
|