Skip to content
Draft
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
18 changes: 13 additions & 5 deletions packages/core/src/build-json-report-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { JsonReport, JsonReportMode } from "./types/index.js";
import { formatReactDoctorError, isReactDoctorError } from "./errors.js";
import { getErrorChainMessages } from "./format-error-chain.js";
import { anonymizeSensitiveText } from "./utils/anonymize-sensitive-text.js";

interface BuildJsonReportErrorInput {
version: string;
Expand Down Expand Up @@ -28,30 +29,37 @@ const safeGetErrorChain = (error: unknown): string[] => {
}
};

const anonymizeMessages = (messages: string[]): string[] => messages.map(anonymizeSensitiveText);

export const buildJsonReportError = (input: BuildJsonReportErrorInput): JsonReport => {
const chain = safeGetErrorChain(input.error);
const chain = anonymizeMessages(safeGetErrorChain(input.error));
const sentryEventId = input.sentryEventId ?? null;
const errorPayload = isReactDoctorError(input.error)
? {
message: formatReactDoctorError(input.error),
message: anonymizeSensitiveText(formatReactDoctorError(input.error)),
name: `ReactDoctorError(${input.error.reason._tag})`,
chain,
sentryEventId,
}
: input.error instanceof Error
? {
message: input.error.message || input.error.name || "Error",
message: anonymizeSensitiveText(input.error.message || input.error.name || "Error"),
name: input.error.name || "Error",
chain,
sentryEventId,
}
: { message: safeStringify(input.error), name: "Error", chain, sentryEventId };
: {
message: anonymizeSensitiveText(safeStringify(input.error)),
name: "Error",
chain,
sentryEventId,
};

return {
schemaVersion: 1,
version: input.version,
ok: false,
directory: input.directory,
directory: anonymizeSensitiveText(input.directory),
mode: input.mode ?? "full",
diff: null,
projects: [],
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export * from "./run-oxlint.js";
export * from "./summarize-diagnostics.js";
export * from "./validate-config-types.js";
export * from "./utils/assign-fix-groups.js";
export * from "./utils/anonymize-sensitive-text.js";
export * from "./utils/build-rule-docs-url.js";
export * from "./utils/classify-package-role.js";
export * from "./utils/compute-config-fingerprint.js";
Expand All @@ -100,6 +101,7 @@ export * from "./utils/resolve-github-actions-score-metadata.js";
export * from "./utils/resolve-lint-batch-ordering.js";
export * from "./utils/resolve-scan-concurrency.js";
export * from "./utils/sort-diagnostics-stable.js";
export * from "./utils/scrub-sensitive-paths.js";
export * from "./utils/to-relative-path.js";
export * from "./utils/warn-config-issue.js";
export * from "./runners/oxlint/capabilities.js";
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/runners/oxlint/parse-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { isMinifiedSource } from "../../utils/is-minified-source.js";
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 { anonymizeSensitiveText } from "../../utils/anonymize-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 @@ -142,8 +142,8 @@ const cleanDiagnosticMessage = (
// 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),
message: anonymizeSensitiveText(cleaned.message),
help: anonymizeSensitiveText(cleaned.help),
};
};

Expand Down Expand Up @@ -223,7 +223,7 @@ const buildRelatedLocations = (
column: label.span.column ?? 0,
offset: label.span.offset,
length: label.span.length,
message: label.label ?? "",
message: anonymizeSensitiveText(label.label ?? ""),
});
}
return related;
Expand Down Expand Up @@ -262,15 +262,15 @@ export const parseOxlintOutput = (
} catch {
throw new ReactDoctorError({
reason: new OxlintOutputUnparseable({
preview: stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS),
preview: anonymizeSensitiveText(stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)),
}),
});
}

if (!isOxlintOutput(parsed)) {
throw new ReactDoctorError({
reason: new OxlintOutputUnparseable({
preview: stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS),
preview: anonymizeSensitiveText(stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)),
}),
});
}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/utils/anonymize-sensitive-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redactSensitiveText } from "./redact-sensitive-text.js";
import { scrubSensitivePaths } from "./scrub-sensitive-paths.js";

