Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a940685
feat(core): add security posture scanner
aidenybai Jun 8, 2026
61448f1
fix(core): address security posture PR blockers
aidenybai Jun 8, 2026
dc17607
fix(core): preserve security posture scanner coverage
aidenybai Jun 9, 2026
a479854
fix(core): locate package metadata secrets
aidenybai Jun 9, 2026
b1622a8
fix(core): tighten security diagnostic locations
aidenybai Jun 9, 2026
96002f9
fix
aidenybai Jun 9, 2026
d7c7e9b
fix
aidenybai Jun 9, 2026
344493e
merge origin/main into feat/security-posture-scanner
aidenybai Jun 9, 2026
dbad750
refactor(oxlint-plugin): decompose security posture scanner into per-…
aidenybai Jun 9, 2026
74e4928
feat(core): add security posture environment check
aidenybai Jun 9, 2026
2ee3488
refactor(core): run security posture scan as an environment check
aidenybai Jun 9, 2026
0981a2b
refactor(oxlint-plugin): delete security posture monolith and bolt-on…
aidenybai Jun 9, 2026
46d0bef
test(security-posture): harden posture rule coverage and docs
aidenybai Jun 9, 2026
8e32777
test: lock down posture-rule exclusion and env-phase integration
aidenybai Jun 9, 2026
6573749
fix(core): bound tailwind version regex quantifiers
aidenybai Jun 10, 2026
3cc743b
refactor(core): parse tailwind version with semver
aidenybai Jun 10, 2026
c3f0d7c
rename: security posture → security scan rules
aidenybai Jun 10, 2026
a056719
refactor(oxlint-plugin): fold scan rules into defineRule
aidenybai Jun 10, 2026
34b26ca
refactor(oxlint-plugin): collapse defineRule to a single signature
aidenybai Jun 10, 2026
0a23790
Merge remote-tracking branch 'origin/main' into feat/security-posture…
aidenybai Jun 10, 2026
aaab3f2
fix(scripts): pack the workspace plugin into the packed-CLI smoke ins…
aidenybai Jun 10, 2026
bd41962
fix: 1-based whole-file scan locations + FileScan as interface
devin-ai-integration[bot] Jun 11, 2026
e394771
fix
aidenybai Jun 11, 2026
86f3847
refactor(oxlint-plugin): collapse hand-rolled security-scan rules int…
rayhanadev Jun 11, 2026
6f4309c
fix(oxlint-plugin): gate untrusted-redirect-following on production s…
rayhanadev Jun 11, 2026
d567dd4
Merge remote-tracking branch 'origin/feat/security-posture-scanner' i…
rayhanadev Jun 11, 2026
8dea644
perf(core): stream security-scan file reads instead of buffering the …
rayhanadev Jun 11, 2026
3a260f5
fix
aidenybai Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 9 additions & 0 deletions .changeset/cool-fans-fly.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions docs/HOW_TO_WRITE_A_RULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/check-security-scan.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

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<string>();
const ignoredTags = options.ignoredTags ?? new Set<string>();

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<string>();

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;
Comment thread
rayhanadev marked this conversation as resolved.
};
Original file line number Diff line number Diff line change
@@ -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<Rule, "severity" | "title" | "recommendation">;
}

// 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",
});
114 changes: 114 additions & 0 deletions packages/core/src/checks/security-scan/collect-security-scan-files.ts
Original file line number Diff line number Diff line change
@@ -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<ScannedFile, void, void> {
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;
}
}
}
13 changes: 13 additions & 0 deletions packages/core/src/checks/security-scan/constants.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
27 changes: 13 additions & 14 deletions packages/core/src/project-info/parse-tailwind-major-minor.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,27 +12,24 @@ 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 => {
if (typeof tailwindVersion !== "string") return null;
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 = (
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/run-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -348,6 +350,7 @@ export const runInspect = <HooksR = never>(
...checkReactServerComponentsAdvisory(scanDirectory, project),
...checkExpoProject(scanDirectory, project),
...checkReactNativeProject(scanDirectory, project),
...checkSecurityScan(scanDirectory, { project, ignoredTags: input.ignoredTags }),
];
const envCollected = yield* Stream.runCollect(
applyPerElementPipeline(Stream.fromIterable(environmentDiagnostics)),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runners/oxlint/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading