diff --git a/.changeset/cool-fans-fly.md b/.changeset/cool-fans-fly.md new file mode 100644 index 000000000..782691d33 --- /dev/null +++ b/.changeset/cool-fans-fly.md @@ -0,0 +1,9 @@ +--- +"react-doctor": patch +"@react-doctor/core": patch +"oxlint-plugin-react-doctor": patch +--- + +Add a project-level security file scan: 36 first-class scan rules (leaked artifact secrets and env dumps, permissive Firebase/Supabase rules, raw SQL injection risk, unsafe webhook signature comparisons, committed private key material, public debug artifacts, …) ship in the oxlint plugin as ordinary `defineRule` modules that declare a project-level `scan` instead of AST visitors and run in `@react-doctor/core`'s environment-check phase over one bounded whole-tree walk — covering shipped bundles, dotenv/config files, SQL, and Firebase rules files that per-file linting never sees. + +Scan rules register metadata (id, title, severity, recommendation, `Security` category, `security-scan` tag) like any other rule but carry a project-level `scan` instead of AST visitors, so their findings flow through the standard diagnostic pipeline: per-rule and per-category severity overrides, inline disables, and output `surfaces` now apply to scan-rule diagnostics, and `react-doctor rules ignore-tag security-scan` (config `ignore.tags`) silences the whole family. They never appear in generated oxlint configs or the ESLint presets — they only execute through React Doctor's scan. A plain `--diff` / `--staged` scan skips them like the other whole-project checks, and the gate is now diff mode itself rather than the presence of include paths, so projects configuring `ignore.files` get the security scan too. diff --git a/docs/HOW_TO_WRITE_A_RULE.md b/docs/HOW_TO_WRITE_A_RULE.md index 989ee064e..a0b1659c7 100644 --- a/docs/HOW_TO_WRITE_A_RULE.md +++ b/docs/HOW_TO_WRITE_A_RULE.md @@ -243,6 +243,52 @@ function reducer(state, action) { Do not report this. The mutation path returns a new object. The `return state` path is a no-op. +### Scan Rules (Project-Level) + +Most rules are per-file AST rules. Use a scan rule when the signal lives in the file system, not in source syntax: + +- The target files are never linted: shipped bundles, `.env` and config files, SQL, Firebase rules, repository secret files. +- Path context (`public/`, build output, repository layout) matters more than code shape. +- A content scan over a whole-tree file walk is the right precision. + +If the bug is a JavaScript/TypeScript code shape in linted source, write a normal AST rule instead. + +Scan rules live in `packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/` and declare a `scan` instead of `create` in their `defineRule` call. A real example (`firebase-permissive-rules.ts`): + +```ts +export const firebasePermissiveRules = defineRule({ + id: "firebase-permissive-rules", + title: "Permissive Firebase security rule", + severity: "error", + recommendation: + "Bind every read/write to `request.auth.uid`, immutable ownership, and tenant membership instead of treating sign-in as authorization.", + scan: scanByPattern({ + shouldScan: (file) => isFirebaseRulesPath(file.relativePath), + pattern: /allow\s+(?:read|write|...)\s*:\s*if\s+(?:true|request\.auth\s*!=\s*null)/i, + message: "Firebase rules grant broad access to everyone or to any signed-in user.", + }), +}); +``` + +The scan contract: + +- `scan(file: ScannedFile): ScanFinding[]` replaces AST visitors. +- `ScannedFile` carries `absolutePath`, `relativePath`, `content`, and `isGeneratedBundle`. +- Each `ScanFinding` has `message`, `line`, `column`, and optional `severity`/`title`/`help` fields that override the rule's registry metadata per finding (for example, `public-debug-artifact` escalates to `"error"` when the artifact contains a secret value). Omit them to inherit the rule's `severity`/`title`/`recommendation`. + +Registration, tags, and severity flow identically to normal rules: + +- Codegen picks the rule up like any other: the `security-scan` bucket auto-applies the `Security` category and the `security-scan` tag. +- `id:` and `severity:` must stay literal fields in the rule file — `scripts/generate-rule-registry.mjs` regex-parses them. +- Capability gating, `disabledBy`, user severity overrides, inline disables, and `ignore.tags` apply the same as for AST rules. + +Execution is different: scan rules never appear in generated oxlint configs or ESLint presets. `@react-doctor/core`'s `check-security-scan` environment check runs every registered `scan` over one bounded whole-tree walk; diff/staged scans skip it like the other whole-project checks. + +To test: + +- Unit-test one rule with the in-memory harness in `packages/oxlint-plugin-react-doctor/src/test-utils/run-scan-rule.ts` (build a `ScannedFile`, assert the findings) in a co-located test file. +- End-to-end coverage runs through `packages/core/tests/check-security-scan.test.ts` against fixture trees in `packages/core/tests/fixtures/check-security-scan/`. + ### V1 Scope Do not mix adjacent rule ideas into v1. diff --git a/packages/core/src/check-security-scan.ts b/packages/core/src/check-security-scan.ts new file mode 100644 index 000000000..d79d80ceb --- /dev/null +++ b/packages/core/src/check-security-scan.ts @@ -0,0 +1,60 @@ +import { REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor"; +import type { FileScan } from "oxlint-plugin-react-doctor"; +import { buildSecurityScanDiagnostic } from "./checks/security-scan/build-security-scan-diagnostic.js"; +import type { SecurityScanRuleEntry } from "./checks/security-scan/build-security-scan-diagnostic.js"; +import { collectSecurityScanFiles } from "./checks/security-scan/collect-security-scan-files.js"; +import { buildCapabilities, shouldEnableRule } from "./runners/oxlint/capabilities.js"; +import type { Diagnostic, ProjectInfo } from "./types/index.js"; + +export interface CheckSecurityScanOptions { + readonly project?: ProjectInfo; + readonly ignoredTags?: ReadonlySet; +} + +interface EnabledScanRule { + readonly entry: SecurityScanRuleEntry; + readonly scan: FileScan; +} + +// Project-level security scan check: registry rules carrying a +// `scan` are excluded from the generated oxlint config and instead run here +// over one bounded whole-tree walk (shipped artifacts, dotenv/config files, +// SQL — paths lint never sees). Selection goes through the same +// `shouldEnableRule` capability/tag gate as lint rules, so `--ignore-tag +// security-scan` and `disabledBy` behave identically across both engines. +export const checkSecurityScan = ( + rootDirectory: string, + options: CheckSecurityScanOptions = {}, +): Diagnostic[] => { + const capabilities = options.project ? buildCapabilities(options.project) : new Set(); + const ignoredTags = options.ignoredTags ?? new Set(); + + const enabledScanRules: EnabledScanRule[] = REACT_DOCTOR_RULES.flatMap((entry) => { + const rule = entry.rule; + const scan = rule.scan; + if (typeof scan !== "function") return []; + if (rule.defaultEnabled === false) return []; + if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags, rule.disabledBy)) { + return []; + } + return [{ entry, scan }]; + }); + if (enabledScanRules.length === 0) return []; + + const diagnostics: Diagnostic[] = []; + const seen = new Set(); + + for (const file of collectSecurityScanFiles(rootDirectory)) { + for (const { entry, scan } of enabledScanRules) { + for (const finding of scan(file)) { + const diagnostic = buildSecurityScanDiagnostic(finding, entry, file.relativePath); + const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`; + if (seen.has(key)) continue; + seen.add(key); + diagnostics.push(diagnostic); + } + } + } + + return diagnostics; +}; diff --git a/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts b/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts new file mode 100644 index 000000000..fe59b8077 --- /dev/null +++ b/packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts @@ -0,0 +1,29 @@ +import type { ScanFinding, Rule } from "oxlint-plugin-react-doctor"; +import type { Diagnostic } from "../../types/index.js"; + +export interface SecurityScanRuleEntry { + readonly id: string; + readonly rule: Pick; +} + +// Shared shape for every security-scan diagnostic. Metadata is +// single-sourced from the registry rule (plugin severity vocab `warn` +// maps to core `warning`); a finding may override `severity`/`title`/ +// `help` for dynamic escalation (e.g. `public-debug-artifact` when the +// artifact carries a live secret). +export const buildSecurityScanDiagnostic = ( + finding: ScanFinding, + entry: SecurityScanRuleEntry, + relativePath: string, +): Diagnostic => ({ + filePath: relativePath, + plugin: "react-doctor", + rule: entry.id, + severity: (finding.severity ?? entry.rule.severity) === "warn" ? "warning" : "error", + title: finding.title ?? entry.rule.title ?? entry.id, + message: finding.message, + help: finding.help ?? entry.rule.recommendation ?? "", + line: finding.line, + column: finding.column, + category: "Security", +}); diff --git a/packages/core/src/checks/security-scan/collect-security-scan-files.ts b/packages/core/src/checks/security-scan/collect-security-scan-files.ts new file mode 100644 index 000000000..b218a7304 --- /dev/null +++ b/packages/core/src/checks/security-scan/collect-security-scan-files.ts @@ -0,0 +1,114 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + classifySecurityScanFile, + shouldReadSecurityScanContent, +} from "oxlint-plugin-react-doctor"; +import type { ScannedFile } from "oxlint-plugin-react-doctor"; +import { readDirectoryEntries } from "../../project-info/utils/read-directory-entries.js"; +import { isLargeMinifiedFile } from "../../utils/is-large-minified-file.js"; +import { + SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES, + SECURITY_SCAN_MAX_DIRECTORY_DEPTH, + SECURITY_SCAN_MAX_FILES, + SECURITY_SCAN_MAX_FILE_SIZE_BYTES, + SKIPPED_DIRECTORY_NAMES, +} from "./constants.js"; + +interface DirectoryStackEntry { + readonly absolutePath: string; + readonly depth: number; +} + +interface SecurityScanCandidate { + readonly absolutePath: string; + readonly relativePath: string; + readonly isGeneratedBundleByName: boolean; +} + +const readScannedFile = (candidate: SecurityScanCandidate): ScannedFile | null => { + let stat: fs.Stats; + try { + stat = fs.statSync(candidate.absolutePath); + } catch { + return null; + } + if (!stat.isFile()) return null; + + const isGeneratedBundle = + candidate.isGeneratedBundleByName || isLargeMinifiedFile(candidate.absolutePath); + const maxSizeBytes = isGeneratedBundle + ? SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES + : SECURITY_SCAN_MAX_FILE_SIZE_BYTES; + if (stat.size > maxSizeBytes) return null; + if (!shouldReadSecurityScanContent(candidate.relativePath, isGeneratedBundle)) return null; + + try { + return { + absolutePath: candidate.absolutePath, + relativePath: candidate.relativePath, + content: fs.readFileSync(candidate.absolutePath, "utf-8"), + isGeneratedBundle, + }; + } catch { + return null; + } +}; + +// Bounded whole-tree walk feeding the security-scan rules: candidate paths +// are bucketed priority → artifact → other by `classifySecurityScanFile` +// so config/secret files and shipped browser artifacts survive the cap on +// huge repositories. Only paths are collected up front; contents are read +// lazily, one file per iteration (capped at SECURITY_SCAN_MAX_FILES +// successful reads per bucket), so a caller that streams findings never +// holds more than one file's content at a time. +export function* collectSecurityScanFiles( + rootDirectory: string, +): Generator { + const priorityCandidates: SecurityScanCandidate[] = []; + const artifactCandidates: SecurityScanCandidate[] = []; + const otherCandidates: SecurityScanCandidate[] = []; + const stack: DirectoryStackEntry[] = [{ absolutePath: rootDirectory, depth: 0 }]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (current.depth > SECURITY_SCAN_MAX_DIRECTORY_DEPTH) continue; + + for (const entry of readDirectoryEntries(current.absolutePath)) { + const absolutePath = path.join(current.absolutePath, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + stack.push({ absolutePath, depth: current.depth + 1 }); + } + continue; + } + + const relativePath = path.relative(rootDirectory, absolutePath).replaceAll("\\", "/"); + const classification = classifySecurityScanFile(relativePath); + if (classification === null) continue; + const candidates = + classification.bucket === "priority" + ? priorityCandidates + : classification.bucket === "artifact" + ? artifactCandidates + : otherCandidates; + candidates.push({ + absolutePath, + relativePath, + isGeneratedBundleByName: classification.isGeneratedBundleByName, + }); + } + } + + for (const candidates of [priorityCandidates, artifactCandidates, otherCandidates]) { + let yieldedCount = 0; + for (const candidate of candidates) { + if (yieldedCount >= SECURITY_SCAN_MAX_FILES) break; + const scannedFile = readScannedFile(candidate); + if (scannedFile === null) continue; + yieldedCount += 1; + yield scannedFile; + } + } +} diff --git a/packages/core/src/checks/security-scan/constants.ts b/packages/core/src/checks/security-scan/constants.ts new file mode 100644 index 000000000..687b292aa --- /dev/null +++ b/packages/core/src/checks/security-scan/constants.ts @@ -0,0 +1,13 @@ +export const SECURITY_SCAN_MAX_FILES = 2500; +export const SECURITY_SCAN_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; +export const SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES = 8 * 1024 * 1024; +export const SECURITY_SCAN_MAX_DIRECTORY_DEPTH = 8; + +export const SKIPPED_DIRECTORY_NAMES = new Set([ + ".git", + ".turbo", + ".vercel", + "coverage", + "node_modules", + "tmp", +]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dddcc0855..d084d5e61 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export * from "./check-pnpm-hardening.js"; export * from "./check-react-native-project.js"; export * from "./check-react-server-components-advisory.js"; export * from "./check-reduced-motion.js"; +export * from "./check-security-scan.js"; export * from "./check-supply-chain.js"; export * from "./collect-ignore-patterns.js"; export * from "./compute-diagnostic-delta.js"; diff --git a/packages/core/src/project-info/parse-tailwind-major-minor.ts b/packages/core/src/project-info/parse-tailwind-major-minor.ts index 1f51745e1..5064b24f8 100644 --- a/packages/core/src/project-info/parse-tailwind-major-minor.ts +++ b/packages/core/src/project-info/parse-tailwind-major-minor.ts @@ -1,3 +1,5 @@ +import * as semver from "semver"; + // HACK: react-doctor reads the project's Tailwind version straight out // of package.json (the `tailwindcss` dep), which produces semver ranges // (`^3.4.1`, `~3.3.0`, `>=3 <5`, `4.x`, `latest`, etc.) — never a @@ -10,6 +12,14 @@ interface TailwindMajorMinor { minor: number; } +// Lower bound of a range (`>=3.4 <5` → 3.4.0), with `coerce` as the +// fallback for non-range specs that still embed a version +// (`npm:tailwindcss@^3.4.1`). Tags (`latest`, `next`) resolve to null. +const parseLowerBoundVersion = (versionSpec: string): semver.SemVer | null => + semver.validRange(versionSpec) !== null + ? semver.minVersion(versionSpec) + : semver.coerce(versionSpec); + export const parseTailwindMajorMinor = ( tailwindVersion: string | null | undefined, ): TailwindMajorMinor | null => { @@ -17,20 +27,9 @@ export const parseTailwindMajorMinor = ( const trimmed = tailwindVersion.trim(); if (trimmed.length === 0) return null; - const majorMinorMatch = trimmed.match(/(\d+)\.(\d+)/); - if (majorMinorMatch) { - const major = Number.parseInt(majorMinorMatch[1], 10); - const minor = Number.parseInt(majorMinorMatch[2], 10); - if (!Number.isFinite(major) || major <= 0) return null; - if (!Number.isFinite(minor) || minor < 0) return null; - return { major, minor }; - } - - const majorOnlyMatch = trimmed.match(/(\d+)/); - if (!majorOnlyMatch) return null; - const major = Number.parseInt(majorOnlyMatch[1], 10); - if (!Number.isFinite(major) || major <= 0) return null; - return { major, minor: 0 }; + const lowerBound = parseLowerBoundVersion(trimmed); + if (lowerBound === null || lowerBound.major <= 0) return null; + return { major: lowerBound.major, minor: lowerBound.minor }; }; export const isTailwindAtLeast = ( diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index dcd8a22e4..6a159aff5 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -18,6 +18,7 @@ import { checkPnpmHardening } from "./check-pnpm-hardening.js"; import { checkReactNativeProject } from "./check-react-native-project.js"; import { checkReactServerComponentsAdvisory } from "./check-react-server-components-advisory.js"; import { checkReducedMotion } from "./check-reduced-motion.js"; +import { checkSecurityScan } from "./check-security-scan.js"; import { DEFAULT_SHOW_WARNINGS } from "./constants.js"; import { highlighter } from "./highlighter.js"; import { computeExplicitLintIncludePaths } from "./explicit-lint-include-paths.js"; @@ -221,7 +222,8 @@ const formatLintFailText = ( * * 1. Config.resolve(directory) → Project.discover → Git metadata * 2. beforeLint hook (e.g. CLI renders the project-detection block) - * 3. environment checks (reduced-motion + pnpm hardening) + * 3. environment checks (reduced-motion + pnpm hardening + + * expo/react-native + security scan) * 4. Linter.run + DeadCode.run — forked as concurrent fibers so * their wall-clock times overlap. Progress spinners stay * sequential (lint first, then dead-code) for clean terminal @@ -348,6 +350,7 @@ export const runInspect = ( ...checkReactServerComponentsAdvisory(scanDirectory, project), ...checkExpoProject(scanDirectory, project), ...checkReactNativeProject(scanDirectory, project), + ...checkSecurityScan(scanDirectory, { project, ignoredTags: input.ignoredTags }), ]; const envCollected = yield* Stream.runCollect( applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)), diff --git a/packages/core/src/runners/oxlint/config.ts b/packages/core/src/runners/oxlint/config.ts index be561e752..f9c23552b 100644 --- a/packages/core/src/runners/oxlint/config.ts +++ b/packages/core/src/runners/oxlint/config.ts @@ -115,6 +115,9 @@ export const createOxlintConfig = ({ for (const registryEntry of REACT_DOCTOR_RULES) { const rule = reactDoctorPlugin.rules[registryEntry.id]; if (!rule) continue; + // Scan rules run via core's check-security-scan environment + // check, not oxlint — registering them would only add dead visitors. + if (rule.scan !== undefined) continue; // `customRulesOnly` mirrors the historical behavior of the pre-port // builtin-react / builtin-a11y gate — skip everything ported 1:1 // from upstream OXC plugins. diff --git a/packages/core/tests/check-security-scan.test.ts b/packages/core/tests/check-security-scan.test.ts new file mode 100644 index 000000000..a119cec91 --- /dev/null +++ b/packages/core/tests/check-security-scan.test.ts @@ -0,0 +1,805 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { checkSecurityScan } from "@react-doctor/core"; +import type { Diagnostic } from "@react-doctor/core"; +import { REACT_DOCTOR_RULES } from "oxlint-plugin-react-doctor"; + +const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, "fixtures", "check-security-scan"); + +let temporaryRoot: string; + +const writeFile = (relativePath: string, content: string): void => { + const absolutePath = path.join(temporaryRoot, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); +}; + +const rulesOf = (diagnostics: ReadonlyArray): ReadonlySet => + new Set(diagnostics.map((diagnostic) => diagnostic.rule)); + +const fixtureRules = (fixtureName: string): ReadonlySet => + rulesOf(checkSecurityScan(path.join(FIXTURES_DIRECTORY, fixtureName))); + +beforeEach(() => { + temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-security-scan-")); +}); + +afterEach(() => { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +describe("checkSecurityScan", () => { + describe("Eva-grounded fixtures", () => { + it("flags an a16z-style full server env object shipped in a browser chunk", () => { + expect(fixtureRules("eva-a16z-env-bundle")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak"]), + ); + }); + + it("flags a GamerSafer-style public React env secret leak in source and build output", () => { + expect(fixtureRules("eva-gamersafer-public-env")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-env-secret-name"]), + ); + }); + + it("flags minified/generated widget bundles outside normal framework output folders", () => { + expect(fixtureRules("eva-minified-widget-bundle")).toEqual(new Set(["artifact-secret-leak"])); + }); + + it("flags Mintlify-style MDX SSR plus cross-tenant static asset exposure", () => { + expect(fixtureRules("eva-mintlify-docs-platform")).toEqual( + new Set(["active-static-asset", "mdx-ssr-execution-risk", "tenant-static-proxy-risk"]), + ); + }); + + it("flags Arc and Chattr style Firebase authorization mistakes", () => { + expect(fixtureRules("eva-arc-chattr-firebase")).toEqual( + new Set([ + "artifact-baas-authority-surface", + "firebase-client-owned-authz-field", + "firebase-permissive-rules", + "firebase-query-filter-as-auth", + ]), + ); + }); + + it("flags a ToDesktop-style release pipeline where install scripts run near release secrets", () => { + expect(fixtureRules("eva-todesktop-release-pipeline")).toEqual( + new Set(["build-pipeline-secret-boundary"]), + ); + }); + + it("flags an ASUS DriverHub-style localhost RPC and updater bridge", () => { + expect(fixtureRules("mrbruh-asus-local-rpc")).toEqual( + new Set(["local-rpc-native-bridge-risk", "plugin-update-trust-risk"]), + ); + }); + + it("flags a Fooocus-style metadata eval import path", () => { + expect(fixtureRules("mrbruh-fooocus-metadata-eval")).toEqual( + new Set(["import-metadata-execution-risk"]), + ); + }); + + it("flags a Lyra-style iframe redirect chain with prefilled privileged URL parameters", () => { + expect(fixtureRules("lyra-clickjacking-redirect-chain")).toEqual( + new Set(["clickjacking-redirect-risk", "url-prefilled-privileged-action"]), + ); + }); + + it("flags a Lyra-style SVG-filtered iframe clickjacking primitive", () => { + expect(fixtureRules("lyra-svg-filter-clickjacking")).toEqual( + new Set(["svg-filter-clickjacking-risk"]), + ); + }); + + it("flags a Supabase service-role key exposed through public client config", () => { + expect(fixtureRules("supabase-service-role-public-client")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-env-secret-name"]), + ); + }); + + it("flags common webhook and provider tokens shipped in browser bundles", () => { + expect(fixtureRules("broad-provider-token-bundle")).toEqual( + new Set(["artifact-secret-leak"]), + ); + }); + + it("flags permissive Supabase RLS plus client-owned tenant and role columns", () => { + expect(fixtureRules("supabase-rls-client-owned-authz")).toEqual( + new Set(["supabase-client-owned-authz-field", "supabase-rls-policy-risk"]), + ); + }); + + it("flags docs-domain credentialed CORS and broad first-party auth cookies", () => { + expect(fixtureRules("docs-cookie-cors-trust")).toEqual(new Set(["cors-cookie-trust-risk"])); + }); + + it("flags public debug logs and source maps that expose server env references", () => { + expect(fixtureRules("public-debug-sourcemap-leak")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-debug-artifact"]), + ); + }); + + it("flags checked-in private release key material and key-shaped CI variables", () => { + expect(fixtureRules("release-key-material-leak")).toEqual(new Set(["key-lifecycle-risk"])); + }); + + it("flags committed env, service-account, and npm auth credential files", () => { + expect(fixtureRules("repository-secret-files")).toEqual( + new Set(["key-lifecycle-risk", "repository-secret-file"]), + ); + }); + + it("flags ported static matcher patterns for postMessage, redirect-following fetches, and dynamic HTML", () => { + expect(fixtureRules("ported-static-matcher-patterns")).toEqual( + new Set(["dangerous-html-sink", "postmessage-origin-risk", "untrusted-redirect-following"]), + ); + }); + + it("keeps safe postMessage, manual redirect, and static HTML patterns quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "ported-static-matcher-safe-patterns")), + ).toEqual([]); + }); + + it("flags ported agent, MCP, SQL, NoSQL, and command execution matcher patterns", () => { + expect(fixtureRules("ported-agent-mcp-tool-risks")).toEqual( + new Set(["agent-tool-capability-risk", "mcp-tool-capability-risk"]), + ); + expect(fixtureRules("ported-database-and-command-risks")).toEqual( + new Set(["command-execution-input-risk", "nosql-injection-risk", "raw-sql-injection-risk"]), + ); + }); + + it("keeps safe agent tools and parameterized database calls quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "ported-agent-database-safe-patterns")), + ).toEqual([]); + }); + + it("flags ported path traversal, git URL injection, webhook signature, and crypto risks", () => { + expect(fixtureRules("ported-web-security-risks")).toEqual( + new Set([ + "git-provider-url-injection-risk", + "insecure-crypto-risk", + "path-traversal-risk", + "webhook-signature-risk", + ]), + ); + }); + + it("keeps safe path, git URL, webhook signature, and crypto patterns quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "ported-web-security-safe-patterns")), + ).toEqual([]); + }); + + it("keeps redacted env examples quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "repository-secret-examples")), + ).toEqual([]); + }); + + it("keeps public Supabase chat browser bundles quiet when they expose no authority fields", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "real-supabase-chat-browser-bundle")), + ).toEqual([]); + }); + + it("keeps known browser-facing analytics, license, map, and search keys quiet", () => { + expect(checkSecurityScan(path.join(FIXTURES_DIRECTORY, "real-public-env-keys"))).toEqual([]); + }); + + it("keeps server-only Supabase service-role routes quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "real-server-service-role-route")), + ).toEqual([]); + }); + + it("keeps public-read private-write Supabase RLS policies quiet", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "real-supabase-public-read-private-write")), + ).toEqual([]); + }); + + it("stays quiet on a hardened app with scoped rules and public-only browser config", () => { + expect(checkSecurityScan(path.join(FIXTURES_DIRECTORY, "safe-hardened-app"))).toEqual([]); + }); + }); + + it("covers the P0-P2 P0-P2 security analyzer families", () => { + writeFile( + ".next/static/chunks/app.js", + `window.__ENV__ = { AWS_SECRET_ACCESS_KEY: "very-secret", AWS_ACCESS_KEY_ID: "AKIAABCDEFGHIJKLMNOP" };`, + ); + writeFile("public/debug.log", "DATABASE_URL=postgres://user:pass@example.com/db\nstack trace"); + writeFile("public/xss.svg", ``); + writeFile( + "firestore.rules", + `service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.auth != null; } } }`, + ); + writeFile( + "src/firebase-client.tsx", + `export const save = () => setDoc(ref, { creatorID: profile.id, role: "SuperAdmin" }); db.collection("sessions").where("userId", "==", userId);`, + ); + writeFile( + "supabase/migrations/001.sql", + `alter table profiles disable row level security; create policy "open" on profiles using (true);`, + ); + writeFile( + "src/supabase-client.ts", + `export const save = (supabase, ownerId) => supabase.from("docs").upsert({ ownerId, role: "admin" });`, + ); + writeFile( + "package.json", + JSON.stringify({ scripts: { postinstall: "node scripts/postinstall.js" } }, null, 2), + ); + writeFile( + ".github/workflows/release.yml", + `steps:\n - run: pnpm install\n env:\n RELEASE_TOKEN: \${{ secrets.RELEASE_TOKEN }}\n`, + ); + writeFile( + "app/api/static/[tenant]/route.ts", + `export const GET = async (_, { params }) => fetch(CDN + "/" + params.tenant + "/" + decodeURIComponent(params.path.join("/")));`, + ); + // Untrusted-shaped source: rendering a project's own docs MDX is the + // benign default, so the rule keys on request/tenant-shaped input. + writeFile( + "src/render-mdx.ts", + `import { compileMDX } from "next-mdx-remote/rsc"; export const render = (req) => compileMDX({ source: req.body.markdown });`, + ); + writeFile( + "src/local-bridge.ts", + `if (origin.includes("driverhub.asus.com")) new WebSocket("ws://127.0.0.1:53000/UpdateApp");`, + ); + writeFile( + "src/share-dialog.tsx", + `const email = new URLSearchParams(location.search).get("userstoinvite"); const role = searchParams.get("role");`, + ); + writeFile( + "src/redirect.ts", + `export const GET = (request) => redirect(request.nextUrl.searchParams.get("next"));`, + ); + writeFile( + "src/import-metadata.ts", + `export const apply = (metadata) => eval(metadata.exifPreset);`, + ); + writeFile( + "src/message-listener.ts", + `window.addEventListener("message", (event) => window.dispatchEvent(new CustomEvent("x", { detail: event.data })));`, + ); + writeFile( + "app/api/preview/route.ts", + `export const POST = async (request) => { const { imageUrl } = await request.json(); return fetch(imageUrl); };`, + ); + writeFile( + "src/remote-html.tsx", + `export const RemoteHtml = ({ html }) =>
;`, + ); + writeFile( + "src/agents/tools/run-command.ts", + `import { tool } from "ai"; import { execFile } from "node:child_process"; export const t = tool({ execute: async ({ command }) => execFile(command, []) });`, + ); + writeFile( + "src/mcp/server.ts", + `import { McpServer } from "@modelcontextprotocol/sdk/server/index.js"; import { readFile } from "node:fs/promises"; const server = new McpServer({ name: "x", version: "1" }); server.tool("read_file", async ({ path }) => readFile(path, "utf-8"));`, + ); + writeFile( + "src/raw-sql.ts", + "export const q = (prisma, id) => prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${id}'`);", + ); + writeFile( + "src/nosql.ts", + "export const q = (collection, request) => collection.find(JSON.parse(request.body.filter));", + ); + writeFile( + "app/api/files/route.ts", + `export const POST = async (request) => readFile(request.body.path, "utf-8");`, + ); + // Request-shaped interpolation: bare `${owner}` parameters proved to be + // internal config in practice, so the rule keys on request property reads. + writeFile( + "src/github-import.ts", + "export const build = (req) => `https://api.github.com/repos/${req.query.owner}/${req.query.repo}`;", + ); + writeFile( + "app/api/stripe/webhook/route.ts", + `export const POST = async (request) => { const event = await request.json(); return Response.json({ received: event.type }); };`, + ); + writeFile("src/session-token.ts", `export const token = () => Math.random().toString(36);`); + // Not under `scripts/`: build-script paths are excluded from production + // source on purpose (see BUILD_SCRIPT_CONTEXT_PATTERN). + writeFile( + "backend/report.py", + "import os\ndef run(request):\n os.system(f\"wkhtmltopdf {request.args['url']} /tmp/report.pdf\")\n", + ); + writeFile( + "src/updater.ts", + `import { execFile } from "node:child_process"; export const update = (updateUrl) => execFile("installer", [updateUrl ?? "https://example.com/app.exe"]);`, + ); + writeFile( + "secrets/signing-key.txt", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n", + ); + writeFile( + "src/cors.ts", + `headers.set("Access-Control-Allow-Credentials", "true"); headers.set("Access-Control-Allow-Origin", "https://docs.example.com"); res.setHeader("Set-Cookie", "session=abc; Domain=.example.com");`, + ); + writeFile("next.config.js", `export default { images: { dangerouslyAllowSVG: true } };`); + + const rules = rulesOf(checkSecurityScan(temporaryRoot)); + + expect(rules).toEqual( + new Set([ + "active-static-asset", + "artifact-env-leak", + "artifact-secret-leak", + "build-pipeline-secret-boundary", + "clickjacking-redirect-risk", + "cors-cookie-trust-risk", + "firebase-client-owned-authz-field", + "firebase-permissive-rules", + "firebase-query-filter-as-auth", + "import-metadata-execution-risk", + "key-lifecycle-risk", + "local-rpc-native-bridge-risk", + "mdx-ssr-execution-risk", + "plugin-update-trust-risk", + "public-debug-artifact", + "supabase-client-owned-authz-field", + "supabase-rls-policy-risk", + "tenant-static-proxy-risk", + "postmessage-origin-risk", + "untrusted-redirect-following", + "dangerous-html-sink", + "url-prefilled-privileged-action", + "agent-tool-capability-risk", + "mcp-tool-capability-risk", + "raw-sql-injection-risk", + "nosql-injection-risk", + "command-execution-input-risk", + "path-traversal-risk", + "git-provider-url-injection-risk", + "webhook-signature-risk", + "insecure-crypto-risk", + ]), + ); + }); + + it("stays quiet on public client keys, Firebase config alone, SVG images, and test keys", () => { + writeFile( + "public/app.js", + `window.config = { firebase: { apiKey: "AIzaSyPublicNotASecret", projectId: "demo" }, sentry: "https://abc@o1.ingest.sentry.io/2", stripe: "pk_live_1234567890" };`, + ); + writeFile("src/logo.tsx", `export const Logo = () => ;`); + writeFile( + "tests/fixtures/private-key.txt", + "-----BEGIN OPENSSH PRIVATE KEY-----\nfixture only\n", + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("adds Security metadata and source locations for direct pattern matches", () => { + writeFile( + "firestore.rules", + `rules_version = "2";\nservice cloud.firestore {\n match /databases/{database}/documents {\n allow read, write: if true;\n }\n}\n`, + ); + + const diagnostics = checkSecurityScan(temporaryRoot); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + plugin: "react-doctor", + rule: "firebase-permissive-rules", + category: "Security", + severity: "error", + line: 4, + column: 5, + }); + }); + + it("uses concrete locations for env leak shapes and public env secret names", () => { + writeFile( + "dist/assets/app.js.map", + `window.__ENV__ = {\n DATABASE_URL: "postgres://user:pass@example.com/app"\n};`, + ); + writeFile(".env", "NEXT_PUBLIC_SECRET_TOKEN=placeholder\n"); + + expect(checkSecurityScan(temporaryRoot)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule: "artifact-env-leak", + line: 2, + column: 3, + }), + expect.objectContaining({ + rule: "repository-secret-file", + line: 1, + column: 1, + }), + ]), + ); + }); + + it("uses concrete locations for package metadata secret values", () => { + writeFile("package.json", `{\n "token": "ghp_abcdefghijklmnopqrstuvwxyz123456"\n}\n`); + + expect(checkSecurityScan(temporaryRoot)).toEqual([ + expect.objectContaining({ + rule: "package-metadata-secret", + line: 2, + column: 13, + }), + ]); + }); + + it("reports postMessage handlers at the unsafe handler location", () => { + writeFile( + "src/message-listener.ts", + `window.addEventListener("message", (event) => {\n if (event.origin !== "https://example.com") return;\n window.dispatchEvent(new CustomEvent("safe", { detail: event.data }));\n});\nwindow.addEventListener("message", (event) => {\n window.dispatchEvent(new CustomEvent("unsafe", { detail: event.data }));\n});\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([ + expect.objectContaining({ + rule: "postmessage-origin-risk", + line: 5, + column: 1, + }), + ]); + }); + + it("reports postMessage handlers that read data before checking origin", () => { + writeFile( + "src/message-order.ts", + `window.addEventListener("message", (event) => {\n window.dispatchEvent(new CustomEvent("unsafe", { detail: event.data }));\n if (event.origin !== "https://example.com") return;\n});\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([ + expect.objectContaining({ + rule: "postmessage-origin-risk", + line: 1, + column: 1, + }), + ]); + }); + + it("keeps unrelated redirect options quiet while scanning got shorthand calls", () => { + writeFile( + "app/api/config/route.ts", + `const defaultOptions = { redirect: "follow" };\nexport const GET = async () => fetch("https://example.com", defaultOptions);\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + + writeFile( + "app/api/proxy/route.ts", + `export const GET = async (request) => {\n const targetUrl = request.nextUrl.searchParams.get("targetUrl");\n return got.get(targetUrl);\n};\n`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("untrusted-redirect-following"); + }); + + it("does not treat server-only Next build output as a browser artifact", () => { + writeFile( + ".next/server/app/page.js", + `export const env = { AWS_SECRET_ACCESS_KEY: "server-only", AWS_ACCESS_KEY_ID: "AKIAABCDEFGHIJKLMNOP" };`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("does not treat ordinary package dist output as browser-shipped artifact output", () => { + writeFile( + "packages/api/dist/index.js", + `export const databaseUrl = process.env.DATABASE_URL ?? "postgres://api:password@db.internal.example.com/api";`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("still reports a real secret when a public key appears in the same browser bundle", () => { + writeFile( + "dist/assets/app.js", + `const publishable = "pk_live_1234567890"; const secret = "AKIAABCDEFGHIJKLMNOP";`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("artifact-secret-leak"); + }); + + it("discovers one-line minified bundles even without a generated bundle filename", () => { + const minifiedPrefix = `var bootstrap="${"a".repeat(21_000)}";`; + writeFile( + "packages/widget/client.js", + `${minifiedPrefix}var env={AWS_ACCESS_KEY_ID:"AKIAABCDEFGHIJKLMNOP"};window.Widget=env;`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("artifact-secret-leak"); + }); + + it("reports public env names that look secret-like in client source", () => { + writeFile("src/client.tsx", `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("public-env-secret-name"); + }); + + it("reports public env secret names at the suspicious name location", () => { + writeFile( + "src/client.tsx", + `export const analytics = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;\nexport const secret = process.env.NEXT_PUBLIC_SECRET_TOKEN;\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([ + expect.objectContaining({ + rule: "public-env-secret-name", + line: 2, + column: 35, + }), + ]); + }); + + it("does not report server-only public env probes as client secret exposure", () => { + writeFile( + "src/server/env.server.ts", + `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps tenant CDN fetches in client code quiet", () => { + writeFile( + "src/components/avatar.tsx", + `export const Avatar = ({ org }) => ;`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("does not flag CI installs that disable lifecycle scripts before secrets are available", () => { + writeFile( + ".github/workflows/test.yml", + `steps:\n - run: pnpm install --ignore-scripts\n env:\n RELEASE_TOKEN: \${{ secrets.RELEASE_TOKEN }}\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("reports source maps that expose server env names even without concrete values", () => { + writeFile( + "dist/assets/app.js.map", + JSON.stringify({ + version: 3, + sourcesContent: [`export const secret = process.env.DATABASE_URL;`], + }), + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("artifact-env-leak"); + }); + + it("keeps ownership-bound Firebase and Supabase policies quiet", () => { + writeFile( + "firestore.rules", + `service cloud.firestore { match /databases/{database}/documents { match /users/{userId} { allow read, write: if request.auth.uid == userId; } } }`, + ); + writeFile( + "supabase/migrations/002.sql", + `alter table profiles enable row level security; create policy "own profile" on profiles using (auth.uid() = user_id) with check (auth.uid() = user_id);`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("does not treat service_role mentions in SQL comments as an RLS bypass", () => { + writeFile( + "supabase/migrations/003_service_role_note.sql", + `alter table profiles enable row level security;\n-- service_role is intentionally not used in policies below\ncreate policy own_profile on profiles using (auth.uid() = user_id) with check (auth.uid() = user_id);\n`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("still scans high-priority SQL even when many artifact files exist first", () => { + for (let fileIndex = 0; fileIndex < 2600; fileIndex += 1) { + writeFile(`public/chunks/chunk-${fileIndex}.js`, `window.chunk${fileIndex} = ${fileIndex};`); + } + writeFile( + "supabase/migrations/999_open_write.sql", + `create policy "open writes" on documents for all using (true) with check (true);`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("supabase-rls-policy-risk"); + }); + + it("still scans source files when artifact files fill their own bucket", () => { + for (let fileIndex = 0; fileIndex < 2600; fileIndex += 1) { + writeFile(`public/chunks/chunk-${fileIndex}.js`, `window.chunk${fileIndex} = ${fileIndex};`); + } + writeFile("src/client.tsx", `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("public-env-secret-name"); + }); + + it("keeps public key blocks quiet while still flagging private key blocks", () => { + writeFile( + "keys/public.pem", + "-----BEGIN PUBLIC KEY-----\nnot-secret-public-material\n-----END PUBLIC KEY-----\n", + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + + writeFile( + "keys/private.pem", + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA39k9udklHnmkU0GtTLpnYtKk1l5txYmUDcGI0bFd3HHOOLG\n-----END RSA PRIVATE KEY-----\n", + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("key-lifecycle-risk"); + }); + + it("reports executable SVG embeds but not regular SVG image tags", () => { + writeFile("src/icon.tsx", `export const Icon = () => ;`); + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + + writeFile("src/embed.tsx", `export const Embed = () => ;`); + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("active-static-asset"); + }); + + it("keeps exact-origin local bridge checks quiet", () => { + writeFile( + "src/local-bridge.ts", + `if (origin === "https://driverhub.asus.com") new WebSocket("ws://127.0.0.1:53000/status");`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps docs examples out of source-only scanners", () => { + writeFile( + "README.md", + `Install the plugin with npm, then render examples with dangerouslySetInnerHTML in docs.`, + ); + writeFile( + "docs/security.md", + `window.addEventListener("message", (event) => console.log(event.data)); localhost update notes.`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps documentation sample keys quiet", () => { + writeFile( + "README.md", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n", + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).not.toContain("key-lifecycle-risk"); + }); + + it("keeps generated source examples quiet", () => { + writeFile( + "src/generated/icons.ts", + `// @generated\nexport const icons = ["javascript", "python", "zip", "svg", "import"];`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("still scans top-level public scripts as browser source", () => { + writeFile( + "public/widget.js", + `window.addEventListener("message", (event) => window.dispatchEvent(new CustomEvent("unsafe", { detail: event.data })));`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("postmessage-origin-risk"); + }); + + it("keeps generated public chunk scripts quiet for source-only scanners", () => { + writeFile( + "public/chunks/widget.js", + `window.addEventListener("message", (event) => window.dispatchEvent(new CustomEvent("unsafe", { detail: event.data })));`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps Vite browser config probes quiet", () => { + writeFile( + "vite.config.ts", + `import react from "@vitejs/plugin-react"; export default { plugins: [react()], define: { BRAVE_BINARY: "browser" } };`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("still reports timing-unsafe signature comparisons", () => { + writeFile( + "src/webhook-crypto.ts", + `if (signature !== expectedSignature) throw new Error("bad");`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain("insecure-crypto-risk"); + }); + + it("keeps placeholder signature comparisons quiet outside security-shaped code", () => { + writeFile("src/git-status.ts", `if (signature === "0") throw new Error("empty");`); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps signature string literal comparisons quiet", () => { + writeFile("src/event-kind.ts", `if (kind === "signature") return "signed";`); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps gesture origin variable names quiet", () => { + writeFile( + "src/gesture.ts", + `const { origin, distance } = getOriginAndDistance(touches[0], touches[1]); updatePinchState(false);`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("keeps non-Firebase immutable map writes quiet", () => { + writeFile( + "src/immutable-map.ts", + `class ImmutableMap { __ownerID = "owner"; set(key: string, value: string) { return [key, value]; } }`, + ); + + expect(checkSecurityScan(temporaryRoot)).toEqual([]); + }); + + it("still reports Firebase compat writes to authorization fields", () => { + writeFile( + "src/firebase-compat.ts", + `firebase.firestore().collection("documents").doc(id).set({ ownerId: user.uid, role: "admin" });`, + ); + + expect(rulesOf(checkSecurityScan(temporaryRoot))).toContain( + "firebase-client-owned-authz-field", + ); + }); + + it("flags secret-like values committed in package metadata", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "package-metadata-secret-leak")), + ).toEqual([ + expect.objectContaining({ + rule: "package-metadata-secret", + filePath: "package.json", + message: "Package metadata contains secret-like values or public env secret names.", + }), + ]); + }); + + it("disables every scan rule when the security-scan tag is ignored", () => { + expect( + checkSecurityScan(path.join(FIXTURES_DIRECTORY, "eva-todesktop-release-pipeline"), { + ignoredTags: new Set(["security-scan"]), + }), + ).toEqual([]); + }); + + it("single-sources diagnostic metadata from the registry rule", () => { + const entry = REACT_DOCTOR_RULES.find( + (candidate) => candidate.id === "build-pipeline-secret-boundary", + ); + if (entry === undefined) throw new Error("build-pipeline-secret-boundary not in registry"); + + const diagnostics = checkSecurityScan( + path.join(FIXTURES_DIRECTORY, "eva-todesktop-release-pipeline"), + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + rule: "build-pipeline-secret-boundary", + title: entry.rule.title, + help: entry.rule.recommendation, + severity: entry.rule.severity === "warn" ? "warning" : "error", + }); + }); +}); diff --git a/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/dist/assets/integrations.js b/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/dist/assets/integrations.js new file mode 100644 index 000000000..3e39fbf1c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/dist/assets/integrations.js @@ -0,0 +1,26 @@ +const integrationDefaults = { + notifications: { + slackWebhookUrl: "configured-server-side", + discordWebhookUrl: "configured-server-side", + }, + database: { + DATABASE_URL: "postgres://integrations:dashboard-password@db.internal.example.com/integrations", + }, + email: { + sendgridApiKey: "configured-server-side", + }, + billing: { + stripeRestrictedKey: "configured-server-side", + }, + ai: { + openAiProjectKey: "configured-server-side", + anthropicKey: "configured-server-side", + }, + developerTools: { + linearApiKey: "configured-server-side", + vercelToken: "configured-server-side", + sentryAuthToken: "configured-server-side", + }, +}; + +window.__INTEGRATIONS_DASHBOARD_CONFIG__ = integrationDefaults; diff --git a/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/package.json b/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/package.json new file mode 100644 index 000000000..193d8e213 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/broad-provider-token-bundle/package.json @@ -0,0 +1,12 @@ +{ + "name": "@example/integrations-dashboard", + "private": true, + "scripts": { + "build": "vite build" + }, + "dependencies": { + "@vitejs/plugin-react": "^latest", + "react": "^19.0.0", + "vite": "^latest" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/app/api/session/route.ts b/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/app/api/session/route.ts new file mode 100644 index 000000000..1936848f0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/app/api/session/route.ts @@ -0,0 +1,9 @@ +export const GET = () => { + return new Response("ok", { + headers: { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "https://docs.cursor.com", + "Set-Cookie": "session=abc; Domain=.cursor.com; Path=/", + }, + }); +}; diff --git a/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/package.json b/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/package.json new file mode 100644 index 000000000..8c799eca0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/docs-cookie-cors-trust/package.json @@ -0,0 +1,7 @@ +{ + "name": "docs-cookie-cors-trust", + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/.next/static/chunks/portfolio.js b/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/.next/static/chunks/portfolio.js new file mode 100644 index 000000000..8ac9814df --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/.next/static/chunks/portfolio.js @@ -0,0 +1,19 @@ +(() => { + const runtimeConfig = { + MARKETPLACE_URL: "https://portfolio.a16z.com/marketplace", + DATABASE_URL: + "postgres://portfolio_app:portfolio-db-password@db.internal.example.com/portfolio", + SALESFORCE_CLIENT_ID: "3MVG9lKcPoNINVBIPJjdw1J9LLM82HnFVV", + SALESFORCE_CLIENT_SECRET: "salesforce-client-secret-from-heroku-config", + OKTA_CLIENT_ID: "0oa1portfolio7I3dExample5d7", + OKTA_CLIENT_SECRET: "okta-client-secret-from-heroku-config", + AWS_BUCKET_NAME: "portfolio-company-logos-prod", + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + MAILGUN_API_KEY: "key-0123456789abcdef0123456789abcdef", + SESSION_SECRET: "portfolio-session-secret-that-should-stay-server-side", + COOKIE_SECRET: "portfolio-cookie-secret-that-should-stay-server-side", + }; + + self.__PORTFOLIO_ENV__ = runtimeConfig; +})(); diff --git a/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/package.json b/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/package.json new file mode 100644 index 000000000..8d80910ca --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-a16z-env-bundle/package.json @@ -0,0 +1,11 @@ +{ + "name": "@example/portfolio-portal", + "private": true, + "scripts": { + "build": "next build" + }, + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/build/static/js/main.js b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/build/static/js/main.js new file mode 100644 index 000000000..bcace851d --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/build/static/js/main.js @@ -0,0 +1,21 @@ +(() => { + const firebaseConfig = { + apiKey: "AIzaSyPublicFirebaseWebKey", + authDomain: "chattr-prod.firebaseapp.com", + projectId: "chattr-prod", + storageBucket: "chattr-prod.appspot.com", + }; + + const db = firebase.firestore(firebase.initializeApp(firebaseConfig)); + + window.__HIRING_PORTAL_COLLECTIONS__ = { + boosts: db.collection("boosts").where("creatorID", "==", window.currentUserId), + adminUsers: db.collection("orgs").doc("0").collection("users"), + candidateSessions: db.collection("sessions").where("userId", "==", window.currentUserId), + writableAdminShape: { + providerId: window.firebaseUserId, + ghostOrg: "0", + role: "SuperAdmin", + }, + }; +})(); diff --git a/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/firestore.rules b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/firestore.rules new file mode 100644 index 000000000..e8f8d7997 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/firestore.rules @@ -0,0 +1,7 @@ +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if request.auth != null; + } + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/package.json b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/package.json new file mode 100644 index 000000000..b3ac7a757 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/package.json @@ -0,0 +1,7 @@ +{ + "name": "eva-arc-chattr-firebase", + "dependencies": { + "firebase": "^12.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/boosts.ts b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/boosts.ts new file mode 100644 index 000000000..adb7814cd --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/boosts.ts @@ -0,0 +1,15 @@ +import { doc, setDoc, updateDoc } from "firebase/firestore"; + +export const createBoost = async (db: unknown, targetUserId: string, payload: string) => { + await setDoc(doc(db, "boosts", crypto.randomUUID()), { + creatorID: targetUserId, + hostPattern: "www.google.com", + javascript: payload, + }); +}; + +export const reassignBoost = async (boostReference: unknown, targetUserId: string) => { + await updateDoc(boostReference, { + creatorID: targetUserId, + }); +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/chattr-admin.ts b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/chattr-admin.ts new file mode 100644 index 000000000..0575e95eb --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-arc-chattr-firebase/src/chattr-admin.ts @@ -0,0 +1,29 @@ +interface FirestoreCollection { + readonly doc: (documentId: string) => FirestoreDocument; + readonly add: (value: Record) => Promise; + readonly where: (field: string, operator: string, value: unknown) => FirestoreCollection; + readonly limit: (count: number) => FirestoreCollection; + readonly get: () => Promise; +} + +interface FirestoreDocument { + readonly collection: (name: string) => FirestoreCollection; +} + +interface FirestoreClient { + readonly collection: (name: string) => FirestoreCollection; +} + +export const inviteSupportOperator = async (db: FirestoreClient, providerId: string) => { + await db.collection("orgs").doc("0").collection("users").add({ + email: "support@chattr.ai", + providerId, + ghostOrg: "0", + role: "SuperAdmin", + status: "active", + }); +}; + +export const loadCandidateSessions = (db: FirestoreClient, userId: string) => { + return db.collection("sessions").where("userId", "==", userId).limit(10).get(); +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/build/static/js/main.js b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/build/static/js/main.js new file mode 100644 index 000000000..61af68052 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/build/static/js/main.js @@ -0,0 +1,15 @@ +(() => { + const process = { + env: { + NODE_ENV: "production", + PUBLIC_URL: "", + REACT_APP_API_HOST: "apiv2.gamersafer.com", + REACT_APP_CHECKOUT_API_URL: "https://checkout-api.execute-api.us-east-1.amazonaws.com/prod/", + REACT_APP_AWS_ACCESS_KEY: "AKIAIOSFODNN7EXAMPLE", + REACT_APP_AWS_SECRET_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + REACT_APP_AUTH_SECRET_KEY: "auth-secret-that-should-stay-server-side", + }, + }; + + window.__ENV__ = process.env; +})(); diff --git a/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/package.json b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/package.json new file mode 100644 index 000000000..f0e8660a4 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/package.json @@ -0,0 +1,11 @@ +{ + "name": "@example/playertrust-admin", + "private": true, + "scripts": { + "build": "react-scripts build" + }, + "dependencies": { + "react": "^19.0.0", + "react-scripts": "^latest" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/src/admin-client.tsx b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/src/admin-client.tsx new file mode 100644 index 000000000..d4610127c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-gamersafer-public-env/src/admin-client.tsx @@ -0,0 +1,17 @@ +interface AdminPortalEnvironment { + readonly apiHost: string | undefined; + readonly awsAccessKey: string | undefined; + readonly awsSecretKey: string | undefined; + readonly authSecretKey: string | undefined; +} + +const adminPortalEnvironment: AdminPortalEnvironment = { + apiHost: process.env.REACT_APP_API_HOST, + awsAccessKey: process.env.REACT_APP_AWS_ACCESS_KEY, + awsSecretKey: process.env.REACT_APP_AWS_SECRET_KEY, + authSecretKey: process.env.REACT_APP_AUTH_SECRET_KEY, +}; + +export const AdminLogin = () => { + return
Admin portal
; +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/package.json b/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/package.json new file mode 100644 index 000000000..056beee93 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/package.json @@ -0,0 +1,6 @@ +{ + "name": "eva-minified-widget-bundle", + "dependencies": { + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/packages/widget/widget.global.js b/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/packages/widget/widget.global.js new file mode 100644 index 000000000..fe7b9f39c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-minified-widget-bundle/packages/widget/widget.global.js @@ -0,0 +1,14 @@ +!(function (global) { + var runtime = { + apiBaseUrl: "https://api.cursor.com", + DATABASE_URL: "postgres://widget_runtime:widget-password@db.internal.example.com/widget", + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }; + global.ReactDoctorWidget = { + mount: function mount(container) { + container.setAttribute("data-widget-api", runtime.apiBaseUrl); + }, + __runtime: runtime, + }; +})(window); diff --git a/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts new file mode 100644 index 000000000..62f217bf0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts @@ -0,0 +1,19 @@ +const CDN_BASE_URL = "https://cdn.mintlify.com"; + +export const GET = async ( + request: Request, + context: { params: { subdomain: string; path: string[] } }, +) => { + const host = request.headers.get("host") ?? ""; + const requestedPath = `/${decodeURIComponent(context.params.path.join("/"))}`; + const assetResponse = await fetch(`${CDN_BASE_URL}/${context.params.subdomain}${requestedPath}`, { + headers: { + "x-docs-host": host, + }, + next: { + revalidate: 3600, + }, + }); + + return assetResponse; +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx new file mode 100644 index 000000000..0d62af25b --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx @@ -0,0 +1,12 @@ +import { compileMDX } from "next-mdx-remote/rsc"; + +export const Page = async ({ params }: { params: { slug: string } }) => { + const customerMarkdown = await fetch(`https://api.mintlify.com/v1/docs/${params.slug}`).then( + (response) => response.text(), + ); + const rendered = await compileMDX({ + source: customerMarkdown, + }); + + return rendered.content; +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/next.config.js b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/next.config.js new file mode 100644 index 000000000..62803bae8 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/next.config.js @@ -0,0 +1,5 @@ +export default { + images: { + dangerouslyAllowSVG: true, + }, +}; diff --git a/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/package.json b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/package.json new file mode 100644 index 000000000..afa2878f3 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/package.json @@ -0,0 +1,8 @@ +{ + "name": "eva-mintlify-docs-platform", + "dependencies": { + "next": "^16.0.0", + "next-mdx-remote": "^5.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/public/uploads/xss.svg b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/public/uploads/xss.svg new file mode 100644 index 000000000..bf549c5f2 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-mintlify-docs-platform/public/uploads/xss.svg @@ -0,0 +1 @@ + diff --git a/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/.github/workflows/release.yml b/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/.github/workflows/release.yml new file mode 100644 index 000000000..e013f3535 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/.github/workflows/release.yml @@ -0,0 +1,15 @@ +name: release + +on: + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + env: + FIREBASE_ADMIN_KEY: ${{ secrets.FIREBASE_ADMIN_KEY }} + UPDATE_SIGNING_TOKEN: ${{ secrets.UPDATE_SIGNING_TOKEN }} + - run: pnpm release diff --git a/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/package.json b/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/package.json new file mode 100644 index 000000000..95c45b6bc --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/eva-todesktop-release-pipeline/package.json @@ -0,0 +1,12 @@ +{ + "name": "@example/electron-customer-app", + "private": true, + "scripts": { + "postinstall": "node scripts/generate-native-assets.js", + "release": "todesktop build && todesktop release" + }, + "dependencies": { + "@todesktop/cli": "^latest", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/app/signin/route.ts b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/app/signin/route.ts new file mode 100644 index 000000000..72aa0a490 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/app/signin/route.ts @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export const GET = (request: Request) => { + const url = new URL(request.url); + redirect(url.searchParams.get("continue") ?? "/"); +}; diff --git a/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/package.json b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/package.json new file mode 100644 index 000000000..9ee7c3156 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/package.json @@ -0,0 +1,7 @@ +{ + "name": "lyra-clickjacking-redirect-chain", + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/src/slide-embed.tsx b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/src/slide-embed.tsx new file mode 100644 index 000000000..e8a842045 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-scan/lyra-clickjacking-redirect-chain/src/slide-embed.tsx @@ -0,0 +1,10 @@ +export const SlideEmbed = ({ videoId }: { videoId: string }) => { + const shareTarget = new URLSearchParams(location.search).get("userstoinvite"); + + return ( + \` }} />\n);\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("flags unsanitized values even when named unsafeHtml", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/components/raw.tsx", + content: `export const Raw = ({ unsafeHtml }: Props) => (\n
\n);\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("flags HTML injected from props", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/components/preview.tsx", + content: `export const Preview = (props: { content: string }) => (\n
\n);\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("flags innerHTML assigned from fetched data", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/widgets/banner.ts", + content: `const response = await fetch(bannerUrl);\nconst payload = await response.json();\nbannerElement.innerHTML = payload.data.bannerHtml;\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("stays silent on innerHTML assigned from an escaping serializer call", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/managers/interaction-manager.ts", + content: `const temporaryContainer = document.createElement("div");\ntemporaryContainer.innerHTML = toHtml(createGutterUtilityElement());\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on KaTeX-rendered html identifiers", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/katex/katex-block.tsx", + content: `const html = useMemo(() => katex.renderToString(code, { displayMode: true }), [code]);\nreturn
;\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on style tags injecting generated CSS text", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/render/file-tree-view.tsx", + content: `return (\n \n);\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on long static template scripts without interpolation", () => { + const themeScriptLines = [ + "return (", + " ", + ");", + ]; + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "app/layout.tsx", + content: themeScriptLines.join("\n"), + }); + expect(findings).toHaveLength(0); + }); + + it("still flags script tags interpolating dynamic values", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "app/layout.tsx", + content: + "return