5 Commits

Author SHA1 Message Date
Aiden Bai 0a231000bb fix(security): four pre-existing findings from a deep audit (rebased onto Effect v4) (#435)
* fix(scripts): escape leaderboard entries before writing them into README

`update-leaderboard.ts` interpolated `entry.name` and
`entry.githubUrl` directly into a markdown table that the workflow
later commits to the canonical README. A compromise of the upstream
benchmarks repo could land arbitrary markdown (or HTML that GitHub's
renderer accepts, like `<img>`) into the README and from there
onto npm via the published `react-doctor` package's README field.

Escape repo names against the markdown table grammar (backslash, pipe,
brackets, backtick, angle brackets, newlines), reject entries whose
`githubUrl` doesn't parse as an `https://github.com/...` URL, and
`encodeURI` the URL before substituting so a stray space or paren
can't terminate the markdown link target.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(website): validate leaderboard github URLs to prevent XSS via href

The hosted leaderboard rendered `<a href={entry.githubUrl}>` with
no protocol check. `entry.githubUrl` is read from
`react-doctor-benchmarks` (a separate repo), so a compromise of
that repo could land a `javascript:` href on react.doctor. React
18+ no longer blocks `javascript:` URLs in href, so this was a
live XSS path even without a same-origin cookie story.

Parse each URL through the WHATWG URL constructor and only keep
entries whose protocol is `https:` and whose host is exactly
`github.com`. Filter happens at fetch time so every downstream
render uses the sanitized list.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(security): zip-slip + git-flag-injection defenses for the Effect-based services

Re-applies the two source-side security fixes from #428 onto the
current Effect v4 services (the original commits were authored
against the pre-refactor sync helpers in get-staged-files.ts /
get-diff-files.ts that no longer exist).

## 1. Zip-Slip: staged path traversal

`StagedFiles.materialize` was resolving each staged path with
`path.join(tempDirectory, relativePath)`, which happily expands
`..` segments outside the temp dir. A deliberately crafted index
entry (via `git update-index --add`, a malicious pack, or a
symlinked working tree) could write attacker-controlled files
anywhere the running user can write.

Added `isPathInsideDirectory(child, parent)` helper, resolved
`tempDirectory` up front, and the per-file loop now resolves the
candidate against the resolved temp dir and `continue`s when the
result falls outside. Same shape as the standard Zip-Slip defense.

## 2. CVE-2018-17456: git-flag injection via --diff <base>

`Git.diffSelection` was forwarding `explicitBaseBranch` to
`git rev-parse --verify <branch>` and `git merge-base <branch> HEAD`
unchanged. A value starting with `-` (e.g. `--upload-pack=evil`)
gets parsed by git as an option instead of a refname — the
CVE-2018-17456 shape.

Added `isSafeGitRevision(candidate)` at the top of the Git service
and a check at the start of `diffSelection`. The validation runs
BEFORE any subprocess spawn, so a malicious ref never reaches
`ChildProcess`. Rejects: empty, leading `-`, leading/trailing `.`,
embedded `..`, `@{` reflog suffix, or any character outside
`[A-Za-z0-9_./-]`.

In the canonical CI path the GitHub composite action already
guards with `case "$DIFF_BASE" in -* )` before the CLI is invoked,
so today's exposure is limited to local CLI runs where the user
is attacking themselves. Hardening the library boundary anyway
so other callers don't have to re-implement the check.

## Regression tests

- `Git.diffSelection — git-flag injection` (6 cases): leading dash,
  `..` rev-range, `@{` reflog, shell metacharacters, leading dot,
  empty. Uses `Git.layerNode` because validation runs before any
  spawn, so the suite never reaches `git`.
- `StagedFiles.layerNode — Zip-Slip defense` (1 case): a staged
  index containing `../escaped.ts` writes only the inside-the-temp
  file and never touches the parent dir.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-22 19:11:34 -07:00
Aiden Bai 83a70de91e feat(core): Effect v4 foundation — tagged errors, schemas, refs, paths (#405)
First PR of the Effect v4 rewrite. Adds the architectural primitives
that subsequent PRs build on, while preserving every public contract
(inspect(), diagnose(), CLI flags, JSON schemaVersion: 1, GitHub Action).

## What lands

- packages/core/src/errors.ts — 9 leaf Schema.TaggedErrorClass
  (OxlintUnavailable, OxlintBatchExceeded, OxlintSpawnFailed,
  OxlintOutputUnparseable, ConfigParseFailed, ProjectNotFound,
  NoReactDependency, AmbiguousProject, DeadCodeAnalysisFailed) composed
  into ReactDoctorError. Helpers formatReactDoctorError /
  isReactDoctorError / isSplittableReactDoctorError (all _tag-based,
  zero string-grepping).
- packages/core/src/schemas.ts — Diagnostic + Severity + JsonReport
  (Schema.Union for forward-compat with a future v2) +
  buildDiagnosticIdentity. Schemas mirror the existing @react-doctor/types
  interfaces; ProjectInfo / ScoreResult stay Schema.Unknown until PR 3.
- packages/core/src/refs.ts — Context.Reference for env-derived ambient
  config (OxlintSpawnTimeoutMs, OxlintOutputMaxBytes,
  StagedFilesTempDirPrefix). Tests override via Layer.succeed.
- packages/core/src/paths.ts — Schema.brand for OxlintBinaryPath +
  NodeBinaryPath. Catches the swap at compile time.
- packages/core/src/constants.ts — hoisted OXLINT_SPAWN_TIMEOUT_MS from
  the inline IIFE in run-oxlint.ts with explanatory docstring.

## Wiring

- handle-error.ts and build-json-report-error.ts dispatch to the
  tagged-error message getter when isReactDoctorError(error), else
  fall back to the existing formatErrorChain. run-oxlint.ts still
  throws plain Errors; PR 2 converts it.
- scripts/smoke-json-report.ts runs the built CLI against
  tests/fixtures/basic-react and Schema.decodeUnknownSync's the stdout
  against the new JsonReport schema. New CI step (must stay green
  through PR 8). Verified locally that a full --no-offline run with
  263 real diagnostics decodes cleanly.

## Effect v4 deps

- effect@4.0.0-beta.70 in packages/core/dependencies and
  packages/react-doctor/dependencies. Marked neverBundle in
  packages/react-doctor/vite.config.ts (~1MB+ of tree-shakable source;
  installers pull it as a regular dep — matches react-doctor-evals).
- @effect/vitest@4.0.0-beta.70 as a devDependency of core for PR 3+.

## Patterns

Every new file matches react-doctor-evals conventions exactly:
- import * as X from "effect/X" (never the umbrella import)
- Schema.TaggedErrorClass<Self>()("Tag", { fields }) with get message()
  delegating to Cause.pretty(Cause.fail(this.cause)) for opaque causes
- Context.Reference<T>("react-doctor/X", { defaultValue }) with env-var
  reads in defaultValue
- Schema.brand("X") via .pipe()
- kebab-case file names (per AGENTS.md)

## Validation

- pnpm typecheck — 10/10 tasks green
- pnpm test — 113 files, 1442 pass / 3 skipped (up from 1198; +244 new
  tests in errors.test.ts and schemas.test.ts)
- pnpm lint — clean
- pnpm format:check — clean across 1119 files
- pnpm build — all 7 packages produce dist/
- pnpm smoke:json-report — schema-valid v1 JsonReport

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:20:18 -07:00
Aiden Bai 914e5f3aee feat: leaderboard in README + dedicated /leaderboard page (#176)
* docs(readme): add leaderboard section with top 10 from benchmarks repo

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* ci(leaderboard): refresh README leaderboard from benchmarks json on schedule

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* feat(website): add /leaderboard page driven by react-doctor-benchmarks

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* chore(scripts): drop bench:scores in favor of leaderboard.json

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* docs(readme): trim leaderboard table to repo + score

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* docs(readme): drop raw-results link from leaderboard section

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(scripts): make update-leaderboard idempotent against formatter

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* refactor(website): extract score thresholds, color, label, doctor face to shared utils

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-08 05:58:36 -07:00
Aiden Bai 4fb4d27e85 fix(react-doctor): forward reactMajorVersion in programmatic diagnose() + cleanups (#174)
* fix(react-doctor): forward reactMajorVersion in programmatic diagnose() + cleanups

H1 (regression introduced by #172) — `diagnose()` in `src/index.ts`
forgot to forward `reactMajorVersion` to `runOxlint`. After the
directional version-gating change in #172, that meant every
"prefer-newer-api" rule (today: `prefer-use-effect-event`) was silently
skipped for every programmatic API consumer, even on React 19+
projects. The CLI (`scan.ts`) was unaffected because it always passed
the version explicitly.

Fix is one line + one import — mirror what `scan.ts` already does.
Added `tests/diagnose.test.ts` with a regression test that asserts
`prefer-use-effect-event` fires on a React 19 fixture, plus a
symmetric guard that it stays skipped when the React version can't
be parsed (e.g. a github: range).

H2 — updated the stale docstring on `runOxlint`'s `reactMajorVersion`
field. The doc still claimed "`null` means unknown — leave those
rules enabled" but after #172 the null branch is directional
(deprecation-warning rules stay on, prefer-newer-api rules go off).

M1 — `SUB_HANDLER_DIRECT_CALLEE_NAMES` was just an alias of
`TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES`. Both names existed for
narrative reasons in different files; knip flagged the duplicate
export. Collapsed to the canonical name and updated the one consumer
(`isCallExpressionWithSubHandlerCallee` in state-and-effects).

L1 — moved `walkInsideStatementBlocks` from inline in
`state-and-effects.ts` to `plugin/helpers.ts` next to its sibling
`walkAst`. It already had four call sites and is the natural
"synchronous-only" walker for any rule asking what runs inside an
effect's own body — colocating with `walkAst` makes future rules
discover it.

L2 (audit finding) — proposed receiver-gating
`post`/`put`/`patch` in `EVENT_TRIGGERED_SIDE_EFFECT_CALLEES`
INTENTIONALLY NOT TAKEN. The canonical "You Might Not Need an Effect"
§6 example is `post(jsonToSubmit)` as a bare callee, so removing
those names breaks textbook detection (3 existing tests / fixtures).
Documented the trade-off in the constants.ts docstring.

Validation
- 642 tests passing (640 baseline + 2 new diagnose-API regressions)
- typecheck / lint / format clean

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: format scripts/benchmark-scores.ts

Pre-existing formatting issue introduced in 6afdc04 — the script was
committed unformatted, breaking `pnpm format:check` on every PR
branched off main since. Picked up by rebasing this PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 03:53:18 -07:00
Nisarg Patel 6afdc04ba7 chore(package): add benchmark scores script to package.json 2026-05-08 03:39:21 -07:00