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
66 changes: 66 additions & 0 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
terminalPixelWidth,
} from "../lib/sixel.js";
import { imageBytesToSixel } from "../lib/sixel-image.js";
import { setOrgProjectContext } from "../lib/telemetry.js";

const log = logger.withTag("api");

Expand Down Expand Up @@ -227,6 +228,70 @@ function resolveApiTarget(endpoint: string): {
};
}

/**
* Org/project slugs or numeric IDs in API paths. Rejects schema placeholders
* (`{organization_id_or_slug}`) and empty/dot segments so we never tag junk.
*/
const API_PATH_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;

function isApiPathSlug(segment: string | undefined): segment is string {
return segment !== undefined && API_PATH_SLUG_RE.test(segment);
}

/**
* Parse org (and project when present) from a Sentry API endpoint path.
*
* `sentry api` never goes through target resolve, so the slug lives only in
* the URL. `organizations/`, `projects/`, and `teams/` prefixes carry the
* org; `projects/` and `organizations/{org}/projects/{project}/` also carry
* a project when that segment is a real slug.
*
* @param endpoint - Path relative to `/api/0/`, optionally with a query string
* or a leading `/api/0/` prefix
* @returns Org (and project when present), or `undefined` when the path is
* unscoped, a list endpoint, or a schema placeholder
* @internal Exported for testing
*/
export function parseOrgProjectFromApiPath(
endpoint: string
): { org: string; project?: string } | undefined {
const path = endpoint.split("?", 1)[0] ?? "";
const segments = path.split("/").filter((segment) => segment.length > 0);

if (segments[0] === "api" && segments[1] === "0") {
segments.splice(0, 2);
}

if (segments[0] === "organizations" && isApiPathSlug(segments[1])) {
const org = segments[1];
if (segments[2] === "projects" && isApiPathSlug(segments[3])) {
return { org, project: segments[3] };
}
return { org };
}

if (segments[0] === "projects" && isApiPathSlug(segments[1])) {
const org = segments[1];
if (isApiPathSlug(segments[2])) {
return { org, project: segments[2] };
}
return { org };
}

if (segments[0] === "teams" && isApiPathSlug(segments[1])) {
return { org: segments[1] };
}

return;
}

function tagOrgFromApiPath(endpoint: string): void {
const parsed = parseOrgProjectFromApiPath(endpoint);
if (parsed) {
setOrgProjectContext([parsed.org], parsed.project ? [parsed.project] : []);
}
}