export const anonymizeSensitiveText = (text: string): string =>
redactSensitiveText(scrubSensitivePaths(text));
19 changes: 19 additions & 0 deletions packages/core/src/utils/scrub-sensitive-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os from "node:os";

const HOME_DIRECTORY = os.homedir();

const USER_HOME_PATTERNS: ReadonlyArray<RegExp> = [
/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi,
/(?:\/Users\/|\/home\/)[^/\\]+/gi,
];

export const scrubSensitivePaths = (text: string): string => {
let scrubbed = text;
if (HOME_DIRECTORY.length > 1) {
scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
}
for (const pattern of USER_HOME_PATTERNS) {
scrubbed = scrubbed.replace(pattern, "~");
}
return scrubbed;
};
58 changes: 58 additions & 0 deletions packages/core/tests/parse-oxlint-output-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vite-plus/test";
import { formatReactDoctorError, isReactDoctorError } from "../src/errors.js";
import { parseOxlintOutput } from "../src/runners/oxlint/parse-output.js";
import { buildProject, TEST_ROOT_DIRECTORY } from "./helpers/oxlint-parse-harness.js";

const TOKEN = `ghp_${"a".repeat(36)}`;

const parseFailureMessage = (stdout: string): string => {
try {
parseOxlintOutput(stdout, buildProject(), TEST_ROOT_DIRECTORY);
} catch (error) {
if (isReactDoctorError(error)) return formatReactDoctorError(error);
throw error;
}
throw new Error("Expected parseOxlintOutput to fail");
};

describe("parseOxlintOutput redaction", () => {
it("redacts sensitive text from unparseable-output previews", () => {
const message = parseFailureMessage(`not json /Users/jane/project ${TOKEN}`);

expect(message).toContain("~/project");
expect(message).not.toContain("/Users/jane");
expect(message).not.toContain(TOKEN);
});

it("redacts related-location labels", () => {
const stdout = JSON.stringify({
diagnostics: [
{
message: "Primary message",
code: "react(no-danger)",
severity: "error",
causes: [],
url: "",
help: "",
filename: "src/components/widget.tsx",
labels: [
{ label: "", span: { offset: 0, length: 1, line: 12, column: 3 } },
{
label: `related /Users/jane/project ${TOKEN}`,
span: { offset: 2, length: 3, line: 13, column: 5 },
},
],
related: [],
},
],
number_of_files: 1,
number_of_rules: 1,
});

const [diagnostic] = parseOxlintOutput(stdout, buildProject(), TEST_ROOT_DIRECTORY);

expect(diagnostic.relatedLocations?.[0]?.message).toContain("~/project");
expect(diagnostic.relatedLocations?.[0]?.message).not.toContain("/Users/jane");
expect(diagnostic.relatedLocations?.[0]?.message).not.toContain(TOKEN);
});
});
6 changes: 2 additions & 4 deletions packages/react-doctor/src/cli/utils/anonymize-text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { isPlainObject, redactSensitiveText } from "@react-doctor/core";
import { scrubSensitivePaths } from "./scrub-sensitive-text.js";
import { anonymizeSensitiveText, isPlainObject } from "@react-doctor/core";

/**
* Free-text fields can carry both a home-directory path (the OS username) and a
Expand All @@ -8,8 +7,7 @@ import { scrubSensitivePaths } from "./scrub-sensitive-text.js";
* event scrubber ({@link scrubSentryEvent}) and metric scrubber
* ({@link scrubSentryMetric}).
*/
export const anonymizeText = (text: string): string =>
redactSensitiveText(scrubSensitivePaths(text));
export const anonymizeText = (text: string): string => anonymizeSensitiveText(text);

