oxlint-plugin-react-doctor 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 isServerOnlyBuildArtifactPath = (relativePath) => /(?:^|\/)(?:\.next\/server|\.output\/server)\//.test(relativePath);
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 (isServerOnlyBuildArtifactPath(relativePath)) return false;
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([
@@ -8495,6 +8509,136 @@ const insecureCryptoRisk = defineRule({
8495
8509
  }
8496
8510
  });
8497
8511
  //#endregion
8512
+ //#region src/plugin/rules/security-scan/utils/find-matching-bracket.ts
8513
+ const findMatchingBracket = (content, openIndex) => {
8514
+ const open = content[openIndex];
8515
+ const close = open === "(" ? ")" : open === "{" ? "}" : open === "[" ? "]" : "";
8516
+ if (close === "") return -1;
8517
+ let depth = 0;
8518
+ let stringDelimiter = null;
8519
+ for (let index = openIndex; index < content.length; index += 1) {
8520
+ const character = content[index];
8521
+ if (stringDelimiter !== null) {
8522
+ if (character === "\\") index += 1;
8523
+ else if (character === stringDelimiter) stringDelimiter = null;
8524
+ continue;
8525
+ }
8526
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8527
+ else if (character === open) depth += 1;
8528
+ else if (character === close) {
8529
+ depth -= 1;
8530
+ if (depth === 0) return index;
8531
+ }
8532
+ }
8533
+ return -1;
8534
+ };
8535
+ //#endregion
8536
+ //#region src/plugin/rules/security-scan/insecure-session-cookie.ts
8537
+ 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])`;
8538
+ const AUTH_COOKIE_NAME_LITERAL = `[\`"'][^\`"']*?${AUTH_COOKIE_NAME_TOKEN}[^\`"']*[\`"']`;
8539
+ const AUTH_COOKIE_SET_CALL_PATTERN = new RegExp(`(?:\\.cookies\\.set|cookies\\(\\s*\\)\\.set|\\.cookie)\\s*\\(\\s*${AUTH_COOKIE_NAME_LITERAL}`, "gi");
8540
+ const HTTP_ONLY_DISABLED_PATTERN = /httpOnly\s*:\s*false\b/i;
8541
+ const STRING_LITERAL_PATTERN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
8542
+ const blankStringContents = (text) => {
8543
+ const characters = text.split("");
8544
+ let index = 0;
8545
+ let stringDelimiter = null;
8546
+ while (index < text.length) {
8547
+ const character = text[index];
8548
+ if (stringDelimiter !== null) {
8549
+ if (character === "\\") {
8550
+ index += 2;
8551
+ continue;
8552
+ }
8553
+ if (character === stringDelimiter) stringDelimiter = null;
8554
+ else if (character !== "\n") characters[index] = " ";
8555
+ index += 1;
8556
+ continue;
8557
+ }
8558
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8559
+ index += 1;
8560
+ }
8561
+ return characters.join("");
8562
+ };
8563
+ const COOKIE_CONFIG_OPENER_PATTERN = /cookie\s*:\s*\{/gi;
8564
+ const CLIENT_AUTH_COOKIE_WRITE_PATTERN = new RegExp(`document\\.cookie\\s*=\\s*[\`"'][^\`"'=;]*?${AUTH_COOKIE_NAME_TOKEN}[^\`"'=;]*=`, "gi");
8565
+ const countTopLevelArguments = (argumentsSource) => {
8566
+ if (argumentsSource.trim().length === 0) return 0;
8567
+ let depth = 0;
8568
+ let stringDelimiter = null;
8569
+ let count = 1;
8570
+ for (let index = 0; index < argumentsSource.length; index += 1) {
8571
+ const character = argumentsSource[index];
8572
+ if (stringDelimiter !== null) {
8573
+ if (character === "\\") index += 1;
8574
+ else if (character === stringDelimiter) stringDelimiter = null;
8575
+ continue;
8576
+ }
8577
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8578
+ else if (character === "(" || character === "[" || character === "{") depth += 1;
8579
+ else if (character === ")" || character === "]" || character === "}") depth -= 1;
8580
+ else if (character === "," && depth === 0) count += 1;
8581
+ }
8582
+ return count;
8583
+ };
8584
+ const addMatchFindings = (content, pattern, message, isInsecure, findings) => {
8585
+ pattern.lastIndex = 0;
8586
+ for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
8587
+ if (!isInsecure(match.index, match[0])) continue;
8588
+ const location = getLocationAtIndex(content, match.index);
8589
+ findings.push({
8590
+ message,
8591
+ line: location.line,
8592
+ column: location.column
8593
+ });
8594
+ }
8595
+ };
8596
+ const insecureSessionCookie = defineRule({
8597
+ id: "insecure-session-cookie",
8598
+ title: "Auth cookie missing HttpOnly protection",
8599
+ severity: "warn",
8600
+ 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.",
8601
+ scan: (file) => {
8602
+ if (!isProductionSourcePath(file.relativePath)) return [];
8603
+ const content = getScannableContent(file);
8604
+ if (!/cookie/i.test(content)) return [];
8605
+ const findings = [];
8606
+ 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.";
8607
+ AUTH_COOKIE_SET_CALL_PATTERN.lastIndex = 0;
8608
+ for (let match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content); match !== null; match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content)) {
8609
+ const openParenIndex = match.index + match[0].lastIndexOf("(");
8610
+ const closeParenIndex = findMatchingBracket(content, openParenIndex);
8611
+ if (closeParenIndex < 0) continue;
8612
+ const argumentsSource = content.slice(openParenIndex + 1, closeParenIndex);
8613
+ const hasNoOptions = countTopLevelArguments(argumentsSource) < 3;
8614
+ const argumentsWithoutStrings = argumentsSource.replace(STRING_LITERAL_PATTERN, "");
8615
+ if (!hasNoOptions && !HTTP_ONLY_DISABLED_PATTERN.test(argumentsWithoutStrings)) continue;
8616
+ const location = getLocationAtIndex(content, match.index);
8617
+ findings.push({
8618
+ message,
8619
+ line: location.line,
8620
+ column: location.column
8621
+ });
8622
+ }
8623
+ const blankedContent = blankStringContents(content);
8624
+ COOKIE_CONFIG_OPENER_PATTERN.lastIndex = 0;
8625
+ for (let match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent); match !== null; match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent)) {
8626
+ const braceIndex = match.index + match[0].length - 1;
8627
+ const closeBraceIndex = findMatchingBracket(blankedContent, braceIndex);
8628
+ const block = closeBraceIndex >= 0 ? blankedContent.slice(braceIndex, closeBraceIndex) : blankedContent.slice(braceIndex, braceIndex + 400);
8629
+ if (!HTTP_ONLY_DISABLED_PATTERN.test(block)) continue;
8630
+ const location = getLocationAtIndex(blankedContent, match.index);
8631
+ findings.push({
8632
+ message,
8633
+ line: location.line,
8634
+ column: location.column
8635
+ });
8636
+ }
8637
+ addMatchFindings(content, CLIENT_AUTH_COOKIE_WRITE_PATTERN, message, () => true, findings);
8638
+ return findings;
8639
+ }
8640
+ });
8641
+ //#endregion
8498
8642
  //#region src/plugin/constants/event-handlers.ts
