Add project-level security scan rules#744
Conversation
commit: |
|
No React Doctor issues found. 🎉 Reviewed by React Doctor for commit |
51e8816 to
cdfe430
Compare
cdfe430 to
b1fe41d
Compare
b1fe41d to
d1f18fc
Compare
d1f18fc to
d9adf7e
Compare
d9adf7e to
4a1840e
Compare
4a1840e to
b18753e
Compare
b18753e to
a940685
Compare
|
/rde parity |
|
❗ Parity changed: +25 added · -10 removed across 12 repos Baseline: tldraw/tldraw (packages/sync) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
twentyhq/twenty (packages/twenty-companion) — +6 / -1✨ Added in this PR (not present in baseline)
241 | <div class="meeting-icon document">
242 | <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
243 | <path d="M14 2H6C4.9 2 4.01 2.9 4.01 4L4 20C4 21.1 4.89 22 5.99 22H18C19.1 22 20 21.1 20 20V8L14 2ZM16 18H8V16H16V18ZM16 14H8V12H16V14ZM13 9V3.5L18.5 9H13Z" fill="#4CAF50"/>
244 | </svg>
245 | </div>
246 | `;
247 | }
248 |
249 | let subtitleHtml = meeting.hasDemo
250 | ? `<div class="meeting-time"><a class="meeting-demo-link">${meeting.subtitle}</a></div>`
251 | : `<div class="meeting-time">${meeting.subtitle}</div>`;
252 |
> 253 | card.innerHTML = `
254 | ${iconHtml}
255 | <div class="meeting-content">
256 | <div class="meeting-title">${meeting.title}</div>
257 | ${subtitleHtml}
258 | </div>
259 | <div class="meeting-actions">
260 | <button class="delete-meeting-btn" data-id="${meeting.id}" title="Delete note">
261 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
262 | <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill="currentColor"/>
263 | </svg>
264 | </button>
265 | </div>
752 | entryDiv.className = 'transcript-entry';
753 |
754 | const speaker = entry.participant?.name || entry.speaker || 'Unknown';
755 | const text = entry.words
756 | ? entry.words.map(word => word.text).join(' ')
757 | : entry.text || '';
758 | const firstWord = entry.words?.[0];
759 | const absTimestamp = firstWord?.start_timestamp?.absolute;
760 | const formattedTime = absTimestamp
761 | ? new Date(absTimestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
762 | : '';
763 |
> 764 | entryDiv.innerHTML = `
765 | <div class="transcript-speaker">${speaker}</div>
766 | <div class="transcript-text">${text}</div>
767 | <div class="transcript-timestamp">${formattedTime}</div>
768 | `;
769 |
770 | // Add a highlight class for the newest entry
771 | if (index === transcript.length - 1) {
772 | entryDiv.classList.add('newest-entry');
773 | }
774 |
775 | transcriptDiv.appendChild(entryDiv);
776 | });
1198 |
1199 | case 'error':
1200 | content = `<span class="error-type">Error: ${entry.errorType}</span>`;
1201 | if (entry.message) {
1202 | content += `<div class="params">${entry.message}</div>`;
1203 | }
1204 | break;
1205 |
1206 | default:
1207 | content = entry.message;
1208 | }
1209 |
> 1210 | logElement.innerHTML += content;
1211 |
1212 | // Add to the top of the log
1213 | loggerContent.insertBefore(logElement, loggerContent.firstChild);
1214 |
1215 | // Only auto-scroll to top if user is already at the top
1216 | const isAtTop = loggerContent.scrollTop <= 5;
1217 | if (isAtTop) {
1218 | loggerContent.scrollTop = 0;
1219 | }
1220 | }
1221 | },
1222 |
1431 |
1432 | updateDebugTranscript(meeting.transcript);
1433 |
1434 | const debugPanel = document.getElementById('debugPanel');
1435 | if (debugPanel && debugPanel.classList.contains('hidden')) {
1436 | const debugPanelToggle = document.getElementById('debugPanelToggle');
1437 | if (debugPanelToggle) {
1438 | debugPanelToggle.classList.add('has-new-content');
1439 |
1440 | if (window.isRecording) {
1441 | const miniNotification = document.createElement('div');
1442 | miniNotification.className = 'debug-notification transcript-notification';
> 1443 | miniNotification.innerHTML = `
1444 | <span class="debug-notification-speaker">${latestSpeaker}</span>:
1445 | <span class="debug-notification-text">${latestText.slice(0, 40)}${latestText.length > 40 ? '...' : ''}</span>
1446 | `;
1447 |
1448 | // Add to document
1449 | document.body.appendChild(miniNotification);
1450 |
1451 | // Remove after a short time
1452 | setTimeout(() => {
1453 | miniNotification.classList.add('fade-out');
1454 | setTimeout(() => {
1455 | document.body.removeChild(miniNotification);
2024 | document.body.removeChild(toast);
2025 | }, 300);
2026 | }, 3000);
2027 | } else {
2028 | console.error('Failed to generate summary:', result.error);
2029 | alert('Failed to generate summary: ' + result.error);
2030 | }
2031 | } catch (error) {
2032 | console.error('Error generating summary:', error);
2033 | alert('Error generating summary: ' + (error.message || error));
2034 | } finally {
2035 | // Reset button state with the original HTML (including sparkle icon)
> 2036 | generateButton.innerHTML = originalHTML;
2037 | generateButton.disabled = false;
2038 | }
2039 | });
2040 | }
2041 |
2042 |
2043 |
2044 | // Listen for recording completed events
2045 | window.electronAPI.onRecordingCompleted((meetingId) => {
2046 | console.log('Recording completed for meeting:', meetingId);
2047 | if (currentEditingMeetingId === meetingId) {
2048 | // Reload the meeting data first
✅ Removed (fixed) in this PR (were in baseline)
tldraw/tldraw (packages/state-react) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
tldraw/tldraw (packages/mermaid) — +2 / -1✨ Added in this PR (not present in baseline)
80 | document.body.appendChild(offscreen)
81 |
82 | try {
83 | const parsedSvg = (await mermaid.render(`mermaid-${nextMermaidId++}`, text, offscreen)).svg
84 |
85 | // Reuse the live SVG that mermaid.render() already mounted into the
86 | // offscreen container. This avoids a second DOM mount and ensures
87 | // getBBox() works for every diagram type (state diagrams in particular
88 | // lack explicit dimension attributes and rely on live layout).
89 | let liveSvg = offscreen.querySelector('svg')
90 |
91 | if (!liveSvg) {
> 92 | offscreen.innerHTML = parsedSvg
93 | liveSvg = offscreen.querySelector('svg')
94 | if (!liveSvg) {
95 | throw new MermaidDiagramError(parsedResult.diagramType, 'parse')
96 | }
97 | }
98 |
99 | // eslint-disable-next-line @typescript-eslint/no-deprecated
100 | const diagramResult = await mermaid.mermaidAPI.getDiagramFromText(text)
101 |
102 | let blueprint
103 | switch (parsedResult.diagramType) {
104 | case 'flowchart-v2': {
✅ Removed (fixed) in this PR (were in baseline)
tldraw/tldraw (packages/sync-core) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
tldraw/tldraw (packages/store) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
aidenybai/bippy (packages/bippy) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
millionco/expect (packages/browser) — +2 / -0✨ Added in this PR (not present in baseline)
14 | "",
15 | "Available rule resources:",
16 | "",
17 | ];
18 | for (const rule of rules) {
19 | const subRuleHint =
20 | rule.subRules.length > 0
21 | ? ` — fetch \`expect://rules/${rule.slug}/{sub-rule}\` for ${rule.subRules.length} detailed sub-rules`
22 | : "";
23 | promptLines.push(`- \`expect://rules/${rule.slug}\` — ${rule.description}${subRuleHint}`);
24 | }
25 |
> 26 | server.registerPrompt(
27 | "rules",
28 | { description: "Available rule resources for fixing domain-specific issues" },
29 | () => ({
30 | messages: [
31 | {
32 | role: "user" as const,
33 | content: { type: "text" as const, text: promptLines.join("\n") },
34 | },
35 | ],
36 | }),
37 | );
38 |
182 | "- Assertion-first: navigate, act, then validate before moving on. Check at least two independent signals per step (e.g. URL changed AND new content appeared).",
183 | "</best_practices>",
184 | ].join("\n");
185 |
186 | // HACK: tool annotations (readOnlyHint, destructiveHint) are required for parallel execution in the Claude Agent SDK
187 | export const createBrowserMcpServer = <E>(
188 | runtime: ManagedRuntime.ManagedRuntime<McpSession | OverlayController | FileSystem, E>,
189 | ) => {
190 | const runMcp = <A>(
191 | effect: Effect.Effect<A, unknown, McpSession | OverlayController | FileSystem>,
192 | ) => runtime.runPromise(effect);
193 |
> 194 | const server = new McpServer({
195 | name: "expect",
196 | version: "0.0.1",
197 | });
198 |
199 | const openTool = server.registerTool(
200 | "open",
201 | {
202 | title: "Open URL",
203 | description:
204 | "Navigate to a URL, launching a browser if needed. Set 'cdp' to a WebSocket URL (e.g. 'ws://localhost:9222/devtools/browser/...') to connect to an already-running Chrome via CDP instead of launching a new browser.",
205 | inputSchema: {
206 | url: z.string().describe("URL to navigate to"),tldraw/tldraw (packages/tlschema) — +2 / -2✨ Added in this PR (not present in baseline)
1 | import { Signal, computed } from '@tldraw/state'
2 | import {
3 | SerializedStore,
4 | Store,
5 | StoreSchema,
6 | StoreSnapshot,
7 | StoreValidationFailure,
8 | } from '@tldraw/store'
9 | import { IndexKey, JsonObject, annotateError, sortByIndex, structuredClone } from '@tldraw/utils'
10 | import { TLAsset, TLAssetId } from './records/TLAsset'
11 | import { CameraRecordType, TLCameraId } from './records/TLCamera'
12 | import { DocumentRecordType, TLDOCUMENT_ID } from './records/TLDocument'✅ Removed (fixed) in this PR (were in baseline)
1 | import { Signal, computed } from '@tldraw/state'
2 | import {
3 | SerializedStore,
4 | Store,
5 | StoreSchema,
6 | StoreSnapshot,
7 | StoreValidationFailure,
8 | } from '@tldraw/store'
9 | import { IndexKey, JsonObject, annotateError, sortByIndex, structuredClone } from '@tldraw/utils'
10 | import { TLAsset, TLAssetId } from './records/TLAsset'
11 | import { CameraRecordType, TLCameraId } from './records/TLCamera'
12 | import { DocumentRecordType, TLDOCUMENT_ID } from './records/TLDocument'pierrecomputer/pierre (packages/trees) — +2 / -0✨ Added in this PR (not present in baseline)
60 | this.setSlotContent(slotName, null);
61 | return;
62 | }
63 |
64 | const currentContent = this.#getCurrentContent(slotName);
65 | if (currentContent != null && currentContent.innerHTML === normalizedHtml) {
66 | this.#contentBySlot.set(slotName, currentContent);
67 | this.#attachContent(slotName, currentContent);
68 | return;
69 | }
70 |
71 | const nextContent = document.createElement('div');
> 72 | nextContent.innerHTML = normalizedHtml;
73 | this.setSlotContent(slotName, nextContent);
74 | }
75 |
76 | #getCurrentContent(slotName: string): HTMLElement | null {
77 | const trackedContent = this.#contentBySlot.get(slotName) ?? null;
78 | if (trackedContent != null) {
79 | return trackedContent;
80 | }
81 |
82 | const host = this.#host;
83 | if (host == null) {
84 | return null;
69 | }
70 |
71 | function renderPreloadedShadowDom(
72 | children: ReactNode,
73 | preloadedData: FileTreePreloadedData | undefined
74 | ): ReactNode {
75 | if (typeof window === 'undefined' && preloadedData != null) {
76 | return (
77 | <>
78 | <template
79 | // @ts-expect-error React does not know the declarative shadow DOM attribute.
80 | shadowrootmode="open"
> 81 | dangerouslySetInnerHTML={{ __html: preloadedData.shadowHtml }}
82 | />
83 | {children}
84 | </>
85 | );
86 | }
87 |
88 | return <>{children}</>;
89 | }
90 |
91 | function hasExistingPreloadedContent(host: HTMLElement): boolean {
92 | const shadowRoot = host.shadowRoot;
93 | if (aidenybai/react-scan (packages/scan) — +1 / -1✨ Added in this PR (not present in baseline)
✅ Removed (fixed) in this PR (were in baseline)
pierrecomputer/pierre (packages/diffs) — +5 / -0✨ Added in this PR (not present in baseline)
1 | export function prerenderHTMLIfNecessary(
2 | element: HTMLElement,
3 | html: string | undefined
4 | ): void {
5 | if (html == null) return;
6 | const shadowRoot =
7 | element.shadowRoot ?? element.attachShadow({ mode: 'open' });
8 | if (shadowRoot.innerHTML === '') {
> 9 | shadowRoot.innerHTML = html;
10 | }
11 | }
12 |
766 | return;
767 | }
768 | this.themeCSSStyle.textContent = patchScrollbarGutterSize(
769 | this.themeCSSStyle.textContent ?? '',
770 | getMeasuredScrollbarGutter(shadowRoot)
771 | );
772 | }
773 |
774 | private applyFullRender(result: FileRenderResult, pre: HTMLPreElement): void {
775 | this.cleanupErrorWrapper();
776 | this.applyPreNodeAttributes(pre, result);
777 | this.code = getOrCreateCodeNode({ code: this.code });
> 778 | this.code.innerHTML = this.fileRenderer.renderPartialHTML(
779 | this.fileRenderer.renderCodeAST(result)
780 | );
781 | pre.replaceChildren(this.code);
782 | this.lastRowCount = result.rowCount;
783 | }
784 |
785 | private applyPartialRender(
786 | previousRenderRange: RenderRange | undefined,
787 | renderRange: RenderRange | undefined
788 | ): boolean {
789 | if (previousRenderRange == null || renderRange == null) {
790 | return false;
1383 | // Clean up addition/deletion elements if necessary
1384 | this.codeDeletions?.remove();
1385 | this.codeDeletions = undefined;
1386 | this.codeAdditions?.remove();
1387 | this.codeAdditions = undefined;
1388 |
1389 | this.codeUnified = getOrCreateCodeNode({
1390 | code: this.codeUnified,
1391 | columnType: 'unified',
1392 | rowSpan,
1393 | containerSize,
1394 | });
> 1395 | this.codeUnified.innerHTML =
1396 | this.hunksRenderer.renderPartialHTML(unifiedAST);
1397 | codeElements.push(this.codeUnified);
1398 | } else if (deletionsAST != null || additionsAST != null) {
1399 | if (deletionsAST != null) {
1400 | shouldReplace = this.codeDeletions == null || this.codeUnified != null;
1401 |
1402 | // Clean up unified column if necessary
1403 | this.codeUnified?.remove();
1404 | this.codeUnified = undefined;
1405 |
1406 | this.codeDeletions = getOrCreateCodeNode({
1407 | code: this.codeDeletions,
1400 | shouldReplace = this.codeDeletions == null || this.codeUnified != null;
1401 |
1402 | // Clean up unified column if necessary
1403 | this.codeUnified?.remove();
1404 | this.codeUnified = undefined;
1405 |
1406 | this.codeDeletions = getOrCreateCodeNode({
1407 | code: this.codeDeletions,
1408 | columnType: 'deletions',
1409 | rowSpan,
1410 | containerSize,
1411 | });
> 1412 | this.codeDeletions.innerHTML =
1413 | this.hunksRenderer.renderPartialHTML(deletionsAST);
1414 | codeElements.push(this.codeDeletions);
1415 | } else {
1416 | // If we have no deletion column, lets clean it up if it exists
1417 | this.codeDeletions?.remove();
1418 | this.codeDeletions = undefined;
1419 | }
1420 |
1421 | if (additionsAST != null) {
1422 | shouldReplace =
1423 | shouldReplace ||
1424 | this.codeAdditions == null ||
1425 | this.codeUnified != null;
1426 |
1427 | // Clean up unified column if necessary
1428 | this.codeUnified?.remove();
1429 | this.codeUnified = undefined;
1430 |
1431 | this.codeAdditions = getOrCreateCodeNode({
1432 | code: this.codeAdditions,
1433 | columnType: 'additions',
1434 | rowSpan,
1435 | containerSize,
1436 | });
> 1437 | this.codeAdditions.innerHTML =
1438 | this.hunksRenderer.renderPartialHTML(additionsAST);
1439 | codeElements.push(this.codeAdditions);
1440 | } else {
1441 | // If we have no addition column, lets clean it up if it exists
1442 | this.codeAdditions?.remove();
1443 | this.codeAdditions = undefined;
1444 | }
1445 | } else {
1446 | // if we get in here, there's no content to render, so lets just clean
1447 | // everything up
1448 | this.codeUnified?.remove();
1449 | this.codeUnified = undefined;
trace |
…rule posture modules One rule file = one rule, like every other bucket: 36 definePostureRule modules under rules/security-posture/ replace the 1433-line scanner monolith and the 364-line no-op stub file. The registry generator's multi-export hack is reverted to main's single-match contract with definePostureRule added to the alternation. checkSecurityPosture is now a thin walk-and-dispatch over registry entries carrying `scan`.
Project-level posture rules now execute through core's environment check machinery — same walker, same dispatch, same diagnostics — selected via the shared shouldEnableRule capability/tag gate instead of a hand-rolled filter. The plugin dispatcher remains only as a parity reference until the next phase removes it.
The scan now joins reduced-motion, pnpm hardening, and expo/RN checks in run-inspect's environment phase: skipped in diff mode, streamed through the per-element pipeline (severity controls, inline disables, surfaces), and gated per rule by shouldEnableRule. services/linter.ts returns to main unchanged; posture rules are excluded from generated oxlint configs and ESLint presets instead of shipping as no-ops.
… surface The scanner file, the /ast subpath export, and the plugin-side copies of core fs utils are gone; the posture rules and their bucket utils are the single home for scan logic, and core owns the walk.
Adds ignoredTags + registry-metadata single-sourcing tests and a package-metadata-secret fixture to the core suite, a runPostureRule harness with colocated regressions tests for the AST and dynamic-severity rules, registry invariants (exactly 36 tagged posture rules, scan field nowhere else), and posture-rule authoring docs. The changeset now describes the environment-check architecture and its intentional behavior changes.
Mutation-verified gaps from review: the ESLint-preset posture filter in rules.ts could be reverted without any test failing (same bug class as the defaultEnabled preset leak), and the runInspect dispatch line could be deleted silently. Adds a registry-derived preset regression and three runInspect integration tests covering full-scan emission, the diff-mode gate, and user severity overrides restamping posture diagnostics.
CodeQL js/polynomial-redos: the unanchored (\d+)\.(\d+) scan over the
package.json version string backtracks quadratically on long digit
runs. Bounded {1,4} quantifiers keep it linear, matching the fix
parse-react-major-minor.ts already carries.
Replaces the hand-rolled digit regex (the CodeQL polynomial-redos surface) with semver, which core already depends on: minVersion gives the range's lower bound (>=3.4 <5 → 3.4) and coerce covers prefixed specs like npm:tailwindcss@^3.4.1.
'Posture' meant nothing to anyone. Project-level rules are now plain 'scan rules': defineScanRule modules in rules/security-scan/, tagged security-scan, typed ScanFinding/FileScan/ScannedFile, executed by core's checkSecurityScan environment check. Same light shape as before — a scan field on Rule and a distinctly-named wrapper — no parallel type system.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c3f0d7c. Configure here.
One definition for both rule kinds: an AST rule provides create, a scan rule provides scan, and defineRule injects the inert visitor factory hosts require. defineScanRule is gone and the registry generator's matcher is back to main's defineRule|defineRetiredRule form. The catch-all generic overload is constrained to extends Rule so context-sensitive scan arrows resolve to the scan overload instead of freezing widened literal types.
The overload set existed only to serve 336 vestigial defineRule<Rule> call sites. With the explicit type arguments (and their orphaned Rule imports) removed, defineRule is one plain function over a two-arm RuleDefinition union — create for AST rules, scan for scan rules — with no generic catch-all and no overload-resolution subtleties.
…tall Installing only the react-doctor tarball resolves oxlint-plugin-react-doctor from the registry, so any PR adding cross-package API fails the smoke before the pair is published. Changesets bumps and ships both packages pinned together — packing and installing both tarballs is what a release actually delivers.
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
…o scanByPattern - scanByPattern gains pattern lists (first match locates the finding), requireAll conjunction gates, and a suppressWhen veto; 12 rules that re-implemented those shapes inline now declare them instead - postmessage-origin-risk inlines its single-use AST helpers and walks with the shared plugin walkAst, deleting 9 one-consumer util files (including the duplicate scan-local walker and its TODO) - repository-secret-file, insecure-crypto-risk, and active-static-asset stop evaluating the same patterns twice; public-debug-artifact anchors its whole-file finding explicitly so getMatchLocation can require a RegExp Co-authored-by: Cursor <cursoragent@cursor.com>
…erver routes only The extra SERVER_CONTEXT_PATTERN disjunct re-admitted test, generated, and docs paths under api/-style directories that every sibling rule excludes; isServerRouteSourcePath already includes the server-context check behind the production-source filter. Co-authored-by: Cursor <cursoragent@cursor.com>
…nto review/pr-744 Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/git-provider-url-injection-risk.ts # packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/insecure-crypto-risk.ts # packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/postmessage-origin-risk.ts # packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/scan-by-pattern.ts
…tree The walk now collects only candidate paths up front and reads contents lazily, one file per iteration, so the check never holds more than one file in memory (previously up to 3x2500 files at the 2-8 MiB caps). Bucket priority and the per-bucket cap on successful reads are unchanged. Addresses the synchronous-resource concern from review. Co-authored-by: Cursor <cursoragent@cursor.com>
|
/rde parity |

Summary
Adds project-level security scan rules: 36 first-class rules that detect what per-file linting never sees — browser-artifact secret leaks, Firebase/Supabase authorization mistakes, CI/install trust-boundary issues, clickjacking/SVG risks, agent/MCP tool capability risks, injection patterns, and committed secret files.
Architecture
Scan rules follow the same lifecycle as every other rule in the repo:
oxlint-plugin-react-doctor/src/plugin/rules/security-scan/<rule-id>.tsasdefineRule({ id, title, severity, recommendation, scan })— same definition as AST rules, withscanin place ofcreate. Thescan(file) => findingsfield replaces AST visitors for this kind; per-findingseverity/title/helpoverrides cover the two dynamic rules (public-debug-artifact,active-static-asset).Security, auto-tagsecurity-scan), so tags, severities, titles, and help text are single-sourced. The registry generator keeps main's one-rule-per-file contract.@react-doctor/core'scheckSecurityScan(shaped likecheckPnpmHardening) runs inrunInspect's environment-checks phase: one bounded whole-tree walk, per-rule gating through the realshouldEnableRule(capabilities,ignoredTags,disabledBy), and diagnostics streamed through the per-element pipeline so severity controls, inline disables, and surfaces apply like any other diagnostic.services/linter.tsis untouched vs main.Behavior notes
includePaths === undefinedproxy) — projects configuringignore.filesget the security scan.rules/categoriesseverity overrides, inline disables, and surface filtering apply to scan-rule diagnostics.ignore.tags: ["security-scan"](orrules ignore-tag security-scan) silences the whole family.Verification
packages/core/tests/check-security-scan.test.ts) over 31 fixture projects; a differential harness compared the original monolith implementation vs the final engine over all fixtures: 0 diagnostic differences.runInspectintegration tests (full-scan emission, diff-mode gate, severity restamp), ESLint-preset + oxlint-config exclusion regressions, registry invariants (exactly 36 tagged scan rules;scannowhere else),runScanRuleunit harness with regressions for the AST and dynamic-severity rules.pnpm typecheck,pnpm build, core (713 tests), react-doctor (1737 tests), and plugin suites green (the 6 failing plugin files are pre-existing onmain).Test plan
pnpm --filter @react-doctor/core testpnpm --filter oxlint-plugin-react-doctor testpnpm --filter ./packages/react-doctor testpnpm typecheck && pnpm build && pnpm format:checkNote
High Risk
Large new security-detection surface over whole-repo file reads; false positives/negatives and performance caps matter, though diff-mode skip, tag ignore, and extensive fixture tests mitigate rollout risk.
Overview
Introduces 36 project-level security scan rules in the
security-scanbucket: each rule is a normaldefineRulemodule with ascan(file)hook instead of AST visitors, auto-tagged and categorized as Security, but never registered in generated oxlint or ESLint configs (dead visitors avoided).@react-doctor/core adds
checkSecurityScan, which enables rules through the sameshouldEnableRule/ignoredTags/disabledBypath as lint, walks the repo with bounded depth/file/size limits and priority bucketing (config/SQL vs shipped artifacts), and mapsScanFindings into standard Security diagnostics (severity overrides, inline disables, surfaces). The check runs inrunInspect's environment-check phase on full scans and is skipped whenisDiffMode(includePaths.length > 0), so diff/staged runs behave like other whole-tree checks while projects withignore.filesstill get the scan on full runs.Supporting changes: shared scan path/classification helpers and secret patterns in the plugin, registry/codegen/docs updates (
HOW_TO_WRITE_A_RULEscan section, README note),parseTailwindMajorMinornow uses semver lower bounds instead of regex, and broaddefineRule<Rule>→defineRulecleanup across existing a11y rules. Coverage is a large fixture suite plus integration tests for oxlint exclusion,runInspect, and tag-based silencing (security-scan).Reviewed by Cursor Bugbot for commit 3a260f5. Bugbot is set up for automated code reviews on this repo. Configure here.