/**
* Recursively rewrites every string within an arbitrary value (object / array /
Expand Down
25 changes: 15 additions & 10 deletions packages/react-doctor/src/cli/utils/ci/github-actions-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,27 +287,32 @@ const findActionWorkflowFile = (projectRoot: string): CiWorkflowFile | null => {
try {
const content = fs.readFileSync(path.join(workflowsDir, entry), "utf8");
if (containsReactDoctor(content)) return { path: path.join(workflowsDir, entry), content };
} catch {}
} catch {
continue;
}
}
return null;
};

// The canonical `react-doctor.yml` when present (it's ours, so edit it), else
// the first other workflow that wires up the action — so `ci config` /
// `ci upgrade` manage the step wherever the user put it.
const readWorkflow = (projectRoot: string): CiWorkflowFile | null => {
const readCanonicalWorkflow = (projectRoot: string): CiWorkflowFile | null => {
const canonical = readReactDoctorWorkflow(projectRoot);
if (canonical) return { path: canonical.workflowPath, content: canonical.content };
return findActionWorkflowFile(projectRoot);
if (canonical === null || !containsReactDoctor(canonical.content)) return null;
return { path: canonical.workflowPath, content: canonical.content };
};

// Prefer the canonical workflow when it actually wires up React Doctor, else
// manage the action wherever the user put the step.
const readWorkflow = (projectRoot: string): CiWorkflowFile | null => {
return readCanonicalWorkflow(projectRoot) ?? findActionWorkflowFile(projectRoot);
};

// Reports "exists" when the action is already wired up anywhere (or our
// canonical file is present), so `ci install` never adds a second workflow.
// Reports "exists" only when the action is already wired up, so a stale empty
// canonical file doesn't mask the workflow users actually run.
const scaffold = (projectRoot: string, defaultBranch: string, gate: CiGate): CiScaffoldResult => {
const existing = readWorkflow(projectRoot);
if (existing) return { status: "exists", path: existing.path };
const workflowPath = getReactDoctorWorkflowPath(projectRoot);
if (fs.existsSync(workflowPath)) return { status: "exists", path: workflowPath };
if (fs.existsSync(workflowPath)) return { status: "failed", path: workflowPath };
try {
fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
fs.writeFileSync(workflowPath, buildGithubWorkflow(defaultBranch, gate, "v2"));
Expand Down
12 changes: 8 additions & 4 deletions packages/react-doctor/src/cli/utils/ci/manage-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ const resolveProjectRoot = (options: CiCommandOptions): string => {
const setupDocsUrl = (provider: CiProvider): string =>
provider.id === "github-actions" ? GITHUB_ACTIONS_SETUP_URL : CI_URL;

const logMissingWorkflow = (provider: CiProvider): void => {
logger.error(`No ${provider.displayName} workflow found.`);
logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`);
};

// Resolves the CI backend: an explicit `--provider`, else autodetection, else a
// prompt (or GitHub Actions when prompts are off). Returns null when the user
// passed an unknown id (after reporting it) so the caller stops cleanly.
Expand Down Expand Up @@ -295,6 +300,7 @@ export const runCiInstall = async (options: CiCommandOptions = {}): Promise<void
if (result.status === "failed") {
scaffoldSpinner.fail(`Couldn't write ${provider.fileLabel}.`);
logger.dim(` Set it up by hand: ${highlighter.info(setupDocsUrl(provider))}`);
process.exitCode = 1;
return;
}

Expand Down Expand Up @@ -353,8 +359,7 @@ export const runCiUpgrade = async (options: CiCommandOptions = {}): Promise<void

const workflow = provider.readWorkflow(projectRoot);
if (workflow === null) {
logger.error(`No ${provider.displayName} workflow found.`);
logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`);
logMissingWorkflow(provider);
process.exitCode = 1;
return;
}
Expand Down Expand Up @@ -427,8 +432,7 @@ export const runCiConfig = async (options: CiCommandOptions = {}): Promise<void>

const workflow = provider.readWorkflow(projectRoot);
if (workflow === null) {
logger.error(`No ${provider.displayName} workflow found.`);
logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`);
logMissingWorkflow(provider);
process.exitCode = 1;
return;
}
Expand Down
29 changes: 18 additions & 11 deletions packages/react-doctor/src/cli/utils/handle-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { VERSION } from "./version.js";
import { METRIC } from "./constants.js";
import { formatEnvironmentError, isEnvironmentError } from "./is-environment-error.js";
import { recordCount } from "./record-metric.js";
import { anonymizeText } from "./anonymize-text.js";
import { buildRunContext } from "./build-run-context.js";

// `shouldExit` is optional here (defaults to exiting) and the CLI adds a Sentry
// event id, surfaced as a reference the user can quote so we can locate the
Expand All @@ -37,17 +39,22 @@ interface ErrorReportContext {
const formatErrorForReport = (error: unknown): string =>
isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error);

const formatErrorForIssue = (error: unknown): string => anonymizeText(formatErrorForReport(error));

const formatSingleLine = (text: string): string => text.replaceAll(/\s+/g, " ").trim();

const getErrorReportContext = (): ErrorReportContext => ({
cwd: process.cwd(),
command: process.argv.join(" "),
nodeVersion: process.version,
platform: process.platform,
architecture: process.arch,
isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]),
isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE]),
});
const getErrorReportContext = (): ErrorReportContext => {
const runContext = buildRunContext();
return {
cwd: anonymizeText(runContext.cwd),
command: anonymizeText(runContext.argv),
nodeVersion: runContext.node,
platform: process.platform,
architecture: process.arch,
isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]),
isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE]),
};
};

const formatConfiguredState = (isConfigured: boolean): string => (isConfigured ? "yes" : "no");

Expand All @@ -56,7 +63,7 @@ const buildErrorIssueBody = (
context: ErrorReportContext,
sentryEventId: string | undefined,
): string => {
const formattedError = formatErrorForReport(error) || "(empty error)";
const formattedError = formatErrorForIssue(error) || "(empty error)";
const isOtlpExporterEnabled =
context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured;

Expand Down Expand Up @@ -90,7 +97,7 @@ const buildErrorIssueBody = (
};

export const buildErrorIssueUrl = (error: unknown, sentryEventId?: string): string => {
const formattedError = formatSingleLine(formatErrorForReport(error));
const formattedError = formatSingleLine(formatErrorForIssue(error));
const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`);
issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error");
issueUrl.searchParams.set("labels", "bug");
Expand Down
38 changes: 1 addition & 37 deletions packages/react-doctor/src/cli/utils/scrub-sensitive-text.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1 @@
import os from "node:os";

// The current user's home directory, replaced wholesale so absolute paths in
// telemetry (cwd, argv, stack frames, span attributes) don't carry the OS
// username — the primary personal identifier that leaks through file paths.
const HOME_DIRECTORY = os.homedir();

// Generic user-home roots, matched case-insensitively with either slash so a
// path belonging to another user — or captured when `os.homedir()` resolution
// fails — is still anonymized. Each captures the `<drive>:\Users|/Users|/home`
// root plus the username segment and collapses the lot to `~`. The Windows
// pattern runs first so the POSIX `/Users/` rule doesn't partially rewrite a
// forward-slash Windows path (`C:/Users/<name>`) into `C:~`.
const USER_HOME_PATTERNS: ReadonlyArray<RegExp> = [
/[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi,
/(?:\/Users\/|\/home\/)[^/\\]+/gi,
];

/**
* Replaces the user's home directory (and generic `/Users|/home|C:\Users\<name>`
* roots) with `~` so absolute paths can't be tied back to an individual. Keeps
* the path's relative structure intact, which stays useful for debugging while
* dropping the personally-identifying prefix. Idempotent — re-running on an
* already-scrubbed `~/...` path is a no-op.
*/
export const scrubSensitivePaths = (text: string): string => {
let scrubbed = text;
// Exact home directory first — covers non-standard roots (e.g. `/root`,
// custom `$HOME`) that the generic patterns below don't anticipate.
if (HOME_DIRECTORY.length > 1) {
scrubbed = scrubbed.split(HOME_DIRECTORY).join("~");
}
for (const pattern of USER_HOME_PATTERNS) {
scrubbed = scrubbed.replace(pattern, "~");
}
return scrubbed;
};
export { scrubSensitivePaths } from "@react-doctor/core";
Loading
Loading