8499
8643
  const MOUSE_EVENT_HANDLERS = [
8500
8644
  "onClick",
@@ -12700,11 +12844,70 @@ const jsxPropsNoSpreading = defineRule({
12700
12844
  }
12701
12845
  });
12702
12846
  //#endregion
12847
+ //#region src/plugin/rules/security-scan/jwt-insecure-verification.ts
12848
+ const NONE_ALGORITHM_PATTERN = /\b(?:alg|algorithms?)\s*:\s*\[?\s*["'`]none["'`]/gi;
12849
+ const isIndexInsideStringLiteral = (content, index) => {
12850
+ let stringDelimiter = null;
12851
+ const templateExpressionDepths = [];
12852
+ for (let cursor = 0; cursor < index; cursor += 1) {
12853
+ const character = content[cursor];
12854
+ if (stringDelimiter === "`") {
12855
+ if (character === "\\") cursor += 1;
12856
+ else if (character === "`") stringDelimiter = null;
12857
+ else if (character === "$" && content[cursor + 1] === "{") {
12858
+ templateExpressionDepths.push(0);
12859
+ stringDelimiter = null;
12860
+ cursor += 1;
12861
+ }
12862
+ continue;
12863
+ }
12864
+ if (stringDelimiter !== null) {
12865
+ if (character === "\\") cursor += 1;
12866
+ else if (character === stringDelimiter) stringDelimiter = null;
12867
+ continue;
12868
+ }
12869
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
12870
+ else if (templateExpressionDepths.length > 0) {
12871
+ const top = templateExpressionDepths.length - 1;
12872
+ if (character === "{") templateExpressionDepths[top] += 1;
12873
+ else if (character === "}") if (templateExpressionDepths[top] === 0) {
12874
+ templateExpressionDepths.pop();
12875
+ stringDelimiter = "`";
12876
+ } else templateExpressionDepths[top] -= 1;
12877
+ }
12878
+ }
12879
+ return stringDelimiter !== null;
12880
+ };
12881
+ const jwtInsecureVerification = defineRule({
12882
+ id: "jwt-insecure-verification",
12883
+ title: "JWT verified with the 'none' algorithm",
12884
+ severity: "error",
12885
+ 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'] })`).",
12886
+ scan: (file) => {
12887
+ if (!isProductionSourcePath(file.relativePath)) return [];
12888
+ const content = getScannableContent(file);
12889
+ if (!/\bjwt\b|jsonwebtoken|\bjose\b/i.test(content)) return [];
12890
+ const findings = [];
12891
+ NONE_ALGORITHM_PATTERN.lastIndex = 0;
12892
+ for (let noneMatch = NONE_ALGORITHM_PATTERN.exec(content); noneMatch !== null; noneMatch = NONE_ALGORITHM_PATTERN.exec(content)) {
12893
+ if (isIndexInsideStringLiteral(content, noneMatch.index)) continue;
12894
+ const location = getLocationAtIndex(content, noneMatch.index);
12895
+ findings.push({
12896
+ message: "JWT is configured with the 'none' algorithm, which disables signature verification, so any forged token is accepted.",
12897
+ line: location.line,
12898
+ column: location.column
12899
+ });
12900
+ }
12901
+ return findings;
12902
+ }
12903
+ });
12904
+ //#endregion
12703
12905
  //#region src/plugin/rules/security-scan/key-lifecycle-risk.ts
12704
12906
  const keyLifecycleRisk = defineRule({
12705
12907
  id: "key-lifecycle-risk",
12706
12908
  title: "Long-lived key material in repository",
12707
12909
  severity: "error",
12910
+ committedFilesOnly: true,
12708
12911
  recommendation: "Remove private keys from source, rotate exposed credentials, prefer short-lived deploy credentials, and document revocation/expiry for release keys.",
12709
12912
  scan: scanByPattern({
12710
12913
  shouldScan: (file) => !TEST_CONTEXT_PATTERN.test(file.relativePath) && !DOCUMENTATION_CONTEXT_PATTERN.test(file.relativePath),
@@ -27035,6 +27238,8 @@ const publicEnvSecretName = defineRule({
27035
27238
  });
27036
27239
  //#endregion
27037
27240
  //#region src/plugin/rules/tanstack-query/query-destructure-result.ts
27241
+ const TANSTACK_QUERY_PACKAGE_PATTERN = /^@tanstack\/[\w-]*query[\w-]*$/;
27242
+ const isTanstackQuerySource = (source) => TANSTACK_QUERY_PACKAGE_PATTERN.test(source) || source === "react-query";
27038
27243
  const queryDestructureResult = defineRule({
27039
27244
  id: "query-destructure-result",
27040
27245
  title: "Whole query result subscribes to every field",
@@ -27047,6 +27252,8 @@ const queryDestructureResult = defineRule({
27047
27252
  if (!node.init || !isNodeOfType(node.init, "CallExpression")) return;
27048
27253
  const calleeName = isNodeOfType(node.init.callee, "Identifier") ? node.init.callee.name : null;
27049
27254
  if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
27255
+ const importSource = getImportSourceForName(node, calleeName);
27256
+ if (importSource !== null && !isTanstackQuerySource(importSource)) return;
27050
27257
  context.report({
27051
27258
  node: node.id,
27052
27259
  message: `Destructure ${calleeName}() results instead of assigning the whole query object, so TanStack Query only subscribes to the fields you use.`
@@ -27889,6 +28096,7 @@ const repositorySecretFile = defineRule({
27889
28096
  id: "repository-secret-file",
27890
28097
  title: "Secret file checked into repository",
27891
28098
  severity: "error",
28099
+ committedFilesOnly: true,
27892
28100
  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
28101
  scan: (file) => {
27894
28102
  if (!isRepositorySecretFilePath(file.relativePath)) return [];
@@ -27905,6 +28113,20 @@ const repositorySecretFile = defineRule({
27905
28113
  }
27906
28114
  });
27907
28115
  //#endregion
28116
+ //#region src/plugin/rules/security-scan/request-body-mass-assignment.ts
28117
+ const REQUEST_INPUT_SOURCE = "(?:req|request|ctx\\.req|ctx\\.request)\\.(?:body|query|params)|await\\s+(?:req|request)\\.json\\(\\s*\\)";
28118
+ const requestBodyMassAssignment = defineRule({
28119
+ id: "request-body-mass-assignment",
28120
+ title: "Request input spread without field allowlist",
28121
+ severity: "warn",
28122
+ 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.",
28123
+ scan: scanByPattern({
28124
+ shouldScan: (file) => isProductionSourcePath(file.relativePath),
28125
+ 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")],
28126
+ 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."
28127
+ })
28128
+ });
28129
+ //#endregion
27908
28130
  //#region src/plugin/utils/function-body-has-return-with-value.ts
27909
28131
  const functionBodyHasReturnWithValue = (functionNode) => {
27910
28132
  if (functionNode.type === "ArrowFunctionExpression" && "body" in functionNode) {
@@ -34330,6 +34552,17 @@ const scope = defineRule({
34330
34552
  });
34331
34553
  } })
34332
34554
  });
34555
+ const secretInFallback = defineRule({
34556
+ id: "secret-in-fallback",
34557
+ title: "Hardcoded secret fallback for env var",
34558
+ severity: "error",
34559
+ 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.",
34560
+ scan: scanByPattern({
34561
+ shouldScan: (file) => isProductionSourcePath(file.relativePath),
34562
+ 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,
34563
+ 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."
34564
+ })
34565
+ });
34333
34566
  //#endregion
34334
34567
  //#region src/plugin/rules/react-builtins/self-closing-comp.ts
34335
34568
  const MESSAGE$2 = "This tag has no children, so the closing tag adds noise without changing output.";
@@ -35139,8 +35372,11 @@ const supabaseClientOwnedAuthzField = defineRule({
35139
35372
  })
35140
35373
  });
35141
35374
  //#endregion
35375
+ //#region src/plugin/rules/security-scan/utils/is-supabase-migration-path.ts
35376
+ const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
35377
+ //#endregion
35142
35378
  //#region src/plugin/rules/security-scan/utils/is-sql-path.ts
35143
- const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
35379
+ const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
35144
35380
  const supabaseRlsPolicyRisk = defineRule({
35145
35381
  id: "supabase-rls-policy-risk",
35146
35382
  title: "Permissive Supabase RLS policy",
@@ -35158,6 +35394,210 @@ const supabaseRlsPolicyRisk = defineRule({
35158
35394
  })
35159
35395
  });
35160
35396
  //#endregion
35397
+ //#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
35398
+ const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
35399
+ const CODE_BODY_KEYWORDS = new Set([
35400
+ "do",
35401
+ "plpgsql",
35402
+ "sql",
35403
+ "plpython3u",
35404
+ "plpythonu",
35405
+ "plperl",
35406
+ "plperlu",
35407
+ "plv8"
35408
+ ]);
35409
+ const precedingKeyword = (content, beforeIndex) => {
35410
+ let lookBack = beforeIndex - 1;
35411
+ while (lookBack >= 0 && /\s/.test(content[lookBack] ?? "")) lookBack -= 1;
35412
+ let wordStart = lookBack;
35413
+ while (wordStart >= 0 && /[A-Za-z0-9_]/.test(content[wordStart] ?? "")) wordStart -= 1;
35414
+ return content.slice(wordStart + 1, lookBack + 1).toLowerCase();
35415
+ };
35416
+ const blankCodeBodyInterior = (content, characters, start, end) => {
35417
+ let index = start;
35418
+ let inExecuteStatement = false;
35419
+ while (index < end) {
35420
+ const character = content[index];
35421
+ if (character === ";") {
35422
+ inExecuteStatement = false;
35423
+ index += 1;
35424
+ continue;
35425
+ }
35426
+ if (/[A-Za-z_]/.test(character)) {
35427
+ let wordEnd = index;
35428
+ while (wordEnd < end && /[A-Za-z0-9_]/.test(content[wordEnd] ?? "")) wordEnd += 1;
35429
+ if (content.slice(index, wordEnd).toLowerCase() === "execute") inExecuteStatement = true;
35430
+ index = wordEnd;
35431
+ continue;
35432
+ }
35433
+ if (character === "'") {
35434
+ const keepVisible = inExecuteStatement;
35435
+ if (!keepVisible) characters[index] = " ";
35436
+ index += 1;
35437
+ while (index < end) {
35438
+ if (content[index] === "'") {
35439
+ if (content[index + 1] === "'") {
35440
+ if (!keepVisible) {
35441
+ characters[index] = " ";
35442
+ characters[index + 1] = " ";
35443
+ }
35444
+ index += 2;
35445
+ continue;
35446
+ }
35447
+ if (!keepVisible) characters[index] = " ";
35448
+ index += 1;
35449
+ break;
35450
+ }
35451
+ if (!keepVisible && content[index] !== "\n") characters[index] = " ";
35452
+ index += 1;
35453
+ }
35454
+ continue;
35455
+ }
35456
+ if (character === "\"") {
35457
+ index += 1;
35458
+ while (index < end) {
35459
+ if (content[index] === "\"") {
35460
+ if (content[index + 1] === "\"") {
35461
+ index += 2;
35462
+ continue;
35463
+ }
35464
+ index += 1;
35465
+ break;
35466
+ }
35467
+ index += 1;
35468
+ }
35469
+ continue;
35470
+ }
35471
+ if (character === "-" && content[index + 1] === "-") {
35472
+ while (index < end && content[index] !== "\n") {
35473
+ characters[index] = " ";
35474
+ index += 1;
35475
+ }
35476
+ continue;
35477
+ }
35478
+ if (character === "/" && content[index + 1] === "*") {
35479
+ while (index < end) {
35480
+ if (content[index] === "*" && content[index + 1] === "/") {
35481
+ characters[index] = " ";
35482
+ characters[index + 1] = " ";
35483
+ index += 2;
35484
+ break;
35485
+ }
35486
+ if (content[index] !== "\n") characters[index] = " ";
35487
+ index += 1;
35488
+ }
35489
+ continue;
35490
+ }
35491
+ index += 1;
35492
+ }
35493
+ };
35494
+ const sanitizeSqlForScan = (content) => {
35495
+ const characters = content.split("");
35496
+ let index = 0;
35497
+ while (index < content.length) {
35498
+ const character = content[index];
35499
+ if (character === "-" && content[index + 1] === "-") {
35500
+ while (index < content.length && content[index] !== "\n") {
35501
+ characters[index] = " ";
35502
+ index += 1;
35503
+ }
35504
+ continue;
35505
+ }
35506
+ if (character === "/" && content[index + 1] === "*") {
35507
+ while (index < content.length) {
35508
+ if (content[index] === "*" && content[index + 1] === "/") {
35509
+ characters[index] = " ";
35510
+ characters[index + 1] = " ";
35511
+ index += 2;
35512
+ break;
35513
+ }
35514
+ if (content[index] !== "\n") characters[index] = " ";
35515
+ index += 1;
35516
+ }
35517
+ continue;
35518
+ }
35519
+ if (character === "'") {
35520
+ characters[index] = " ";
35521
+ index += 1;
35522
+ while (index < content.length) {
35523
+ if (content[index] === "'") {
35524
+ if (content[index + 1] === "'") {
35525
+ characters[index] = " ";
35526
+ characters[index + 1] = " ";
35527
+ index += 2;
35528
+ continue;
35529
+ }
35530
+ characters[index] = " ";
35531
+ index += 1;
35532
+ break;
35533
+ }
35534
+ if (content[index] !== "\n") characters[index] = " ";
35535
+ index += 1;
35536
+ }
35537
+ continue;
35538
+ }
35539
+ if (character === "$") {
35540
+ const tagMatch = DOLLAR_QUOTE_TAG_PATTERN.exec(content.slice(index));
35541
+ if (tagMatch !== null) {
35542
+ const tag = tagMatch[0];
35543
+ const closeIndex = content.indexOf(tag, index + tag.length);
35544
+ const endIndex = closeIndex < 0 ? content.length : closeIndex + tag.length;
35545
+ const keyword = precedingKeyword(content, index);
35546
+ if (CODE_BODY_KEYWORDS.has(keyword)) blankCodeBodyInterior(content, characters, index + tag.length, endIndex);
35547
+ else for (let blankIndex = index; blankIndex < endIndex; blankIndex += 1) if (content[blankIndex] !== "\n") characters[blankIndex] = " ";
35548
+ index = endIndex;
35549
+ continue;
35550
+ }
35551
+ }
35552
+ if (character === "\"") {
35553
+ index += 1;
35554
+ while (index < content.length) {
35555
+ if (content[index] === "\"") {
35556
+ if (content[index + 1] === "\"") {
35557
+ index += 2;
35558
+ continue;
35559
+ }
35560
+ index += 1;
35561
+ break;
35562
+ }
35563
+ index += 1;
35564
+ }
35565
+ continue;
35566
+ }
35567
+ index += 1;
35568
+ }
35569
+ return characters.join("");
35570
+ };
35571
+ //#endregion
35572
+ //#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
35573
+ 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;
35574
+ 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");
35575
+ const supabaseTableMissingRls = defineRule({
35576
+ id: "supabase-table-missing-rls",
35577
+ title: "Supabase table created without Row Level Security",
35578
+ severity: "error",
35579
+ 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.",
35580
+ scan: (file) => {
35581
+ if (!isSupabaseMigrationPath(file.relativePath)) return [];
35582
+ const content = sanitizeSqlForScan(file.content);
35583
+ if (!/create\s+(?:unlogged\s+)?table/i.test(content)) return [];
35584
+ const findings = [];
35585
+ CREATE_PUBLIC_TABLE_PATTERN.lastIndex = 0;
35586
+ for (let match = CREATE_PUBLIC_TABLE_PATTERN.exec(content); match !== null; match = CREATE_PUBLIC_TABLE_PATTERN.exec(content)) {
35587
+ const tableName = match[1];
35588
+ if (tableName === void 0) continue;
35589
+ if (enableRlsForTablePattern(tableName).test(content.slice(match.index))) continue;
35590
+ const location = getLocationAtIndex(content, match.index);
35591
+ findings.push({
35592
+ message: "Supabase migration creates a public table but never enables Row Level Security, leaving every row exposed to the anon key.",
35593
+ line: location.line,
35594
+ column: location.column
35595
+ });
35596
+ }
35597
+ return findings;
35598
+ }
35599
+ });
35600
+ //#endregion
35161
35601
  //#region src/plugin/rules/security-scan/svg-filter-clickjacking-risk.ts
35162
35602
  const svgFilterClickjackingRisk = defineRule({
35163
35603
  id: "svg-filter-clickjacking-risk",
@@ -35856,6 +36296,47 @@ const tenantStaticProxyRisk = defineRule({
35856
36296
  })
35857
36297
  });
35858
36298
  //#endregion
36299
+ //#region src/plugin/rules/security-scan/unsafe-json-in-html.ts
36300
+ 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];
36301
+ const RETURN_ESCAPE_PATTERN = /^[\s)]*\.replace\s*\([^)]*(?:\\u003[cC]|&lt;|<)/;
36302
+ const ESCAPE_WRAPPER_PATTERN = /(?:\b(?:escapeHtml|escapeJSON|escapeJson|htmlEscape|jsesc)|(?<![.\w])(?:serialize|serializeJavascript|devalue|uneval|superjson))\s*\(\s*$/i;
36303
+ const JSON_STRINGIFY_TOKEN_PATTERN = /\bJSON\.stringify\s*\($/i;
36304
+ const RETURN_LOOKAHEAD_CHARS = 160;
36305
+ const unsafeJsonInHtml = defineRule({
36306
+ id: "unsafe-json-in-html",
36307
+ title: "Unescaped JSON in HTML or script sink",
36308
+ severity: "warn",
36309
+ 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.",
36310
+ scan: (file) => {
36311
+ if (!isProductionSourcePath(file.relativePath)) return [];
36312
+ const content = getScannableContent(file);
36313
+ if (!content.includes("JSON.stringify")) return [];
36314
+ const findings = [];
36315
+ const seenIndices = /* @__PURE__ */ new Set();
36316
+ for (const pattern of SINK_JSON_STRINGIFY_PATTERNS) {
36317
+ pattern.lastIndex = 0;
36318
+ for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
36319
+ const beforeStringify = match[0].replace(JSON_STRINGIFY_TOKEN_PATTERN, "");
36320
+ if (ESCAPE_WRAPPER_PATTERN.test(beforeStringify)) continue;
36321
+ const closeParenIndex = findMatchingBracket(content, match.index + match[0].length - 1);
36322
+ if (closeParenIndex >= 0) {
36323
+ const afterReturn = content.slice(closeParenIndex + 1, closeParenIndex + 1 + RETURN_LOOKAHEAD_CHARS);
36324
+ if (RETURN_ESCAPE_PATTERN.test(afterReturn)) continue;
36325
+ }
36326
+ if (seenIndices.has(match.index)) continue;
36327
+ seenIndices.add(match.index);
36328
+ const location = getLocationAtIndex(content, match.index);
36329
+ findings.push({
36330
+ message: "JSON.stringify is embedded in HTML/script markup without HTML-escaping; data containing `<\/script>` or `<` breaks out and becomes XSS.",
36331
+ line: location.line,
36332
+ column: location.column
36333
+ });
36334
+ }
36335
+ }
36336
+ return findings;
36337
+ }
36338
+ });
36339
+ //#endregion
35859
36340
  //#region src/plugin/rules/security-scan/untrusted-redirect-following.ts
35860
36341
  const OUTBOUND_FETCH_CALL_PATTERN = /(?:(?<![.\w$])fetch|\baxios\.\s*(?:get|post|put|delete|head)|\bgot|\bgot\.\s*(?:get|post))\s*\(\s*([^,)]+)/;
35861
36342
  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 +36484,7 @@ const voidDomElementsNoChildren = defineRule({
36003
36484
  //#region src/plugin/rules/security-scan/webhook-signature-risk.ts
36004
36485
  const WEBHOOK_HANDLER_PATTERN = /(?:^|\/)[^/]*webhook[^/]*\/|(?:^|\/)[^/]*webhook[^/]*\.[cm]?[jt]s$|\bwebhook\b/i;
36005
36486
  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["']/i;
36487
+ 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
36488
  const OUTBOUND_WEBHOOK_URL_MENTION_PATTERN = /webhook[\s_-]?ur[il]\w*/gi;
36008
36489
  const OUTBOUND_WEBHOOK_CONFIG_PATTERN = /process\.env\.\w*WEBHOOK_URL|\b(?:send|post|dispatch|publish|notify)\w*Webhook/;
36009
36490
  const REQUEST_READ_PATTERN = /\b(?:req|request)\b/;
@@ -37164,6 +37645,18 @@ const reactDoctorRules = [
37164
37645
  tags: [...new Set(["security-scan", ...insecureCryptoRisk.tags ?? []])]
37165
37646
  }
37166
37647
  },
37648
+ {
37649
+ key: "react-doctor/insecure-session-cookie",
37650
+ id: "insecure-session-cookie",
37651
+ source: "react-doctor",
37652
+ originallyExternal: false,
37653
+ rule: {
37654
+ ...insecureSessionCookie,
37655
+ framework: "global",
37656
+ category: "Security",
37657
+ tags: [...new Set(["security-scan", ...insecureSessionCookie.tags ?? []])]
37658
+ }
37659
+ },
37167
37660
  {
37168
37661
  key: "react-doctor/interactive-supports-focus",
37169
37662
  id: "interactive-supports-focus",
@@ -37606,6 +38099,18 @@ const reactDoctorRules = [
37606
38099
  requires: [...new Set(["react", ...jsxPropsNoSpreading.requires ?? []])]
37607
38100
  }
37608
38101
  },
38102
+ {
38103
+ key: "react-doctor/jwt-insecure-verification",
38104
+ id: "jwt-insecure-verification",
38105
+ source: "react-doctor",
38106
+ originallyExternal: false,
38107
+ rule: {
38108
+ ...jwtInsecureVerification,
38109
+ framework: "global",
38110
+ category: "Security",
38111
+ tags: [...new Set(["security-scan", ...jwtInsecureVerification.tags ?? []])]
38112
+ }
38113
+ },
37609
38114
  {
37610
38115
  key: "react-doctor/key-lifecycle-risk",
37611
38116
  id: "key-lifecycle-risk",
@@ -39756,6 +40261,18 @@ const reactDoctorRules = [
39756
40261
  tags: [...new Set(["security-scan", ...repositorySecretFile.tags ?? []])]
39757
40262
  }
39758
40263
  },
40264
+ {
40265
+ key: "react-doctor/request-body-mass-assignment",
40266
+ id: "request-body-mass-assignment",
40267
+ source: "react-doctor",
40268
+ originallyExternal: false,
40269
+ rule: {
40270
+ ...requestBodyMassAssignment,
40271
+ framework: "global",
40272
+ category: "Security",
40273
+ tags: [...new Set(["security-scan", ...requestBodyMassAssignment.tags ?? []])]
40274
+ }
40275
+ },
39759
40276
  {
39760
40277
  key: "react-doctor/require-render-return",
39761
40278
  id: "require-render-return",
@@ -40344,6 +40861,18 @@ const reactDoctorRules = [
40344
40861
  requires: [...new Set(["react", ...scope.requires ?? []])]
40345
40862
  }
40346
40863
  },
40864
+ {
40865
+ key: "react-doctor/secret-in-fallback",
40866
+ id: "secret-in-fallback",
40867
+ source: "react-doctor",
40868
+ originallyExternal: false,
40869
+ rule: {
40870
+ ...secretInFallback,
40871
+ framework: "global",
40872
+ category: "Security",
40873
+ tags: [...new Set(["security-scan", ...secretInFallback.tags ?? []])]
40874
+ }
40875
+ },
40347
40876
  {
40348
40877
  key: "react-doctor/self-closing-comp",
40349
40878
  id: "self-closing-comp",
@@ -40500,6 +41029,18 @@ const reactDoctorRules = [
40500
41029
  tags: [...new Set(["security-scan", ...supabaseRlsPolicyRisk.tags ?? []])]
40501
41030
  }
40502
41031
  },
41032
+ {
41033
+ key: "react-doctor/supabase-table-missing-rls",
41034
+ id: "supabase-table-missing-rls",
41035
+ source: "react-doctor",
41036
+ originallyExternal: false,
41037
+ rule: {
41038
+ ...supabaseTableMissingRls,
41039
+ framework: "global",
41040
+ category: "Security",
41041
+ tags: [...new Set(["security-scan", ...supabaseTableMissingRls.tags ?? []])]
41042
+ }
41043
+ },
40503
41044
  {
40504
41045
  key: "react-doctor/svg-filter-clickjacking-risk",
40505
41046
  id: "svg-filter-clickjacking-risk",
@@ -40690,6 +41231,18 @@ const reactDoctorRules = [
40690
41231
  tags: [...new Set(["security-scan", ...tenantStaticProxyRisk.tags ?? []])]
40691
41232
  }
40692
41233
  },
41234
+ {
41235
+ key: "react-doctor/unsafe-json-in-html",
41236
+ id: "unsafe-json-in-html",
41237
+ source: "react-doctor",
41238
+ originallyExternal: false,
41239
+ rule: {
41240
+ ...unsafeJsonInHtml,
41241
+ framework: "global",
41242
+ category: "Security",
41243
+ tags: [...new Set(["security-scan", ...unsafeJsonInHtml.tags ?? []])]
41244
+ }
41245
+ },
40693
41246
  {
40694
41247
  key: "react-doctor/untrusted-redirect-following",
40695
41248
  id: "untrusted-redirect-following",