diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index afc2547d8..d12f595c5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -631,24 +631,27 @@ export async function runCli( const { resolveCommandPath } = await import("./lib/introspect.js"); const { getDefaultOrganization } = await import("./lib/db/defaults.js"); + // Redact first: a sensitive flag's value would otherwise survive as a + // path segment or the trailing argv token. + const safeArgv = redactArgv(argv); + // Strip flags so resolveCommandPath only sees command path segments - const pathSegments = argv.filter((t) => !t.startsWith("-")); + const pathSegments = safeArgv.filter((t) => !t.startsWith("-")); const resolved = resolveCommandPath( routes as unknown as Parameters[0], pathSegments, ); const unknownToken = - resolved?.kind === "unresolved" ? resolved.input : (argv.at(-1) ?? ""); + resolved?.kind === "unresolved" + ? resolved.input + : (safeArgv.at(-1) ?? ""); const suggestions = resolved?.kind === "unresolved" ? resolved.suggestions : []; // Extract org/project from argv, fall back to default org from SQLite - const fromArgv = extractOrgProjectFromArgv(argv); + const fromArgv = extractOrgProjectFromArgv(safeArgv); const org = fromArgv.org ?? getDefaultOrganization() ?? undefined; - // Redact sensitive flags (e.g., --token) before sending to Sentry - const safeArgv = redactArgv(argv); - Sentry.setTag("command", "unknown"); if (org) { Sentry.setTag("sentry.org", org); @@ -660,7 +663,7 @@ export async function runCli( argv: safeArgv, unknown_token: unknownToken, suggestions, - arg_count: argv.length, + arg_count: safeArgv.length, org: org ?? null, project: fromArgv.project ?? null, }); diff --git a/packages/cli/test/lib/unknown-command-telemetry.test.ts b/packages/cli/test/lib/unknown-command-telemetry.test.ts new file mode 100644 index 000000000..8d9b6df29 --- /dev/null +++ b/packages/cli/test/lib/unknown-command-telemetry.test.ts @@ -0,0 +1,47 @@ +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn +import * as Sentry from "@sentry/node"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { runCli } from "../../src/cli.js"; +import { SENTRY_CLI } from "../../src/sentry-cli.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("unknown-command-telemetry-"); + +const SECRET = "abcd12efg0"; +const originalExitCode = process.exitCode; + +let setContextSpy: ReturnType; + +beforeEach(() => { + vi.spyOn(process.stdout, "write").mockReturnValue(true); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + setContextSpy = vi.spyOn(Sentry, "setContext"); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; +}); + +describe("unknown_command telemetry: sensitive flag values are redacted", () => { + test.each([ + { + label: "--flag VALUE form", + argv: ["alert", "--token", SECRET], + }, + { + label: "--flag=VALUE form", + argv: ["alert", `--token=${SECRET}`], + }, + ])("$label does not leak the token", async ({ argv }) => { + await runCli(argv, SENTRY_CLI); + + const unknownCommandCall = setContextSpy.mock.calls.find( + ([name]) => name === "unknown_command", + ); + expect(unknownCommandCall).toBeDefined(); + + const serialized = JSON.stringify(unknownCommandCall); + expect(serialized).not.toContain(SECRET); + }); +});