Skip to content
Open
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
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512)
- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608)
- Handle rename DORA to DORA_2022_2554 to follow the naming <name>_<version> in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551)
- UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures [(#11665)](https://github.com/prowler-cloud/prowler/pull/11665)

### 🔐 Security

Expand Down
24 changes: 7 additions & 17 deletions ui/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
startProgress,
} from "@/components/ui/navigation-progress/use-navigation-progress";
import { getRuntimeConfigClient } from "@/lib/get-runtime-config.client";
import {
applySentryEventPolicy,
SENTRY_EVENT_SOURCE,
} from "@/sentry/event-policy";

export const NAVIGATION_TYPE = {
PUSH: "push",
Expand Down Expand Up @@ -96,25 +100,9 @@ if (typeof window !== "undefined" && sentryDsn) {
],

beforeSend(event, hint) {
// Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
if (event.message?.includes("ResizeObserver")) {
return null; // Don't send to Sentry
}

// Filter out non-actionable errors
if (event.exception) {
const error = hint.originalException;

// Don't send cancelled requests
if (
error &&
typeof error === "object" &&
"name" in error &&
error.name === "AbortError"
) {
return null;
}

// Add additional context for API errors
if (
error &&
Expand All @@ -130,7 +118,9 @@ if (typeof window !== "undefined" && sentryDsn) {
}
}

return event; // Send to Sentry
return applySentryEventPolicy(event, hint, {
source: SENTRY_EVENT_SOURCE.CLIENT,
});
},
});

Expand Down
239 changes: 211 additions & 28 deletions ui/lib/server-actions-helper.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
import {
isErrorAlreadyReported,
markErrorAsReported,
} from "@/sentry/event-policy";

import { handleApiError, handleApiResponse } from "./server-actions-helper";

vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
captureException: vi.fn(),
captureMessage: vi.fn(),
}));

vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
revalidatePath: vi.fn(),
}));

vi.mock("./helper", () => ({
vi.mock("@/lib/helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
getErrorMessage: (error: unknown) =>
Expand All @@ -26,29 +27,211 @@ vi.mock("./helper", () => ({
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
}));

import { handleApiResponse } from "./server-actions-helper";

describe("server action error handling", () => {
describe("server-actions-helper", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});

it("throws a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
afterEach(() => {
vi.restoreAllMocks();
});

describe("handleApiResponse", () => {
it("should throw a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
);

// When / Then
await expect(handleApiResponse(response)).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});

it("should not capture controlled 4xx API validation responses", async () => {
// Given
const response = new Response(
JSON.stringify({ errors: [{ detail: "Provider already exists" }] }),
{ status: 409, statusText: "Conflict" },
);

// When
const result = await handleApiResponse(response);

// Then
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(result).toMatchObject({
error: "Provider already exists",
status: 409,
});
});

it.each([418, 429])(
"should capture unexpected %s API client failures before returning them",
async (status) => {
// Given
const response = new Response(
JSON.stringify({ message: "Unexpected API contract failure" }),
{ status, statusText: "Unexpected Client Failure" },
);

// When
const result = await handleApiResponse(response);

// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
api_error: true,
error_source: "handleApiResponse",
error_type: "client_error",
status_code: status.toString(),
}),
}),
);
expect(result).toMatchObject({
error: "Unexpected API contract failure",
status,
});
},
);

// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
it("should capture and mark server errors before throwing", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "backend down" }),
{
status: 500,
statusText: "Internal Server Error",
},
);

// When
await expect(handleApiResponse(response)).rejects.toThrow("backend down");

// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
const capturedError = vi.mocked(Sentry.captureException).mock
.calls[0]?.[0];
expect(isErrorAlreadyReported(capturedError)).toBe(true);
});

it("should fingerprint server errors by pathname without query string", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "backend down" }),
{
status: 500,
statusText: "Internal Server Error",
},
);
Object.defineProperty(response, "url", {
value: "https://api.prowler.test/api/v1/providers?tenant=123",
});

// When
await expect(handleApiResponse(response)).rejects.toThrow("backend down");

// Then
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
fingerprint: ["api-server-error", "500", "/api/v1/providers"],
}),
);
});

it("should fingerprint unexpected client failures by pathname without query string", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "Unexpected API contract failure" }),
{ status: 429, statusText: "Too Many Requests" },
);
Object.defineProperty(response, "url", {
value: "https://api.prowler.test/api/v1/scans?page=2",
});

// When
await handleApiResponse(response);

// Then
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
fingerprint: ["api-client-contract-error", "429", "/api/v1/scans"],
}),
);
});
});

describe("handleApiError", () => {
it("should not recapture errors that were already reported", () => {
// Given
const error = new Error("Already reported failure");
vi.spyOn(console, "error").mockImplementation(() => undefined);
markErrorAsReported(error);

// When
const result = handleApiError(error);

// Then
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(result).toEqual({ error: "Already reported failure" });
});

it("should capture unmarked request failure errors", () => {
// Given
const error = new Error("Request failed unexpectedly");
vi.spyOn(console, "error").mockImplementation(() => undefined);

// When
const result = handleApiError(error);

// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
error_source: "handleApiError",
error_type: "unexpected_error",
}),
}),
);
expect(isErrorAlreadyReported(error)).toBe(true);
expect(result).toEqual({ error: "Request failed unexpectedly" });
});

it("should capture unmarked runtime errors that include expected HTTP status numbers", () => {
// Given
const error = new Error("Runtime worker 401 crashed unexpectedly");
vi.spyOn(console, "error").mockImplementation(() => undefined);

// When
const result = handleApiError(error);

// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
error_source: "handleApiError",
error_type: "unexpected_error",
}),
}),
);
expect(result).toEqual({
error: "Runtime worker 401 crashed unexpectedly",
});
});
});
});
Loading
Loading