Skip to content
Merged
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
11 changes: 6 additions & 5 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ import {
getSynonymSuggestionFromArgv,
} from "./lib/command-suggestions.js";
import { CLI_VERSION } from "./lib/constants.js";
import { redactCredentialText } from "./lib/credential-redaction.js";
import { reportCliError } from "./lib/error-reporting.js";
import {
ApiError,
AuthError,
CliError,
formatError,
getExitCode,
OutputError,
stringifyUnknown,
WizardError,
} from "./lib/errors.js";
import { error as errorColor, warning } from "./lib/formatters/colors.js";
Expand Down Expand Up @@ -383,7 +384,7 @@ const customText: ApplicationText = {
// user mistakes, not real errors.
const synonymResult = formatSynonymError(exc, ansiColor);
if (synonymResult) {
return synonymResult;
return redactCredentialText(synonymResult);
}

// Report command errors to Sentry with stable fingerprinting. Stricli
Expand All @@ -400,12 +401,12 @@ const customText: ApplicationText = {
return "";
}
const prefix = ansiColor ? errorColor("Error:") : "Error:";
return `${prefix} ${exc.format()}`;
return `${prefix} ${formatError(exc)}`;
}
if (exc instanceof Error) {
return `Unexpected error: ${exc.stack ?? exc.message}`;
return `Unexpected error: ${redactCredentialText(exc.stack ?? exc.message)}`;
}
return `Unexpected error: ${stringifyUnknown(exc)}`;
return `Unexpected error: ${formatError(exc)}`;
},
};

Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
* stream error handlers and calls `startCli()`.
*/

import { redactCredentialText } from "./lib/credential-redaction.js";
import { getEnv } from "./lib/env.js";
import { CliError } from "./lib/errors.js";
import { CliError, formatError } from "./lib/errors.js";
import { initTimezone } from "./lib/timezone.js";

/**
Expand Down Expand Up @@ -239,7 +240,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
const { ExitCode, run } = await import("@stricli/core");
const { app } = await import("./app.js");
const { buildContext } = await import("./context.js");
const { AuthError, OutputError, formatError, getExitCode } = await import(
const { AuthError, OutputError, getExitCode } = await import(
"./lib/errors.js"
);
const { error } = await import("./lib/formatters/colors.js");
Expand Down Expand Up @@ -693,15 +694,15 @@ export async function startCli(): Promise<void> {
await preloadProjectContext(process.cwd());
} catch (err) {
if (err instanceof CliError) {
process.stderr.write(`${err.format()}\n`);
process.stderr.write(`${formatError(err)}\n`);
process.exitCode = err.exitCode;
return;
}
// Gracefully degrade: project context is optional.
}

return runCli(args).catch((err) => {
process.stderr.write(`Fatal: ${err}\n`);
process.stderr.write(`Fatal: ${redactCredentialText(String(err))}\n`);
process.exitCode = 1;
});
}
80 changes: 80 additions & 0 deletions packages/cli/src/lib/credential-redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Stateless credential redaction for CLI diagnostics and telemetry.
* Kept free of SDK imports for CLI startup and the completion fast path.
* Successful API responses are not passed through this redactor.
*/

