oxlint-plugin-react-doctor 0.7.9-dev.6c5c91b → 0.7.9-dev.71892af

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.
Files changed (2) hide show
  1. package/dist/index.js +212 -117
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { KEYS } from "eslint-visitor-keys";
2
2
  import * as path from "node:path";
3
+ import { parseSync, visitorKeys } from "oxc-parser";
3
4
  import * as fs from "node:fs";
4
5
  import { readFileSync } from "node:fs";
5
- import { parseSync, visitorKeys } from "oxc-parser";
6
6
  import { analyze } from "eslint-scope";
7
7
  //#region src/plugin/utils/is-node-of-type.ts
8
8
  const isNodeOfType = (node, type) => node !== null && typeof node === "object" && "type" in node && node.type === type;
@@ -327,7 +327,15 @@ const defineRule = (rule) => {
327
327
  let lastContentLineIndex;
328
328
  const buildLineStartOffsets = (content) => {
329
329
  const lineStartOffsets = [0];
330
- for (let newlineIndex = content.indexOf("\n"); newlineIndex !== -1; newlineIndex = content.indexOf("\n", newlineIndex + 1)) lineStartOffsets.push(newlineIndex + 1);
330
+ for (let characterIndex = 0; characterIndex < content.length; characterIndex += 1) {
331
+ const character = content[characterIndex];
332
+ if (character === "\r" && content[characterIndex + 1] === "\n") {
333
+ characterIndex += 1;
334
+ lineStartOffsets.push(characterIndex + 1);
335
+ continue;
336
+ }
337
+ if (character === "\r" || character === "\n" || character === "\u2028" || character === "\u2029") lineStartOffsets.push(characterIndex + 1);
338
+ }
331
339
  return lineStartOffsets;
332
340
  };
333
341
  const getLineStartOffsets = (content) => {
@@ -4342,7 +4350,8 @@ const SECRET_VALUE_PATTERNS = [
4342
4350
  ];
4343
4351
  const JWT_LITERAL_VALUE_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}\b/;
4344
4352
  const PUBLIC_ENV_SECRET_NAME_PATTERN = /\b(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PRIVATE|DATABASE_URL|SERVICE_ROLE|AWS_ACCESS_KEY|AWS_SECRET)[A-Z0-9_]*\b/i;
4345
- const FULL_ENV_LEAK_CONTEXT_PATTERN = /\b(?:process\.env|import\.meta\.env|window\.__[A-Z0-9_]*ENV[A-Z0-9_]*__|__[A-Z0-9_]*ENV[A-Z0-9_]*__)\b/;
4353
+ const FULL_ENV_LEAK_CONTEXT_PATTERN = /\b(?:process\s*\.\s*env|import\s*\.\s*meta\s*\.\s*env|window\.__[A-Z0-9_]*ENV[A-Z0-9_]*__|__[A-Z0-9_]*ENV[A-Z0-9_]*__)\b/;
4354
+ const FULL_ENV_LEAK_COMMENT_TRIVIA_PATTERN = /\b(?:(?:process|window)\s*(?:\/[*/]|<!--|-->|\.\s*(?:\/[*/]|<!--|-->))|import\s*(?:\/[*/]|<!--|-->|\.\s*(?:\/[*/]|<!--|-->|meta\s*(?:\/[*/]|<!--|-->|\.\s*(?:\/[*/]|<!--|-->)))))/;
4346
4355
  const FULL_ENV_LEAK_SECRET_NAME_PATTERN = /\b(?:DATABASE_URL|AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|MAILGUN_API_KEY|SALESFORCE_CLIENT_SECRET|OKTA_CLIENT_SECRET|SESSION_SECRET|COOKIE_SECRET|PRIVATE_KEY|SERVICE_ROLE)\b/;
4347
4356
  const TRUSTED_PUBLIC_SECRET_NAME_PATTERN = /(?:SENTRY_DSN|PUBLIC_KEY|PUBLISHABLE|ANON_KEY|POSTHOG_(?:PROJECT_)?TOKEN|POSTHOG_KEY|TLDRAW_LICENSE_KEY|CLERK_PUBLISHABLE_KEY|ALGOLIA_SEARCH_KEY|GC_API_KEY|GOOGLE_MAPS_API_KEY|MAPBOX_TOKEN|MIXPANEL_TOKEN|FACEBOOK_CLIENT_TOKEN|(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_(?:DISABLE|ENABLE|ALLOW|REQUIRE)_)|(?:TOKEN|SECRET|PASSWORD|PRIVATE)_(?:KIND|TYPE|URL|URI|ENDPOINT|HEADER|NAME)$/i;
4348
4357
  const PUBLIC_CLIENT_KEY_PATTERNS = [
@@ -4500,6 +4509,172 @@ const findSuspiciousPublicEnvSecretNamePattern = (content) => {
4500
4509
  //#region src/plugin/rules/security-scan/utils/has-full-env-leak-shape.ts
4501
4510
  const hasFullEnvLeakShape = (content) => FULL_ENV_LEAK_CONTEXT_PATTERN.test(content) && FULL_ENV_LEAK_SECRET_NAME_PATTERN.test(content);
4502
4511
  //#endregion
4512
+ //#region src/plugin/utils/attach-parent-references.ts
4513
+ const attachParentReferences = (root) => {
4514
+ const visit = (node, parent) => {
4515
+ const writableNode = node;
4516
+ writableNode.parent = parent;
4517
+ const nodeRecord = node;
4518
+ for (const key of Object.keys(nodeRecord)) {
4519
+ if (key === "parent") continue;
4520
+ const child = nodeRecord[key];
4521
+ if (Array.isArray(child)) {
4522
+ for (const item of child) if (isAstNode(item)) visit(item, node);
4523
+ } else if (isAstNode(child)) visit(child, node);
4524
+ }
4525
+ };
4526
+ visit(root, null);
4527
+ };
4528
+ //#endregion
4529
+ //#region src/plugin/utils/cross-file-probe-recorder.ts
4530
+ let activeProbeTrace = null;
4531
+ const recordExistenceProbe = (absolutePath) => {
4532
+ activeProbeTrace?.existencePaths.add(absolutePath);
4533
+ };
4534
+ const recordContentProbe = (absolutePath) => {
4535
+ activeProbeTrace?.contentPaths.add(absolutePath);
4536
+ };
4537
+ const isProbeRecorderActive = () => activeProbeTrace !== null;
4538
+ const collectCrossFileProbes = (collect) => {
4539
+ const previousTrace = activeProbeTrace;
4540
+ const trace = {
4541
+ existencePaths: /* @__PURE__ */ new Set(),
4542
+ contentPaths: /* @__PURE__ */ new Set()
4543
+ };
4544
+ activeProbeTrace = trace;
4545
+ try {
4546
+ collect();
4547
+ } finally {
4548
+ activeProbeTrace = previousTrace;
4549
+ }
4550
+ return trace;
4551
+ };
4552
+ //#endregion
4553
+ //#region src/plugin/utils/parse-source-file.ts
4554
+ const FILENAME_TO_LANG = {
4555
+ ".ts": "ts",
4556
+ ".tsx": "tsx",
4557
+ ".js": "js",
4558
+ ".jsx": "jsx",
4559
+ ".mjs": "js",
4560
+ ".cjs": "js",
4561
+ ".mts": "ts",
4562
+ ".cts": "ts"
4563
+ };
4564
+ const resolveLang = (filename) => {
4565
+ return FILENAME_TO_LANG[path.extname(filename).toLowerCase()] ?? "tsx";
4566
+ };
4567
+ const parseSourceText = ({ filename, sourceText, shouldAttachParentReferences = true }) => {
4568
+ try {
4569
+ const result = parseSync(filename, sourceText, {
4570
+ astType: "ts",
4571
+ lang: resolveLang(filename)
4572
+ });
4573
+ if (result.errors.some((parseError) => parseError.severity === "Error")) return null;
4574
+ const parsedProgram = result.program;
4575
+ if (shouldAttachParentReferences) attachParentReferences(parsedProgram);
4576
+ return parsedProgram;
4577
+ } catch {
4578
+ return null;
4579
+ }
4580
+ };
4581
+ const parseCache = /* @__PURE__ */ new Map();
4582
+ const parseSourceFile = (absoluteFilePath) => {
4583
+ if (!(absoluteFilePath.endsWith(".d.ts") || absoluteFilePath.endsWith(".d.mts") || absoluteFilePath.endsWith(".d.cts"))) recordContentProbe(absoluteFilePath);
4584
+ let fileStat;
4585
+ try {
4586
+ fileStat = fs.statSync(absoluteFilePath);
4587
+ } catch {
4588
+ return null;
4589
+ }
4590
+ if (!fileStat.isFile()) return null;
4591
+ if (fileStat.size > 2e6) return null;
4592
+ const cached = parseCache.get(absoluteFilePath);
4593
+ if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) return cached.program;
4594
+ if (absoluteFilePath.endsWith(".d.ts") || absoluteFilePath.endsWith(".d.mts") || absoluteFilePath.endsWith(".d.cts")) {
4595
+ parseCache.set(absoluteFilePath, {
4596
+ mtimeMs: fileStat.mtimeMs,
4597
+ size: fileStat.size,
4598
+ program: null
4599
+ });
4600
+ return null;
4601
+ }
4602
+ let sourceText;
4603
+ try {
4604
+ sourceText = fs.readFileSync(absoluteFilePath, "utf8");
4605
+ } catch {
4606
+ parseCache.set(absoluteFilePath, {
4607
+ mtimeMs: fileStat.mtimeMs,
4608
+ size: fileStat.size,
4609
+ program: null
4610
+ });
4611
+ return null;
4612
+ }
4613
+ const parsedProgram = parseSourceText({
4614
+ filename: absoluteFilePath,
4615
+ sourceText
4616
+ });
4617
+ parseCache.set(absoluteFilePath, {
4618
+ mtimeMs: fileStat.mtimeMs,
4619
+ size: fileStat.size,
4620
+ program: parsedProgram
4621
+ });
4622
+ return parsedProgram;
4623
+ };
4624
+ //#endregion
4625
+ //#region src/plugin/rules/security-scan/utils/mask-source-comments.ts
4626
+ const SOURCE_FILE_EXTENSION_PATTERN$1 = /\.(?:[cm]?[jt]sx?)$/i;
4627
+ const POSSIBLE_SOURCE_COMMENT_PATTERN = /\/\/|\/\*|<!--/;
4628
+ const LINE_TERMINATORS = new Set([
4629
+ "\r",
4630
+ "\n",
4631
+ "\u2028",
4632
+ "\u2029"
4633
+ ]);
4634
+ const hasPossibleAnnexBClosingComment = (content) => {
4635
+ let searchIndex = 0;
4636
+ while (searchIndex < content.length) {
4637
+ const closingCommentIndex = content.indexOf("-->", searchIndex);
4638
+ if (closingCommentIndex === -1) return false;
4639
+ let prefixIndex = closingCommentIndex - 1;
4640
+ while (prefixIndex >= 0 && !LINE_TERMINATORS.has(content[prefixIndex] ?? "")) {
4641
+ if (content[prefixIndex]?.trim() !== "") break;
4642
+ prefixIndex -= 1;
4643
+ }
4644
+ if (prefixIndex < 0 || LINE_TERMINATORS.has(content[prefixIndex] ?? "")) return true;
4645
+ searchIndex = closingCommentIndex + 3;
4646
+ }
4647
+ return false;
4648
+ };
4649
+ const maskSourceComments = (relativePath, content) => {
4650
+ if (!SOURCE_FILE_EXTENSION_PATTERN$1.test(relativePath)) return content;
4651
+ if (!content.startsWith("#!") && !POSSIBLE_SOURCE_COMMENT_PATTERN.test(content) && !hasPossibleAnnexBClosingComment(content)) return content;
4652
+ try {
4653
+ const result = parseSync(relativePath, content, {
4654
+ astType: "ts",
4655
+ lang: resolveLang(relativePath)
4656
+ });
4657
+ if (result.errors.some((parseError) => parseError.severity === "Error")) return void 0;
4658
+ const firstLineTerminatorIndex = content.search(/[\r\n\u2028\u2029]/);
4659
+ const ignoredRanges = [...content.startsWith("#!") ? [{
4660
+ start: 0,
4661
+ end: firstLineTerminatorIndex === -1 ? content.length : firstLineTerminatorIndex
4662
+ }] : [], ...result.comments];
4663
+ if (ignoredRanges.length === 0) return content;
4664
+ const contentParts = [];
4665
+ let previousEnd = 0;
4666
+ for (const ignoredRange of ignoredRanges) {
4667
+ contentParts.push(content.slice(previousEnd, ignoredRange.start));
4668
+ contentParts.push(content.slice(ignoredRange.start, ignoredRange.end).replace(/[^\r\n\u2028\u2029]/g, " "));
4669
+ previousEnd = ignoredRange.end;
4670
+ }
4671
+ contentParts.push(content.slice(previousEnd));
4672
+ return contentParts.join("");
4673
+ } catch {
4674
+ return;
4675
+ }
4676
+ };
4677
+ //#endregion
4503
4678
  //#region src/plugin/rules/security-scan/utils/scan-artifact-leak.ts
4504
4679
  const scanArtifactLeak = (file, findLeakPattern, message) => {
4505
4680
  if (DOCUMENTATION_CONTEXT_PATTERN.test(file.relativePath)) return [];
@@ -4515,12 +4690,39 @@ const scanArtifactLeak = (file, findLeakPattern, message) => {
4515
4690
  };
4516
4691
  //#endregion
4517
4692
  //#region src/plugin/rules/security-scan/artifact-env-leak.ts
4693
+ const ARTIFACT_ENV_LEAK_MESSAGE = "A browser artifact contains server-secret environment names or a full environment dump shape.";
4694
+ const findArtifactEnvLeakPattern = (content) => findSuspiciousPublicEnvSecretNamePattern(content) ?? (hasFullEnvLeakShape(content) ? FULL_ENV_LEAK_SECRET_NAME_PATTERN : void 0);
4518
4695
  const artifactEnvLeak = defineRule({
4519
4696
  id: "artifact-env-leak",
4520
4697
  title: "Server env leaked to browser artifact",
4521
4698
  severity: "error",
4522
4699
  recommendation: "Treat public env prefixes as publication, not secrecy; keep secret env vars server-only and rebuild after rotating leaked keys.",
4523
- scan: (file) => scanArtifactLeak(file, (content) => findSuspiciousPublicEnvSecretNamePattern(content) ?? (hasFullEnvLeakShape(content) ? FULL_ENV_LEAK_SECRET_NAME_PATTERN : void 0), "A browser artifact contains server-secret environment names or a full environment dump shape.")
4700
+ scan: (file) => {
4701
+ let isRawCandidateExact = false;
4702
+ const findRawCandidatePattern = (content) => {
4703
+ const suspiciousPublicNamePattern = findSuspiciousPublicEnvSecretNamePattern(content);
4704
+ if (suspiciousPublicNamePattern) {
4705
+ isRawCandidateExact = true;
4706
+ return suspiciousPublicNamePattern;
4707
+ }
4708
+ if (!FULL_ENV_LEAK_SECRET_NAME_PATTERN.test(content)) return void 0;
4709
+ if (FULL_ENV_LEAK_CONTEXT_PATTERN.test(content)) {
4710
+ isRawCandidateExact = true;
4711
+ return FULL_ENV_LEAK_SECRET_NAME_PATTERN;
4712
+ }
4713
+ return FULL_ENV_LEAK_COMMENT_TRIVIA_PATTERN.test(content) ? FULL_ENV_LEAK_SECRET_NAME_PATTERN : void 0;
4714
+ };
4715
+ const rawCandidateFindings = scanArtifactLeak(file, findRawCandidatePattern, ARTIFACT_ENV_LEAK_MESSAGE);
4716
+ if (rawCandidateFindings.length === 0) return rawCandidateFindings;
4717
+ const rawFindings = isRawCandidateExact ? rawCandidateFindings : scanArtifactLeak(file, findArtifactEnvLeakPattern, ARTIFACT_ENV_LEAK_MESSAGE);
4718
+ const executableContent = maskSourceComments(file.relativePath, file.content);
4719
+ if (executableContent === void 0) return rawCandidateFindings;
4720
+ if (executableContent === file.content) return rawFindings;
4721
+ return scanArtifactLeak({
4722
+ ...file,
4723
+ content: executableContent
4724
+ }, findArtifactEnvLeakPattern, ARTIFACT_ENV_LEAK_MESSAGE);
4725
+ }
4524
4726
  });
4525
4727
  //#endregion
4526
4728
  //#region src/plugin/rules/security-scan/artifact-secret-leak.ts
@@ -9083,119 +9285,6 @@ const findReExportTargetsForName = (programRoot, exportedName) => {
9083
9285
  return exportAllTargets;
9084
9286
  };
9085
9287
  //#endregion
9086
- //#region src/plugin/utils/attach-parent-references.ts
9087
- const attachParentReferences = (root) => {
9088
- const visit = (node, parent) => {
9089
- const writableNode = node;
9090
- writableNode.parent = parent;
9091
- const nodeRecord = node;
9092
- for (const key of Object.keys(nodeRecord)) {
9093
- if (key === "parent") continue;
9094
- const child = nodeRecord[key];
9095
- if (Array.isArray(child)) {
9096
- for (const item of child) if (isAstNode(item)) visit(item, node);
9097
- } else if (isAstNode(child)) visit(child, node);
9098
- }
9099
- };
9100
- visit(root, null);
9101
- };
9102
- //#endregion
9103
- //#region src/plugin/utils/cross-file-probe-recorder.ts
9104
- let activeProbeTrace = null;
9105
- const recordExistenceProbe = (absolutePath) => {
9106
- activeProbeTrace?.existencePaths.add(absolutePath);
9107
- };
9108
- const recordContentProbe = (absolutePath) => {
9109
- activeProbeTrace?.contentPaths.add(absolutePath);
9110
- };
9111
- const isProbeRecorderActive = () => activeProbeTrace !== null;
9112
- const collectCrossFileProbes = (collect) => {
9113
- const previousTrace = activeProbeTrace;
9114
- const trace = {
9115
- existencePaths: /* @__PURE__ */ new Set(),
9116
- contentPaths: /* @__PURE__ */ new Set()
9117
- };
9118
- activeProbeTrace = trace;
9119
- try {
9120
- collect();
9121
- } finally {
9122
- activeProbeTrace = previousTrace;
9123
- }
9124
- return trace;
9125
- };
9126
- //#endregion
9127
- //#region src/plugin/utils/parse-source-file.ts
9128
- const FILENAME_TO_LANG = {
9129
- ".ts": "ts",
9130
- ".tsx": "tsx",
9131
- ".js": "js",
9132
- ".jsx": "jsx",
9133
- ".mjs": "js",
9134
- ".cjs": "js",
9135
- ".mts": "ts",
9136
- ".cts": "ts"
9137
- };
9138
- const resolveLang = (filename) => {
9139
- return FILENAME_TO_LANG[path.extname(filename).toLowerCase()] ?? "tsx";
9140
- };
9141
- const parseSourceText = ({ filename, sourceText, shouldAttachParentReferences = true }) => {
9142
- try {
9143
- const result = parseSync(filename, sourceText, {
9144
- astType: "ts",
9145
- lang: resolveLang(filename)
9146
- });
9147
- if (result.errors.some((parseError) => parseError.severity === "Error")) return null;
9148
- const parsedProgram = result.program;
9149
- if (shouldAttachParentReferences) attachParentReferences(parsedProgram);
9150
- return parsedProgram;
9151
- } catch {
9152
- return null;
9153
- }
9154
- };
9155
- const parseCache = /* @__PURE__ */ new Map();
9156
- const parseSourceFile = (absoluteFilePath) => {
9157
- if (!(absoluteFilePath.endsWith(".d.ts") || absoluteFilePath.endsWith(".d.mts") || absoluteFilePath.endsWith(".d.cts"))) recordContentProbe(absoluteFilePath);
9158
- let fileStat;
9159
- try {
9160
- fileStat = fs.statSync(absoluteFilePath);
9161
- } catch {
9162
- return null;
9163
- }
9164
- if (!fileStat.isFile()) return null;
9165
- if (fileStat.size > 2e6) return null;
9166
- const cached = parseCache.get(absoluteFilePath);
9167
- if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) return cached.program;
9168
- if (absoluteFilePath.endsWith(".d.ts") || absoluteFilePath.endsWith(".d.mts") || absoluteFilePath.endsWith(".d.cts")) {
9169
- parseCache.set(absoluteFilePath, {
9170
- mtimeMs: fileStat.mtimeMs,
9171
- size: fileStat.size,
9172
- program: null
9173
- });
9174
- return null;
9175
- }
9176
- let sourceText;
9177
- try {
9178
- sourceText = fs.readFileSync(absoluteFilePath, "utf8");
9179
- } catch {
9180
- parseCache.set(absoluteFilePath, {
9181
- mtimeMs: fileStat.mtimeMs,
9182
- size: fileStat.size,
9183
- program: null
9184
- });
9185
- return null;
9186
- }
9187
- const parsedProgram = parseSourceText({
9188
- filename: absoluteFilePath,
9189
- sourceText
9190
- });
9191
- parseCache.set(absoluteFilePath, {
9192
- mtimeMs: fileStat.mtimeMs,
9193
- size: fileStat.size,
9194
- program: parsedProgram
9195
- });
9196
- return parsedProgram;
9197
- };
9198
- //#endregion
9199
9288
  //#region src/plugin/utils/resolve-relative-import-path.ts
9200
9289
  const MODULE_FILE_EXTENSIONS = [
9201
9290
  ".ts",
@@ -15016,6 +15105,11 @@ const doesResourceResultEscape = (resourceNode, allowReturnedResourceEscape, all
15016
15105
  parentNode = currentNode.parent;
15017
15106
  continue;
15018
15107
  }
15108
+ if (isNodeOfType(parentNode, "ConditionalExpression") && (parentNode.consequent === currentNode || parentNode.alternate === currentNode) || isNodeOfType(parentNode, "LogicalExpression") && (parentNode.right === currentNode || parentNode.left === currentNode && parentNode.operator !== "&&")) {
15109
+ currentNode = parentNode;
15110
+ parentNode = currentNode.parent;
15111
+ continue;
15112
+ }
15019
15113
  if (isNodeOfType(parentNode, "VariableDeclarator") && parentNode.init === currentNode && isNodeOfType(parentNode.id, "Identifier") && isNodeOfType(parentNode.parent, "VariableDeclaration") && parentNode.parent.kind === "const") {
15020
15114
  const ownerFunction = findEnclosingFunction$1(resourceNode);
15021
15115
  const resourceSymbol = context.scopes.symbolFor(parentNode.id);
@@ -34082,6 +34176,7 @@ const noChainStateUpdates = defineRule({
34082
34176
  id: "no-chain-state-updates",
34083
34177
  title: "State updates chained through effects",
34084
34178
  severity: "warn",
34179
+ disabledWhen: ["react:18"],
34085
34180
  tags: ["test-noise"],
34086
34181
  recommendation: "Set all the related state together in the event handler that starts it, instead of having one useEffect react to a state change and set more state. See https://react.dev/learn/you-might-not-need-an-effect#chains-of-computations",
34087
34182
  create: (context) => ({ CallExpression(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.7.9-dev.6c5c91b",
3
+ "version": "0.7.9-dev.71892af",
4
4
  "description": "React Doctor rules for oxlint.",
5
5
  "keywords": [
6
6
  "accessibility",