oxlint-plugin-react-doctor 0.5.8-dev.ea00b1b → 0.5.8-dev.f69f216
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -1
- package/dist/index.js +181 -55
- package/package.json +5 -4
package/LICENSE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
MIT License
|
|
1
|
+
Modified MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2026 Million Software, Inc.
|
|
4
4
|
|
|
@@ -19,3 +19,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
21
|
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Our only modification is that the following uses require prior written
|
|
24
|
+
permission from the copyright holder. To request permission, contact
|
|
25
|
+
founders@million.dev.
|
|
26
|
+
|
|
27
|
+
1. Using the Software, its source code, or any derivative works thereof, in
|
|
28
|
+
whole or in part, as training, fine-tuning, or evaluation data, or as input
|
|
29
|
+
to any automated pipeline for training or improving any machine learning
|
|
30
|
+
model or AI system.
|
|
31
|
+
|
|
32
|
+
2. Selling the Software, or offering it to third parties as a paid, hosted, or
|
|
33
|
+
managed product or service (including any commercial API or SaaS offering)
|
|
34
|
+
whose value derives entirely or substantially from the Software.
|
package/dist/index.js
CHANGED
|
@@ -1176,6 +1176,20 @@ const getImportSourceForName = (contextNode, localIdentifierName) => {
|
|
|
1176
1176
|
if (!lookup) return null;
|
|
1177
1177
|
return lookup.get(localIdentifierName)?.source ?? null;
|
|
1178
1178
|
};
|
|
1179
|
+
const getImportBindingForName = (contextNode, localIdentifierName) => {
|
|
1180
|
+
const info = getImportLookup(contextNode)?.get(localIdentifierName);
|
|
1181
|
+
if (!info) return null;
|
|
1182
|
+
if (info.isNamespace) return {
|
|
1183
|
+
source: info.source,
|
|
1184
|
+
exportedName: null,
|
|
1185
|
+
isNamespace: true
|
|
1186
|
+
};
|
|
1187
|
+
return {
|
|
1188
|
+
source: info.source,
|
|
1189
|
+
exportedName: info.isDefault ? "default" : info.imported,
|
|
1190
|
+
isNamespace: false
|
|
1191
|
+
};
|
|
1192
|
+
};
|
|
1179
1193
|
//#endregion
|
|
1180
1194
|
//#region src/plugin/utils/find-variable-initializer.ts
|
|
1181
1195
|
const FUNCTION_LIKE_TYPES$1 = new Set([
|
|
@@ -10789,10 +10803,22 @@ const isWithinChildrenToArray = (jsxNode) => {
|
|
|
10789
10803
|
}
|
|
10790
10804
|
return false;
|
|
10791
10805
|
};
|
|
10806
|
+
const spreadCanOverwriteKey = (spreadAttribute) => {
|
|
10807
|
+
const argument = spreadAttribute.argument;
|
|
10808
|
+
if (!isNodeOfType(argument, "ObjectExpression")) return true;
|
|
10809
|
+
for (const property of argument.properties) {
|
|
10810
|
+
if (!isNodeOfType(property, "Property")) return true;
|
|
10811
|
+
if (property.computed) return true;
|
|
10812
|
+
const propertyKey = property.key;
|
|
10813
|
+
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "key") return true;
|
|
10814
|
+
if (isNodeOfType(propertyKey, "Literal") && String(propertyKey.value) === "key") return true;
|
|
10815
|
+
}
|
|
10816
|
+
return false;
|
|
10817
|
+
};
|
|
10792
10818
|
const checkKeyBeforeSpread = (context, openingElement) => {
|
|
10793
10819
|
let keyIndex = null;
|
|
10794
10820
|
let keyAttribute = null;
|
|
10795
|
-
let
|
|
10821
|
+
let lastOverwritingSpreadIndex = null;
|
|
10796
10822
|
for (let attributeIndex = 0; attributeIndex < openingElement.attributes.length; attributeIndex++) {
|
|
10797
10823
|
const attribute = openingElement.attributes[attributeIndex];
|
|
10798
10824
|
if (isNodeOfType(attribute, "JSXAttribute")) {
|
|
@@ -10800,11 +10826,9 @@ const checkKeyBeforeSpread = (context, openingElement) => {
|
|
|
10800
10826
|
keyIndex = attributeIndex;
|
|
10801
10827
|
keyAttribute = attribute;
|
|
10802
10828
|
}
|
|
10803
|
-
} else if (isNodeOfType(attribute, "JSXSpreadAttribute"))
|
|
10804
|
-
if (firstSpreadIndex === null) firstSpreadIndex = attributeIndex;
|
|
10805
|
-
}
|
|
10829
|
+
} else if (isNodeOfType(attribute, "JSXSpreadAttribute") && spreadCanOverwriteKey(attribute)) lastOverwritingSpreadIndex = attributeIndex;
|
|
10806
10830
|
}
|
|
10807
|
-
if (keyIndex !== null &&
|
|
10831
|
+
if (keyIndex !== null && lastOverwritingSpreadIndex !== null && lastOverwritingSpreadIndex > keyIndex && keyAttribute) context.report({
|
|
10808
10832
|
node: keyAttribute,
|
|
10809
10833
|
message: KEY_BEFORE_SPREAD
|
|
10810
10834
|
});
|
|
@@ -29463,6 +29487,19 @@ const REACT_NATIVE_TEXT_COMPONENTS = new Set([
|
|
|
29463
29487
|
"H5",
|
|
29464
29488
|
"H6"
|
|
29465
29489
|
]);
|
|
29490
|
+
const REACT_NATIVE_RAW_TEXT_HOST_COMPONENTS = new Set([
|
|
29491
|
+
"View",
|
|
29492
|
+
"ScrollView",
|
|
29493
|
+
"SafeAreaView",
|
|
29494
|
+
"KeyboardAvoidingView",
|
|
29495
|
+
"ImageBackground",
|
|
29496
|
+
"Modal",
|
|
29497
|
+
"Pressable",
|
|
29498
|
+
"TouchableOpacity",
|
|
29499
|
+
"TouchableHighlight",
|
|
29500
|
+
"TouchableWithoutFeedback",
|
|
29501
|
+
"TouchableNativeFeedback"
|
|
29502
|
+
]);
|
|
29466
29503
|
const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
29467
29504
|
"Text",
|
|
29468
29505
|
"Title",
|
|
@@ -29475,7 +29512,11 @@ const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
|
29475
29512
|
"Description",
|
|
29476
29513
|
"Body"
|
|
29477
29514
|
]);
|
|
29478
|
-
const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set([
|
|
29515
|
+
const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set([
|
|
29516
|
+
"Fragment",
|
|
29517
|
+
"fbt",
|
|
29518
|
+
"fbs"
|
|
29519
|
+
]);
|
|
29479
29520
|
const DEPRECATED_RN_MODULE_REPLACEMENTS = new Map([
|
|
29480
29521
|
["AsyncStorage", "@react-native-async-storage/async-storage"],
|
|
29481
29522
|
["Picker", "@react-native-picker/picker"],
|
|
@@ -30433,23 +30474,29 @@ const jsxRootForwardsChildrenIntoText = (jsxRoot, bindings, isTextHandlingElemen
|
|
|
30433
30474
|
return didForwardIntoText;
|
|
30434
30475
|
};
|
|
30435
30476
|
const isMeaningfulJsxChild = (child) => !isNodeOfType(child, "JSXText") || Boolean(child.value?.trim());
|
|
30436
|
-
const
|
|
30437
|
-
let
|
|
30477
|
+
const jsxRootForwardsChildren = (jsxRoot, bindings, isTextHandlingElement, countsAsForwardTarget) => {
|
|
30478
|
+
let didForward = false;
|
|
30438
30479
|
walkAst(jsxRoot, (node) => {
|
|
30439
|
-
if (
|
|
30480
|
+
if (didForward || isFunctionNode(node)) return false;
|
|
30440
30481
|
if (!isNodeOfType(node, "JSXElement") && !isNodeOfType(node, "JSXFragment")) return;
|
|
30441
30482
|
if (isNodeOfType(node, "JSXElement")) {
|
|
30442
30483
|
const elementName = resolveJsxElementName(node.openingElement);
|
|
30443
30484
|
if (elementName && isTextHandlingElement(elementName)) return false;
|
|
30444
|
-
if (!(node.children ?? []).some(isMeaningfulJsxChild) && (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings))) {
|
|
30445
|
-
|
|
30485
|
+
if (!(node.children ?? []).some(isMeaningfulJsxChild) && countsAsForwardTarget(node) && (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings))) {
|
|
30486
|
+
didForward = true;
|
|
30446
30487
|
return;
|
|
30447
30488
|
}
|
|
30448
30489
|
}
|
|
30449
|
-
|
|
30490
|
+
if (countsAsForwardTarget(node) && (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings))) didForward = true;
|
|
30450
30491
|
});
|
|
30451
|
-
return
|
|
30492
|
+
return didForward;
|
|
30452
30493
|
};
|
|
30494
|
+
const jsxRootRendersChildrenOutsideText = (jsxRoot, bindings, isTextHandlingElement) => jsxRootForwardsChildren(jsxRoot, bindings, isTextHandlingElement, () => true);
|
|
30495
|
+
const jsxRootRendersChildrenIntoNonTextHost = (jsxRoot, bindings, isTextHandlingElement, isNonTextHostElement) => jsxRootForwardsChildren(jsxRoot, bindings, isTextHandlingElement, (node) => {
|
|
30496
|
+
if (!isNodeOfType(node, "JSXElement")) return false;
|
|
30497
|
+
const elementName = resolveJsxElementName(node.openingElement);
|
|
30498
|
+
return elementName !== null && isNonTextHostElement(elementName);
|
|
30499
|
+
});
|
|
30453
30500
|
const resolveStyledFactoryBaseName = (definitionNode) => {
|
|
30454
30501
|
let current = stripParenExpression(definitionNode);
|
|
30455
30502
|
while (current) {
|
|
@@ -30486,49 +30533,71 @@ const resolveClassRenderFunction = (classNode) => {
|
|
|
30486
30533
|
}
|
|
30487
30534
|
return null;
|
|
30488
30535
|
};
|
|
30489
|
-
const
|
|
30490
|
-
if (!componentName || !isReactComponentName(componentName)) return;
|
|
30491
|
-
if (wrappers.has(componentName)) return;
|
|
30492
|
-
if (!definitionNode) return;
|
|
30536
|
+
const classifyChildrenForwarding = (definitionNode, isTextHandlingElement, isNonTextHostElement) => {
|
|
30493
30537
|
const unwrapped = unwrapComponentDefinition(definitionNode);
|
|
30494
30538
|
const styledBaseName = resolveStyledFactoryBaseName(unwrapped);
|
|
30495
|
-
if (styledBaseName
|
|
30496
|
-
|
|
30497
|
-
return;
|
|
30539
|
+
if (styledBaseName) {
|
|
30540
|
+
if (isTextHandlingElement(styledBaseName)) return "text";
|
|
30541
|
+
if (isNonTextHostElement(styledBaseName)) return "nonText";
|
|
30542
|
+
return "unknown";
|
|
30498
30543
|
}
|
|
30499
30544
|
const functionNode = resolveClassRenderFunction(unwrapped) ?? (isFunctionNode(unwrapped) ? unwrapped : null);
|
|
30500
|
-
if (!functionNode) return;
|
|
30545
|
+
if (!functionNode) return "unknown";
|
|
30501
30546
|
const bindings = resolveParamChildrenBindings(functionNode);
|
|
30502
30547
|
collectChildrenAliases(functionNode, bindings);
|
|
30503
30548
|
const jsxRoots = collectReturnedJsxRoots(functionNode);
|
|
30504
|
-
if (jsxRoots.some((jsxRoot) =>
|
|
30549
|
+
if (jsxRoots.some((jsxRoot) => jsxRootRendersChildrenIntoNonTextHost(jsxRoot, bindings, isTextHandlingElement, isNonTextHostElement))) return "nonText";
|
|
30550
|
+
if (jsxRoots.some((jsxRoot) => jsxRootRendersChildrenOutsideText(jsxRoot, bindings, isTextHandlingElement))) return "unknown";
|
|
30505
30551
|
for (const jsxRoot of jsxRoots) {
|
|
30506
30552
|
if (isNodeOfType(jsxRoot, "JSXElement")) {
|
|
30507
30553
|
const rootName = resolveJsxElementName(jsxRoot.openingElement);
|
|
30508
|
-
if (rootName && isTextHandlingElement(rootName))
|
|
30509
|
-
wrappers.add(componentName);
|
|
30510
|
-
return;
|
|
30511
|
-
}
|
|
30512
|
-
}
|
|
30513
|
-
if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) {
|
|
30514
|
-
wrappers.add(componentName);
|
|
30515
|
-
return;
|
|
30554
|
+
if (rootName && isTextHandlingElement(rootName)) return "text";
|
|
30516
30555
|
}
|
|
30556
|
+
if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) return "text";
|
|
30517
30557
|
}
|
|
30558
|
+
return "unknown";
|
|
30559
|
+
};
|
|
30560
|
+
const recordWrapperFromDeclaration = (componentName, definitionNode, isTextHandlingElement, isNonTextHostElement, wrappers, nonTextWrappers) => {
|
|
30561
|
+
if (!componentName || !isReactComponentName(componentName)) return;
|
|
30562
|
+
if (wrappers.has(componentName)) return;
|
|
30563
|
+
if (!definitionNode) return;
|
|
30564
|
+
const kind = classifyChildrenForwarding(definitionNode, isTextHandlingElement, isNonTextHostElement);
|
|
30565
|
+
if (kind === "text") wrappers.add(componentName);
|
|
30566
|
+
else if (kind === "nonText") nonTextWrappers.add(componentName);
|
|
30518
30567
|
};
|
|
30519
30568
|
const MAX_TRANSITIVE_WRAPPER_PASSES = 3;
|
|
30520
|
-
const collectTextWrapperComponents = (programNode, isTextHandlingRoot) => {
|
|
30569
|
+
const collectTextWrapperComponents = (programNode, isTextHandlingRoot, isNonTextHostRoot) => {
|
|
30521
30570
|
const wrappers = /* @__PURE__ */ new Set();
|
|
30571
|
+
const nonTextWrappers = /* @__PURE__ */ new Set();
|
|
30522
30572
|
const isTextHandlingElement = (elementName) => isTextHandlingRoot(elementName) || wrappers.has(elementName);
|
|
30573
|
+
const isNonTextHostElement = (elementName) => isNonTextHostRoot(elementName) || nonTextWrappers.has(elementName);
|
|
30574
|
+
const recordDeclaration = (componentName, definitionNode) => recordWrapperFromDeclaration(componentName, definitionNode, isTextHandlingElement, isNonTextHostElement, wrappers, nonTextWrappers);
|
|
30523
30575
|
for (let pass = 0; pass < MAX_TRANSITIVE_WRAPPER_PASSES; pass += 1) {
|
|
30524
|
-
const
|
|
30576
|
+
const wrappersSizeBeforePass = wrappers.size;
|
|
30577
|
+
const nonTextSizeBeforePass = nonTextWrappers.size;
|
|
30525
30578
|
walkAst(programNode, (node) => {
|
|
30526
|
-
if (isNodeOfType(node, "VariableDeclarator"))
|
|
30527
|
-
else if (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "ClassDeclaration"))
|
|
30579
|
+
if (isNodeOfType(node, "VariableDeclarator")) recordDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node.init ?? null);
|
|
30580
|
+
else if (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "ClassDeclaration")) recordDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node);
|
|
30528
30581
|
});
|
|
30529
|
-
if (wrappers.size ===
|
|
30582
|
+
if (wrappers.size === wrappersSizeBeforePass && nonTextWrappers.size === nonTextSizeBeforePass) break;
|
|
30530
30583
|
}
|
|
30531
|
-
|
|
30584
|
+
for (const wrapperName of wrappers) nonTextWrappers.delete(wrapperName);
|
|
30585
|
+
return {
|
|
30586
|
+
textWrappers: wrappers,
|
|
30587
|
+
nonTextWrappers
|
|
30588
|
+
};
|
|
30589
|
+
};
|
|
30590
|
+
//#endregion
|
|
30591
|
+
//#region src/plugin/rules/react-native/utils/resolve-imported-component-forwarding.ts
|
|
30592
|
+
const resolveImportedComponentForwarding = (contextNode, fromFilename, localName, isTextHandlingRoot, isNonTextHostRoot) => {
|
|
30593
|
+
const binding = getImportBindingForName(contextNode, localName);
|
|
30594
|
+
if (!binding || binding.isNamespace || binding.exportedName === null) return null;
|
|
30595
|
+
const resolvedNode = resolveCrossFileFunctionExport(fromFilename, binding.source, binding.exportedName);
|
|
30596
|
+
if (!resolvedNode) return null;
|
|
30597
|
+
const moduleProgram = findProgramRoot(resolvedNode);
|
|
30598
|
+
if (moduleProgram === null) return classifyChildrenForwarding(resolvedNode, isTextHandlingRoot, isNonTextHostRoot);
|
|
30599
|
+
const { textWrappers, nonTextWrappers } = collectTextWrapperComponents(moduleProgram, isTextHandlingRoot, isNonTextHostRoot);
|
|
30600
|
+
return classifyChildrenForwarding(resolvedNode, (elementName) => isTextHandlingRoot(elementName) || textWrappers.has(elementName), (elementName) => isNonTextHostRoot(elementName) || nonTextWrappers.has(elementName));
|
|
30532
30601
|
};
|
|
30533
30602
|
//#endregion
|
|
30534
30603
|
//#region src/plugin/rules/react-native/utils/is-expo-ui-component-element.ts
|
|
@@ -30599,19 +30668,37 @@ const rnNoRawText = defineRule({
|
|
|
30599
30668
|
recommendation: "Text outside a `<Text>` component crashes on React Native. Wrap it like `<Text>{value}</Text>`.",
|
|
30600
30669
|
create: (context) => {
|
|
30601
30670
|
let isDomComponentFile = false;
|
|
30602
|
-
let
|
|
30671
|
+
let autoDetectedTextWrappers = /* @__PURE__ */ new Set();
|
|
30672
|
+
let autoDetectedNonTextWrappers = /* @__PURE__ */ new Set();
|
|
30673
|
+
const isNonTextHostName = (elementName) => !isReactComponentName(elementName) || REACT_NATIVE_RAW_TEXT_HOST_COMPONENTS.has(elementName);
|
|
30674
|
+
const isRawTextReportTarget = (elementName) => elementName !== null && (isNonTextHostName(elementName) || autoDetectedNonTextWrappers.has(elementName));
|
|
30675
|
+
const importedNonTextWrapperCache = /* @__PURE__ */ new Map();
|
|
30676
|
+
const isImportedNonTextWrapper = (elementName, contextNode) => {
|
|
30677
|
+
if (elementName === null || !isReactComponentName(elementName)) return false;
|
|
30678
|
+
const { filename } = context;
|
|
30679
|
+
if (filename === void 0) return false;
|
|
30680
|
+
const cached = importedNonTextWrapperCache.get(elementName);
|
|
30681
|
+
if (cached !== void 0) return cached;
|
|
30682
|
+
const isNonTextWrapper = resolveImportedComponentForwarding(contextNode, filename, elementName, isTextHandlingComponent, isNonTextHostName) === "nonText";
|
|
30683
|
+
importedNonTextWrapperCache.set(elementName, isNonTextWrapper);
|
|
30684
|
+
return isNonTextWrapper;
|
|
30685
|
+
};
|
|
30603
30686
|
return {
|
|
30604
30687
|
Program(programNode) {
|
|
30605
30688
|
isDomComponentFile = hasDirective(programNode, "use dom");
|
|
30606
|
-
|
|
30689
|
+
const childrenForwarding = collectTextWrapperComponents(programNode, isTextHandlingComponent, isNonTextHostName);
|
|
30690
|
+
autoDetectedTextWrappers = childrenForwarding.textWrappers;
|
|
30691
|
+
autoDetectedNonTextWrappers = childrenForwarding.nonTextWrappers;
|
|
30607
30692
|
},
|
|
30608
30693
|
JSXElement(node) {
|
|
30609
30694
|
if (isDomComponentFile) return;
|
|
30610
30695
|
const elementName = resolveTextBoundaryName(node.openingElement);
|
|
30611
|
-
if (elementName && (isTextHandlingComponent(elementName) ||
|
|
30696
|
+
if (elementName && (isTextHandlingComponent(elementName) || autoDetectedTextWrappers.has(elementName))) return;
|
|
30612
30697
|
if (isExpoUiComponentElement(node.openingElement, node, "ListItem")) return;
|
|
30613
30698
|
if (isInsidePlatformOsWebBranch(node)) return;
|
|
30614
30699
|
if (isTransparentTextWrapper(elementName) && isInsideTextHandlingComponent(node)) return;
|
|
30700
|
+
if (!(node.children ?? []).some(isRawTextContent)) return;
|
|
30701
|
+
if (!isRawTextReportTarget(elementName) && !isImportedNonTextWrapper(elementName, node)) return;
|
|
30615
30702
|
for (const child of node.children ?? []) {
|
|
30616
30703
|
if (!isRawTextContent(child)) continue;
|
|
30617
30704
|
context.report({
|
|
@@ -35854,22 +35941,6 @@ const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migratio
|
|
|
35854
35941
|
//#endregion
|
|
35855
35942
|
//#region src/plugin/rules/security-scan/utils/is-sql-path.ts
|
|
35856
35943
|
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
|
|
35857
|
-
const supabaseRlsPolicyRisk = defineRule({
|
|
35858
|
-
id: "supabase-rls-policy-risk",
|
|
35859
|
-
title: "Permissive Supabase RLS policy",
|
|
35860
|
-
severity: "error",
|
|
35861
|
-
recommendation: "Keep public-read policies explicit, but gate inserts, updates, deletes, and service-role bypasses behind `auth.uid()` plus trusted tenant membership.",
|
|
35862
|
-
scan: scanByPattern({
|
|
35863
|
-
shouldScan: (file) => isSqlPath(file.relativePath),
|
|
35864
|
-
pattern: [
|
|
35865
|
-
/disable\s+row\s+level\s+security/i,
|
|
35866
|
-
/create\s+policy[\s\S]{0,700}auth\.role\(\)\s*=\s*["']service_role["']/i,
|
|
35867
|
-
/create\s+policy[\s\S]{0,700}\bfor\s+(?:all|insert|update|delete)\b[\s\S]{0,500}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i,
|
|
35868
|
-
/create\s+policy(?:(?!\bfor\s+select\b)[\s\S]){0,700}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i
|
|
35869
|
-
],
|
|
35870
|
-
message: "Supabase policy SQL disables RLS, permits writes broadly, or references a service-role bypass."
|
|
35871
|
-
})
|
|
35872
|
-
});
|
|
35873
35944
|
//#endregion
|
|
35874
35945
|
//#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
|
|
35875
35946
|
const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
|
|
@@ -36046,6 +36117,60 @@ const sanitizeSqlForScan = (content) => {
|
|
|
36046
36117
|
return characters.join("");
|
|
36047
36118
|
};
|
|
36048
36119
|
//#endregion
|
|
36120
|
+
//#region src/plugin/rules/security-scan/supabase-rls-policy-risk.ts
|
|
36121
|
+
const DISABLED_RLS_PATTERN = /disable\s+row\s+level\s+security/i;
|
|
36122
|
+
const SERVICE_ROLE_BODY_BYPASS_PATTERN = /auth\.role\(\)\s*=\s*["']service_role["']/i;
|
|
36123
|
+
const CREATE_POLICY_PATTERN = /create\s+policy/gi;
|
|
36124
|
+
const STATEMENT_END_PATTERN = /;|create\s+policy/i;
|
|
36125
|
+
const PERMISSIVE_TRUE_PATTERN = /\b(?:using|with\s+check)\s*\(\s*true\s*\)/i;
|
|
36126
|
+
const FOR_SELECT_PATTERN = /\bfor\s+select\b/i;
|
|
36127
|
+
const TO_CLAUSE_PATTERN = /\bto\s+([\s\S]+?)(?=\s+(?:using|with\s+check|as|for)\b|;|$)/i;
|
|
36128
|
+
const SERVER_ONLY_ROLES = new Set([
|
|
36129
|
+
"service_role",
|
|
36130
|
+
"postgres",
|
|
36131
|
+
"supabase_admin"
|
|
36132
|
+
]);
|
|
36133
|
+
const isServerOnlyScoped = (statement) => {
|
|
36134
|
+
const toClause = TO_CLAUSE_PATTERN.exec(statement);
|
|
36135
|
+
if (toClause === null) return false;
|
|
36136
|
+
const roles = toClause[1].split(",").map((role) => role.trim().replace(/["'`]/g, "").toLowerCase()).filter(Boolean);
|
|
36137
|
+
return roles.length > 0 && roles.every((role) => SERVER_ONLY_ROLES.has(role));
|
|
36138
|
+
};
|
|
36139
|
+
const isRiskyPolicyStatement = (statement, rawStatement) => {
|
|
36140
|
+
if (SERVICE_ROLE_BODY_BYPASS_PATTERN.test(rawStatement)) return true;
|
|
36141
|
+
if (!PERMISSIVE_TRUE_PATTERN.test(statement)) return false;
|
|
36142
|
+
if (FOR_SELECT_PATTERN.test(statement)) return false;
|
|
36143
|
+
return !isServerOnlyScoped(statement);
|
|
36144
|
+
};
|
|
36145
|
+
const POLICY_RISK_MESSAGE = "Supabase policy SQL disables RLS, permits writes broadly, or references a service-role bypass.";
|
|
36146
|
+
const supabaseRlsPolicyRisk = defineRule({
|
|
36147
|
+
id: "supabase-rls-policy-risk",
|
|
36148
|
+
title: "Permissive Supabase RLS policy",
|
|
36149
|
+
severity: "error",
|
|
36150
|
+
recommendation: "Keep public-read policies explicit, but gate inserts, updates, deletes, and service-role bypasses behind `auth.uid()` plus trusted tenant membership.",
|
|
36151
|
+
scan: (file) => {
|
|
36152
|
+
if (!isSqlPath(file.relativePath)) return [];
|
|
36153
|
+
const content = sanitizeSqlForScan(file.content);
|
|
36154
|
+
let earliestRiskIndex = content.search(DISABLED_RLS_PATTERN);
|
|
36155
|
+
CREATE_POLICY_PATTERN.lastIndex = 0;
|
|
36156
|
+
for (let policyMatch = CREATE_POLICY_PATTERN.exec(content); policyMatch !== null; policyMatch = CREATE_POLICY_PATTERN.exec(content)) {
|
|
36157
|
+
const afterKeyword = policyMatch.index + policyMatch[0].length;
|
|
36158
|
+
const terminatorOffset = content.slice(afterKeyword).search(STATEMENT_END_PATTERN);
|
|
36159
|
+
const statementEnd = terminatorOffset < 0 ? content.length : afterKeyword + terminatorOffset;
|
|
36160
|
+
if (!isRiskyPolicyStatement(content.slice(policyMatch.index, statementEnd), file.content.slice(policyMatch.index, statementEnd))) continue;
|
|
36161
|
+
if (earliestRiskIndex < 0 || policyMatch.index < earliestRiskIndex) earliestRiskIndex = policyMatch.index;
|
|
36162
|
+
break;
|
|
36163
|
+
}
|
|
36164
|
+
if (earliestRiskIndex < 0) return [];
|
|
36165
|
+
const { line, column } = getLocationAtIndex(content, earliestRiskIndex);
|
|
36166
|
+
return [{
|
|
36167
|
+
message: POLICY_RISK_MESSAGE,
|
|
36168
|
+
line,
|
|
36169
|
+
column
|
|
36170
|
+
}];
|
|
36171
|
+
}
|
|
36172
|
+
});
|
|
36173
|
+
//#endregion
|
|
36049
36174
|
//#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
|
|
36050
36175
|
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;
|
|
36051
36176
|
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");
|
|
@@ -42536,6 +42661,7 @@ const CROSS_FILE_RULE_IDS = new Set([
|
|
|
42536
42661
|
"nextjs-missing-metadata",
|
|
42537
42662
|
"nextjs-no-use-search-params-without-suspense",
|
|
42538
42663
|
"no-mutating-reducer-state",
|
|
42664
|
+
"rn-no-raw-text",
|
|
42539
42665
|
"rn-prefer-expo-image"
|
|
42540
42666
|
]);
|
|
42541
42667
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-plugin-react-doctor",
|
|
3
|
-
"version": "0.5.8-dev.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.8-dev.f69f216",
|
|
4
|
+
"description": "React Doctor rules for oxlint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
7
7
|
"linter",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bugs": {
|
|
22
22
|
"url": "https://github.com/millionco/react-doctor/issues"
|
|
23
23
|
},
|
|
24
|
-
"license": "
|
|
24
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
25
25
|
"author": "Aiden Bai",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist/**/*.js",
|
|
33
|
-
"dist/**/*.d.ts"
|
|
33
|
+
"dist/**/*.d.ts",
|
|
34
|
+
"LICENSE"
|
|
34
35
|
],
|
|
35
36
|
"type": "module",
|
|
36
37
|
"sideEffects": false,
|