const INVALID_BEARER_HEADER_START =
/(\bHeaders\.(?:set|append):[ \t]*)(\\*["'])Bearer[ \t]+/gi;
const INVALID_HEADER_END = /(?<!\\)(\\*["']) is an invalid header value/gi;
// Header validation errors quote the entire value, including invalid newlines.
// The end-of-string alternative also covers messages truncated by the SDK.
const QUOTED_CREDENTIAL =
/(?<!\\)(\\*["'])(Bearer[ \t]+|sntry[su]_)[\s\S]*?(?:\1|$)/gi;
/** Control characters can also appear escaped in serialized diagnostics. */
const ESCAPED_CONTROL = /\\+(?:[nrtbfv]|u00[01][\da-f]|u007f|u202[89])/.source;
const BEARER_PART = String.raw`(?:${ESCAPED_CONTROL}[ \t]*|\\+[^"'\s\\]|[^\s"'\\])`;
// An explicit Bearer context can contain opaque tokens with punctuation.
// Quotes (including JSON-escaped quotes) delimit the diagnostic string.
const BEARER_CREDENTIAL = new RegExp(
String.raw`\bBearer[ \t]+${BEARER_PART}+(?:(?:\r\n|(?! )[\s\x00-\x1f\x7f-\x9f])[ \t]*${BEARER_PART}+)*`,
"gi"
);
const SENTRY_CREDENTIAL = new RegExp(
String.raw`\bsntry[su]_[A-Za-z0-9._~+/=-]+(?:(?:\r\n|(?! )[\s\x00-\x1f\x7f-\x9f]|${ESCAPED_CONTROL})[ \t]*[A-Za-z0-9._~+/=-]+)*`,
"gi"
);

/**
* Use the runtime's final delimiter because an invalid token can contain quotes.
* This may also hide text between concatenated diagnostics with the same quote:
* the runtime does not distinguish a delimiter inside a token from its end.
* Index suffixes once so repeated header prefixes cannot cause quadratic scans.
*/
function redactInvalidBearerHeaders(text: string): string {
const lastEnds = new Map<string, number>();
for (const match of text.matchAll(INVALID_HEADER_END)) {
const quote = match[1];
if (quote) {
lastEnds.set(quote, match.index);
}
}
if (lastEnds.size === 0) {
return text;
}

const parts: string[] = [];
let cursor = 0;
for (const match of text.matchAll(INVALID_BEARER_HEADER_START)) {
if (match.index < cursor) {
continue;
Comment thread
sentry[bot] marked this conversation as resolved.
}
const [, prefix, quote] = match;
if (!(prefix && quote)) {
continue;
}
const end = lastEnds.get(quote);
if (end === undefined || end < match.index + match[0].length) {
continue;
}
parts.push(
text.slice(cursor, match.index),
`${prefix}${quote}Bearer [REDACTED]${quote}`
);
cursor = end + quote.length;
}
parts.push(text.slice(cursor));
return parts.join("");
}

/** Remove recognizable credentials from diagnostics without retaining secrets. */
export function redactCredentialText(text: string): string {
return redactInvalidBearerHeaders(text)
.replace(QUOTED_CREDENTIAL, (_match, quote: string, prefix: string) =>
prefix.toLowerCase().startsWith("bearer")
? `${quote}Bearer [REDACTED]${quote}`
: `${quote}[REDACTED]${quote}`
)
.replace(BEARER_CREDENTIAL, "Bearer [REDACTED]")
.replace(SENTRY_CREDENTIAL, "[REDACTED]");
}
3 changes: 2 additions & 1 deletion packages/cli/src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import { redactCredentialText } from "./credential-redaction.js";
import {
ApiError,
AuthError,
Expand Down Expand Up @@ -255,7 +256,7 @@ export function extractResourceKind(resource: string): string {
* `"Invalid trace ID \"abc\". Expected ..."` → `"Invalid trace ID"` (with maxWords=3)
*/
export function extractMessagePrefix(message: string, maxWords = 3): string {
const firstLine = message.split("\n", 1)[0] ?? "";
const firstLine = redactCredentialText(message).split("\n", 1)[0] ?? "";
return firstLine
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* @see https://cli.sentry.dev/exit-codes/ for full reference
*/

import { redactCredentialText } from "./credential-redaction.js";
import {
buildBillingUrl,
buildOrgSettingsUrl,
Expand Down Expand Up @@ -802,10 +803,9 @@ export function stringifyUnknown(value: unknown): string {
* @returns Formatted error string
*/
export function formatError(error: unknown): string {
if (error instanceof CliError) {
return error.format();
}
return stringifyUnknown(error);
return redactCredentialText(
error instanceof CliError ? error.format() : stringifyUnknown(error)
);
}

/**
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/lib/sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @module
*/

import { redactCredentialText } from "./credential-redaction.js";

/** Options for programmatic CLI invocation. */
export type SentryOptions = {
/**
Expand Down Expand Up @@ -73,13 +75,13 @@ export class SentryError extends Error {
/** CLI exit code (non-zero). */
readonly exitCode: number;

/** Raw stderr output from the command. */
/** Captured stderr output with recognizable credentials redacted. */
readonly stderr: string;

constructor(message: string, exitCode: number, stderr: string) {
super(message);
super(redactCredentialText(message));
this.name = "SentryError";
this.exitCode = exitCode;
this.stderr = stderr;
this.stderr = redactCredentialText(stderr);
}
}
11 changes: 10 additions & 1 deletion packages/cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import { ApiError, isUserError } from "./errors.js";
import { attachSentryReporter, logger } from "./logger.js";
import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js";
import { redactTelemetryEnvelope } from "./telemetry/credential-redaction.js";
import { makeCompressedTransport } from "./telemetry/zstd-transport.js";
import { getRealUsername } from "./utils.js";

Expand Down Expand Up @@ -595,7 +596,15 @@ export function initSentry(
// smaller payloads, faster compress/decompress on both sides.
// Automatic gzip fallback when running on Node < 22.15, where
// `node:zlib`'s zstd support is unavailable.
transport: makeCompressedTransport,
transport: (transportOptions) => {
const transport = makeCompressedTransport(transportOptions);
return {
// The SDK adds log scope attributes after beforeSendLog and skips
// beforeSend for internal errors. Redact at the final delivery boundary.
send: (envelope) => transport.send(redactTelemetryEnvelope(envelope)),
flush: (timeout) => transport.flush(timeout),
};
},
// Pass custom CA certificates to the transport for corporate TLS proxies.
// The zstd-transport reads `caCerts` and passes it as `ca:` to
// `http.request()`, and the SDK's fallback `makeNodeTransport` does the same.
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/lib/telemetry/credential-redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** Redact outgoing SDK envelopes without modifying live scopes or caller data. */

import { type Envelope, normalize } from "@sentry/core";
import { redactCredentialText } from "../credential-redaction.js";

/** Materialize the same JSON as the SDK before redacting a detached copy. */
function redactJson<T>(value: T): T {
let serialized: string | undefined;
try {
serialized = JSON.stringify(value);
} catch {
// This is the SDK's envelope serialization fallback for cycles and BigInt.
serialized = JSON.stringify(normalize(value));
}
const copy: T = JSON.parse(serialized ?? "null");
if (typeof copy === "string") {
return redactCredentialText(copy) as T;
}
const pending: unknown[] = [copy];
while (pending.length > 0) {
const current = pending.pop();
if (current === null || typeof current !== "object") {
continue;
}
for (const [key, nested] of Object.entries(current)) {
if (typeof nested === "string") {
(current as Record<string, unknown>)[key] =
redactCredentialText(nested);
} else {
pending.push(nested);
}
}
}
return copy;
}

/**
* Scrub the final envelope, after SDK metadata and log attributes are resolved.
* JSON materialization preserves boxed values/toJSON without mutating live
* scopes, client options, or caller-owned objects. Binary attachments stay intact.
*/
export function redactTelemetryEnvelope(envelope: Envelope): Envelope {
return [
redactJson(envelope[0]),
envelope[1].map(([headers, payload]) => {
const safeHeaders = redactJson(headers);
const safePayload =
payload instanceof Uint8Array ? payload : redactJson(payload);
if (typeof safePayload === "string" && headers.length !== undefined) {
safeHeaders.length = Buffer.byteLength(safePayload, "utf8");
}
return [safeHeaders, safePayload];
}),
] as Envelope;
}
25 changes: 24 additions & 1 deletion packages/cli/test/e2e/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "vitest";
import { Database } from "../../src/lib/db/sqlite.js";
import { EXIT } from "../../src/lib/errors.js";
import { createE2EContext, type E2EContext } from "../fixture.js";
import { createE2EContext, type E2EContext, runCli } from "../fixture.js";
import { cleanupTestDir, createTestConfigDir } from "../helpers.js";
import { createSentryMockServer, TEST_TOKEN } from "../mocks/routes.js";
import type { MockServer } from "../mocks/server.js";
Expand Down Expand Up @@ -250,3 +250,26 @@ describe("sentry auth logout", () => {
expect(result.exitCode).toBe(0);
});
});

describe("command error redaction", () => {
test("redacts unexpected command errors handled inside Stricli", async () => {
const result = await runCli(["auth", "whoami", "--json"], {
env: {
SENTRY_CONFIG_DIR: testConfigDir,
SENTRY_URL: mockServer.url,
SENTRY_AUTH_TOKEN: TEST_TOKEN,
SENTRY_FORCE_ENV_TOKEN: "1",
SENTRY_CUSTOM_HEADERS:
"X-Proxy: Bearer SYNTHETIC-PREFIX\rSYNTHETIC-SECRET-TAIL",
SENTRY_CLI_NO_TELEMETRY: "1",
},
});
const output = result.stdout + result.stderr;

expect(result.exitCode).toBe(EXIT.GENERAL);
expect(output).toContain("Unexpected error: TypeError:");
expect(output).toContain("[REDACTED]");
expect(output).not.toContain("SYNTHETIC-PREFIX");
expect(output).not.toContain("SYNTHETIC-SECRET-TAIL");
});
});
37 changes: 37 additions & 0 deletions packages/cli/test/lib/cli-startup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test, vi } from "vitest";
import { startCli } from "../../src/cli.js";
// biome-ignore lint/performance/noNamespaceImport: spy on the startup dependency
import * as upgrade from "../../src/lib/upgrade.js";
import { useTestConfigDir } from "../helpers.js";

useTestConfigDir("cli-startup-");

test("fatal errors preserve their name while redacting credentials", async () => {
const argv = process.argv;
const exitCode = process.exitCode;
const stderr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const cleanup = vi
.spyOn(upgrade, "startCleanupOldBinary")
.mockImplementation(() => {
throw new TypeError(
'Headers.set: "Bearer SYNTHETIC_PREFIX\nSYNTHETIC_SECRET" is an invalid header value.'
);
});

try {
process.argv = ["node", "sentry", "--help"];
await startCli();

expect(cleanup).toHaveBeenCalledOnce();
expect(stderr).toHaveBeenLastCalledWith(
'Fatal: TypeError: Headers.set: "Bearer [REDACTED]" is an invalid header value.\n'
);
expect(process.exitCode).toBe(1);
} finally {
process.argv = argv;
process.exitCode = exitCode;
vi.restoreAllMocks();
}
});
Loading
Loading