Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/redact-secrets-from-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runners/oxlint/parse-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]*$/;
Expand Down Expand Up @@ -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();
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/utils/redact-sensitive-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { GENERIC_SECRET_MIN_LENGTH_CHARS } from "../constants.js";

export const REDACTED_PLACEHOLDER = "<redacted>";

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
// `<redacted>` 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;
};
98 changes: 98 additions & 0 deletions packages/core/tests/redact-sensitive-text.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading