-
Notifications
You must be signed in to change notification settings - Fork 398
Add project-level security scan rules #744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aidenybai
wants to merge
28
commits into
main
Choose a base branch
from
feat/security-posture-scanner
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 61448f1
fix(core): address security posture PR blockers
aidenybai dc17607
fix(core): preserve security posture scanner coverage
aidenybai a479854
fix(core): locate package metadata secrets
aidenybai b1622a8
fix(core): tighten security diagnostic locations
aidenybai 96002f9
fix
aidenybai d7c7e9b
fix
aidenybai 344493e
merge origin/main into feat/security-posture-scanner
aidenybai dbad750
refactor(oxlint-plugin): decompose security posture scanner into per-…
aidenybai 74e4928
feat(core): add security posture environment check
aidenybai 2ee3488
refactor(core): run security posture scan as an environment check
aidenybai 0981a2b
refactor(oxlint-plugin): delete security posture monolith and bolt-on…
aidenybai 46d0bef
test(security-posture): harden posture rule coverage and docs
aidenybai 8e32777
test: lock down posture-rule exclusion and env-phase integration
aidenybai 6573749
fix(core): bound tailwind version regex quantifiers
aidenybai 3cc743b
refactor(core): parse tailwind version with semver
aidenybai c3f0d7c
rename: security posture → security scan rules
aidenybai a056719
refactor(oxlint-plugin): fold scan rules into defineRule
aidenybai 34b26ca
refactor(oxlint-plugin): collapse defineRule to a single signature
aidenybai 0a23790
Merge remote-tracking branch 'origin/main' into feat/security-posture…
aidenybai aaab3f2
fix(scripts): pack the workspace plugin into the packed-CLI smoke ins…
aidenybai bd41962
fix: 1-based whole-file scan locations + FileScan as interface
devin-ai-integration[bot] e394771
fix
aidenybai 86f3847
refactor(oxlint-plugin): collapse hand-rolled security-scan rules int…
rayhanadev 6f4309c
fix(oxlint-plugin): gate untrusted-redirect-following on production s…
rayhanadev d567dd4
Merge remote-tracking branch 'origin/feat/security-posture-scanner' i…
rayhanadev 8dea644
perf(core): stream security-scan file reads instead of buffering the …
rayhanadev 3a260f5
fix
aidenybai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }; | ||
29 changes: 29 additions & 0 deletions
29
packages/core/src/checks/security-scan/build-security-scan-diagnostic.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
114
packages/core/src/checks/security-scan/collect-security-scan-files.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ]); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.