diff --git a/.changeset/redact-secrets-from-diagnostics.md b/.changeset/redact-secrets-from-diagnostics.md new file mode 100644 index 0000000000..72f4a044b7 --- /dev/null +++ b/.changeset/redact-secrets-from-diagnostics.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Redact secrets and PII from diagnostic output. Every diagnostic's `message`/`help` is now scrubbed for API keys, tokens, private keys, JWTs, credentialed URLs, and email addresses before it reaches the terminal, the JSON report, or the score API — so react-doctor never echoes or transmits a secret embedded in your source. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index de63d9300b..fab791201c 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -36,6 +36,12 @@ export const EARLIEST_GATED_PREACT_MAJOR = 10; export const ERROR_PREVIEW_LENGTH_CHARS = 200; +// Minimum length for the generic high-entropy token sweep in +// `redactSensitiveText`. Real API keys / tokens run 32+ chars; the +// known-format detectors catch shorter prefixed credentials, so this +// floor keeps the catch-all from masking ordinary long identifiers. +export const GENERIC_SECRET_MIN_LENGTH_CHARS = 32; + export const PERFECT_SCORE = 100; export const SCORE_GOOD_THRESHOLD = 75; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3383f477ec..bc639ec7e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,6 +69,7 @@ export * from "./utils/build-rule-prompt-url.js"; export * from "./utils/dedupe-diagnostics.js"; export * from "./utils/group-by.js"; export * from "./utils/match-glob-pattern.js"; +export * from "./utils/redact-sensitive-text.js"; export * from "./utils/resolve-github-actions-score-metadata.js"; export * from "./utils/to-relative-path.js"; export * from "./utils/warn-config-issue.js"; diff --git a/packages/core/src/runners/oxlint/parse-output.ts b/packages/core/src/runners/oxlint/parse-output.ts index e891f019ff..83013c2be8 100644 --- a/packages/core/src/runners/oxlint/parse-output.ts +++ b/packages/core/src/runners/oxlint/parse-output.ts @@ -9,6 +9,7 @@ import { ERROR_PREVIEW_LENGTH_CHARS, SOURCE_FILE_PATTERN } from "../../constants import { OxlintOutputUnparseable, ReactDoctorError } from "../../errors.js"; import { buildNoSecretsRecommendation } from "../../utils/build-no-secrets-recommendation.js"; import { appendReanimatedSharedValueHint } from "../../utils/append-reanimated-shared-value-hint.js"; +import { redactSensitiveText } from "../../utils/redact-sensitive-text.js"; import { shouldSuppressLocalUseHookDiagnostic } from "./should-suppress-local-use-hook-diagnostic.js"; const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/; @@ -72,6 +73,24 @@ const cleanDiagnosticMessage = ( plugin: string, rule: string, project: ProjectInfo, +): CleanedDiagnostic => { + const cleaned = resolveCleanedDiagnostic(message, help, plugin, rule, project); + // Final guard: a rule may echo a source fragment containing a secret + // or PII into its message/help. Scrub it here — the single point every + // diagnostic flows through — so it reaches neither the terminal, the + // JSON report, nor the score API. + return { + message: redactSensitiveText(cleaned.message), + help: redactSensitiveText(cleaned.help), + }; +}; + +const resolveCleanedDiagnostic = ( + message: string, + help: string, + plugin: string, + rule: string, + project: ProjectInfo, ): CleanedDiagnostic => { if (plugin === "react-hooks-js") { const rawMessage = message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim(); diff --git a/packages/core/src/utils/redact-sensitive-text.ts b/packages/core/src/utils/redact-sensitive-text.ts new file mode 100644 index 0000000000..b5d327400b --- /dev/null +++ b/packages/core/src/utils/redact-sensitive-text.ts @@ -0,0 +1,83 @@ +import { GENERIC_SECRET_MIN_LENGTH_CHARS } from "../constants.js"; + +export const REDACTED_PLACEHOLDER = ""; + +interface RedactionRule { + readonly pattern: RegExp; + readonly replacement: string; +} + +// High-precision detectors for credentials and PII that can ride along +// inside a diagnostic's `message` / `help` when a rule echoes a source +// fragment (e.g. `useState("sk-live-…")`). Ordered so structured matches +// (key blocks, JWTs, credentialed URLs) run before the broad +// generic-token sweep, and so each replacement leaves only inert +// `` text that no later rule can re-match. Patterns are +// intentionally narrow — they target real secret shapes, never ordinary +// identifiers or short captions — so normal diagnostics stay readable. +const buildRedactionRules = (): RedactionRule[] => { + const genericTokenPattern = new RegExp( + // A contiguous base64url / hex run long enough to be a credential, + // constrained by lookaheads to contain BOTH a letter and a digit so + // plain prose words and all-digit line/column noise never match. The + // class deliberately excludes `= + /` so the run can't bleed across a + // `name=value` separator and swallow an adjacent label. + `\\b(?=[A-Za-z0-9_-]*[A-Za-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{${GENERIC_SECRET_MIN_LENGTH_CHARS},}`, + "g", + ); + + return [ + { + pattern: + /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g, + replacement: REDACTED_PLACEHOLDER, + }, + { + pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, + replacement: REDACTED_PLACEHOLDER, + }, + { + // Credentials embedded in a URL authority (`scheme://user:pass@host`). + // Lookbehind / lookahead keep the scheme and host so the location + // stays useful while the `user:pass` pair is masked. + pattern: /(?<=:\/\/)[^\s/:@]+:[^\s/:@]+(?=@)/g, + replacement: REDACTED_PLACEHOLDER, + }, + { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\b[sprk]k_(?:live|test)_[A-Za-z0-9]{10,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: REDACTED_PLACEHOLDER }, + { pattern: /\bya29\.[0-9A-Za-z_-]{20,}/g, replacement: REDACTED_PLACEHOLDER }, + { + pattern: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{16,}/g, + replacement: REDACTED_PLACEHOLDER, + }, + { + pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + replacement: REDACTED_PLACEHOLDER, + }, + { pattern: genericTokenPattern, replacement: REDACTED_PLACEHOLDER }, + ]; +}; + +const REDACTION_RULES = buildRedactionRules(); + +/** + * Masks API keys, tokens, private keys, credentialed URLs, and emails + * found anywhere inside a free-text string, returning the scrubbed text. + * Applied to every diagnostic's `message` / `help` at construction time + * so secrets never reach the terminal, the JSON report, or the score + * API — react-doctor must never echo or transmit a user's secrets. + */ +export const redactSensitiveText = (text: string): string => { + if (!text) return text; + let redacted = text; + for (const rule of REDACTION_RULES) { + redacted = redacted.replace(rule.pattern, rule.replacement); + } + return redacted; +}; diff --git a/packages/core/tests/redact-sensitive-text.test.ts b/packages/core/tests/redact-sensitive-text.test.ts new file mode 100644 index 0000000000..fdfc5b5143 --- /dev/null +++ b/packages/core/tests/redact-sensitive-text.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vite-plus/test"; +import { REDACTED_PLACEHOLDER, redactSensitiveText } from "@react-doctor/core"; + +describe("redactSensitiveText", () => { + it("returns empty input unchanged", () => { + expect(redactSensitiveText("")).toBe(""); + }); + + it("leaves ordinary diagnostic prose untouched", () => { + const messages = [ + "useState initialized from prop", + "useContext is superseded by `use()`", + "forwardRef is no longer needed on React 19+", + "Avoid calling setState inside useEffect (line 12:4)", + "Move secrets to server-only code", + ]; + for (const message of messages) { + expect(redactSensitiveText(message)).toBe(message); + } + }); + + it("redacts an AWS access key id", () => { + expect(redactSensitiveText("key AKIAIOSFODNN7EXAMPLE found")).toBe( + `key ${REDACTED_PLACEHOLDER} found`, + ); + }); + + it("redacts GitHub personal access tokens", () => { + const token = `ghp_${"a".repeat(36)}`; + expect(redactSensitiveText(`token: ${token}`)).toBe(`token: ${REDACTED_PLACEHOLDER}`); + }); + + it("redacts Stripe live keys", () => { + expect(redactSensitiveText(`useState("sk_live_${"4".repeat(20)}")`)).toContain( + REDACTED_PLACEHOLDER, + ); + expect(redactSensitiveText(`useState("sk_live_${"4".repeat(20)}")`)).not.toContain("sk_live_"); + }); + + it("redacts OpenAI-style sk- keys", () => { + const key = `sk-${"A1b2".repeat(10)}`; + expect(redactSensitiveText(`const apiKey = "${key}"`)).toBe( + `const apiKey = "${REDACTED_PLACEHOLDER}"`, + ); + }); + + it("redacts a JWT", () => { + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N"; + expect(redactSensitiveText(`Authorization header ${jwt}`)).toBe( + `Authorization header ${REDACTED_PLACEHOLDER}`, + ); + }); + + it("masks credentials inside a URL but keeps scheme and host", () => { + expect(redactSensitiveText("postgres://admin:hunter2pass@db.internal:5432/app")).toBe( + `postgres://${REDACTED_PLACEHOLDER}@db.internal:5432/app`, + ); + }); + + it("redacts a bearer token but keeps the scheme word", () => { + const result = redactSensitiveText("Authorization: Bearer abcDEF123456ghijKLmnop"); + expect(result).toBe(`Authorization: Bearer ${REDACTED_PLACEHOLDER}`); + }); + + it("redacts email addresses (PII)", () => { + expect(redactSensitiveText('useState("jane.doe@example.com")')).toBe( + `useState("${REDACTED_PLACEHOLDER}")`, + ); + }); + + it("redacts a PEM private key block", () => { + const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"; + expect(redactSensitiveText(`key: ${pem}`)).toBe(`key: ${REDACTED_PLACEHOLDER}`); + }); + + it("redacts an unprefixed high-entropy token", () => { + const token = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"; + expect(token.length).toBeGreaterThanOrEqual(32); + expect(redactSensitiveText(`token=${token}`)).toBe(`token=${REDACTED_PLACEHOLDER}`); + }); + + it("does not redact ordinary long identifiers without digits", () => { + const identifier = "someVeryDescriptiveComponentDisplayName"; + expect(redactSensitiveText(identifier)).toBe(identifier); + }); + + it("does not redact short alphanumeric tokens", () => { + expect(redactSensitiveText("status code 404 at offset 12ab")).toBe( + "status code 404 at offset 12ab", + ); + }); + + it("is idempotent", () => { + const once = redactSensitiveText("ghp_" + "z".repeat(36)); + expect(redactSensitiveText(once)).toBe(once); + }); +});