/**
* Parse a field value, attempting JSON parse first.
*
Expand Down Expand Up @@ -1560,6 +1625,7 @@ export const apiCommand = buildCommand({

const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } =
resolveApiTarget(endpoint);
tagOrgFromApiPath(normalizedEndpoint);
if (strippedApiPrefix) {
// Silent auto-fix — not a warning. Users commonly copy/paste URLs
// that include the /api/0/ prefix; we strip it transparently and
Expand Down
28 changes: 22 additions & 6 deletions packages/cli/src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from "../../lib/sentry-url-parser.js";
import { buildEventSearchUrl } from "../../lib/sentry-urls.js";
import { getSpanTreeLines } from "../../lib/span-tree.js";
import { setOrgProjectContext } from "../../lib/telemetry.js";
import { isAllDigits } from "../../lib/utils.js";
import { EventViewOutputSchema, type SentryEvent } from "../../types/index.js";

Expand Down Expand Up @@ -571,6 +572,8 @@ type ResolveTargetOptions = {
*
* Handles all target types (explicit, search, org-all, auto-detect)
* including cross-project fallback via the eventids endpoint.
* Tags `sentry.org` / `sentry.project` on success because several of
* those paths skip `resolve-target`'s telemetry helper.
*
* @internal Exported for testing
*/
Expand All @@ -579,15 +582,17 @@ export async function resolveEventTarget(
): Promise<ResolvedEventTarget | null> {
const { parsed, eventId, cwd } = options;

let target: ResolvedEventTarget | null;
switch (parsed.type) {
case ProjectSpecificationType.Explicit: {
const org = await resolveEffectiveOrg(parsed.org);
return {
target = {
org,
project: parsed.project,
orgDisplay: parsed.org,
projectDisplay: parsed.project,
};
break;
}

case ProjectSpecificationType.ProjectSearch: {
Expand All @@ -597,25 +602,36 @@ export async function resolveEventTarget(
`sentry event view <org>/${parsed.projectSlug} ${eventId}`,
parsed.originalSlug
);
return {
target = {
org: resolved.org,
project: resolved.project,
orgDisplay: resolved.org,
projectDisplay: resolved.project,
};
break;
}

case ProjectSpecificationType.OrgAll: {
const org = await resolveEffectiveOrg(parsed.org);
return resolveOrgAllTarget(org, eventId, cwd);
target = await resolveOrgAllTarget(org, eventId, cwd);
break;
}

case ProjectSpecificationType.AutoDetect:
return resolveAutoDetectTarget(eventId, cwd);
target = await resolveAutoDetectTarget(eventId, cwd);
break;

default: {
const _exhaustiveCheck: never = parsed;
target = null;
break;
}
}

default:
return null;
if (target) {
setOrgProjectContext([target.org], [target.project]);
}
return target;
}

/**
Expand Down
54 changes: 53 additions & 1 deletion packages/cli/test/commands/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

import { writeFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as Sentry from "@sentry/node-core/light";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
apiCommand,
buildBodyFromFields,
Expand All @@ -27,6 +29,7 @@ import {
parseFieldKey,
parseFields,
parseHeaders,
parseOrgProjectFromApiPath,
prepareRequestOptions,
readStdin,
resolveApiResponseOutput,
Expand Down Expand Up @@ -108,6 +111,55 @@ describe("normalizeEndpoint: api/0/ prefix stripping (CLI-K1)", () => {
});
});

describe("parseOrgProjectFromApiPath", () => {
test.each([
["organizations/acme/issues/", { org: "acme" }],
["projects/acme/web/events/", { org: "acme", project: "web" }],
["teams/acme/engineering/", { org: "acme" }],
["organizations/", undefined],
["organizations/{organization_id_or_slug}/issues/", undefined],
["issues/", undefined],
] as const)("%s", (path, expected) => {
expect(parseOrgProjectFromApiPath(path)).toEqual(expected);
});
});

describe("apiCommand tags org from the path", () => {
let setTagSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
setTagSpy = vi.spyOn(Sentry, "setTag");
});

afterEach(() => {
setTagSpy.mockRestore();
});

test("dry-run of a projects path sets sentry.org and sentry.project", async () => {
const func = await apiCommand.loader();
const context = {
stdin: createMockStdin(""),
stdout: { write: vi.fn(() => true), isTTY: false },
stderr: { write: vi.fn(() => true) },
cwd: "/tmp",
};
await func.call(
context,
{
method: "GET",
silent: false,
verbose: false,
"dry-run": true,
json: false,
},
"projects/acme/web/events/"
);

expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme");
expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "web");
});
});

describe("normalizeEndpoint: path traversal hardening (#350)", () => {
test("rejects bare .. traversal", () => {
expect(() => normalizeEndpoint("..")).toThrow(/path traversal/);
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/commands/event/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* and viewCommand func() body in src/commands/event/view.ts
*/

// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as Sentry from "@sentry/node-core/light";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
collectEventIds,
Expand Down Expand Up @@ -729,12 +731,14 @@ describe("resolveEventTarget", () => {
let findEventAcrossOrgsSpy: ReturnType<typeof spyOn>;
let resolveOrgAndProjectSpy: ReturnType<typeof spyOn>;
let resolveProjectBySlugSpy: ReturnType<typeof spyOn>;
let setTagSpy: ReturnType<typeof spyOn>;

beforeEach(async () => {
resolveEventInOrgSpy = vi.spyOn(apiClient, "resolveEventInOrg");
findEventAcrossOrgsSpy = vi.spyOn(apiClient, "findEventAcrossOrgs");
resolveOrgAndProjectSpy = vi.spyOn(resolveTarget, "resolveOrgAndProject");
resolveProjectBySlugSpy = vi.spyOn(resolveTarget, "resolveProjectBySlug");
setTagSpy = vi.spyOn(Sentry, "setTag");
setOrgRegion("acme", DEFAULT_SENTRY_URL);
});

Expand All @@ -743,6 +747,7 @@ describe("resolveEventTarget", () => {
findEventAcrossOrgsSpy.mockRestore();
resolveOrgAndProjectSpy.mockRestore();
resolveProjectBySlugSpy.mockRestore();
setTagSpy.mockRestore();
});

test("returns explicit target directly", async () => {
Expand Down Expand Up @@ -803,6 +808,8 @@ describe("resolveEventTarget", () => {
expect(result?.org).toBe("acme");
expect(result?.project).toBe("backend");
expect(result?.prefetchedEvent).toBeDefined();
expect(setTagSpy).toHaveBeenCalledWith("sentry.org", "acme");
expect(setTagSpy).toHaveBeenCalledWith("sentry.project", "backend");
});

test("delegates AutoDetect to resolveAutoDetectTarget", async () => {
Expand Down
Loading