- onNodeClick(
+ onNodeClick?.(
data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
id: "finding",
labels: ["ProwlerFinding"],
@@ -67,6 +67,29 @@ describe("IssueGraph finding lookup", () => {
navigateToFinding.mockResolvedValue(undefined);
});
+ it("leaves findings closed in a read-only graph", async () => {
+ // Given: a host that cannot hold a second detail view, such as the panel
+ render(
+ ,
+ );
+
+ // When
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+
+ // Then
+ expect(navigateToFinding).not.toHaveBeenCalled();
+ expect(getFindings).not.toHaveBeenCalled();
+ expect(
+ screen.getByText(/Open the full path to inspect a finding/),
+ ).toBeInTheDocument();
+ });
+
it("opens a folded finding by id without another lookup", async () => {
// Given
render(
diff --git a/ui/components/issues/issue-graph.tsx b/ui/components/issues/issue-graph.tsx
index 6d9a424c67..57752f6f17 100644
--- a/ui/components/issues/issue-graph.tsx
+++ b/ui/components/issues/issue-graph.tsx
@@ -21,6 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
+ /**
+ * No finding drawer on click: for hosts inside the side panel, where a
+ * second detail view would replace the one holding the graph.
+ */
+ readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,6 +51,7 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
+ readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
@@ -139,6 +145,7 @@ export const IssueGraph = ({
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths.
+ {readOnly && " Open the full path to inspect a finding."}
{loading && (
@@ -162,7 +169,7 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
- onNodeClick={handleNodeClick}
+ onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
diff --git a/ui/components/issues/issue-panel.tsx b/ui/components/issues/issue-panel.tsx
index 84624f45b9..73a61f6d1d 100644
--- a/ui/components/issues/issue-panel.tsx
+++ b/ui/components/issues/issue-panel.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
+import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueTriage } from "@/components/issues/issue-triage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
@@ -38,6 +39,7 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
+ const providerId = issue.relationships?.provider?.data?.id;
return (
{
Exploitability
+ Attack path
Remediation
Triage
@@ -124,6 +127,17 @@ export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
+ {/* Mounted only while selected, so the graph's requests run when
+ someone asks for it. */}
+
+
+
+
{attributes.fix}
diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx
index 4694496fdd..6f2d9b4556 100644
--- a/ui/components/issues/issues-table.test.tsx
+++ b/ui/components/issues/issues-table.test.tsx
@@ -7,7 +7,10 @@ import type { IssueResponse } from "@/types/issues";
import { IssuesTable } from "./issues-table";
-const { push } = vi.hoisted(() => ({ push: vi.fn() }));
+const { push, getIssue } = vi.hoisted(() => ({
+ push: vi.fn(),
+ getIssue: vi.fn(),
+}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ push, refresh: vi.fn() }),
@@ -15,7 +18,25 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
-vi.mock("@/actions/issues", () => ({ getIssueNotes: async () => [] }));
+vi.mock("@/actions/issues", () => ({
+ getIssue,
+ getIssueFindings: async () => [],
+ getIssueNotes: async () => [],
+}));
+vi.mock("@/components/issues/issue-graph", () => ({
+ IssueGraph: ({
+ resourceUids,
+ readOnly,
+ }: {
+ resourceUids: string[];
+ readOnly?: boolean;
+ }) => (
+
+ Graph of {resourceUids.join(", ")}
+ {readOnly ? " (read-only)" : ""}
+
+ ),
+}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
@@ -143,6 +164,21 @@ describe("IssuesTable", () => {
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
+ // When
+ getIssue.mockResolvedValue({
+ data: {
+ ...issue,
+ attributes: { ...issue.attributes, resource_uids: ["bucket"] },
+ },
+ });
+ await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
+
+ // Then: the graph loads on demand and leaves finding drill-down to the page
+ expect(
+ await within(panel).findByText("Graph of bucket (read-only)"),
+ ).toBeInTheDocument();
+ expect(getIssue).toHaveBeenCalledWith("issue-1");
+
// When
await user.click(within(panel).getByRole("tab", { name: "Triage" }));
diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx
index 7d3057b06f..d0a635c03c 100644
--- a/ui/components/issues/issues.integration.test.tsx
+++ b/ui/components/issues/issues.integration.test.tsx
@@ -257,6 +257,17 @@ describe("Issues responsive layout", () => {
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
+
+ // When: the attack path tab asks for the graph
+ await screen.getByRole("tab", { name: "Attack path" }).click();
+
+ // Then
+ await expect
+ .element(screen.getByRole("region", { name: "Finding evidence" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByTestId("attack-path-finding-node"))
+ .toBeVisible();
});
it("resets the Details tab when another row opens", async () => {
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
index d6e9773121..a0552ed931 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
@@ -130,10 +130,9 @@ describe("AppSidebarContent", () => {
render( );
// Then
- expect(screen.getByRole("link", { name: /Issues/ })).toHaveAttribute(
- "href",
- "/issues",
- );
+ expect(
+ screen.getByRole("link", { name: /Exploitability issues/ }),
+ ).toHaveAttribute("href", "/issues");
});
it("hides Issues navigation in Cloud while the feature flag is off", () => {
@@ -143,7 +142,7 @@ describe("AppSidebarContent", () => {
// Then
expect(
- screen.queryByRole("link", { name: /Issues/ }),
+ screen.queryByRole("link", { name: /Exploitability issues/ }),
).not.toBeInTheDocument();
});
diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts
index 627929d8cb..d9be858bcb 100644
--- a/ui/components/layout/app-sidebar/navigation-config.test.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.test.ts
@@ -119,7 +119,7 @@ describe("getNavigationConfig", () => {
.map((item) => item.label);
// Then
- expect(labels).not.toContain("Issues");
+ expect(labels).not.toContain("Exploitability issues");
});
it("never shows Issues in Local Server, even with the flag on", () => {
@@ -136,7 +136,7 @@ describe("getNavigationConfig", () => {
.map((item) => item.label);
// Then
- expect(labels).not.toContain("Issues");
+ expect(labels).not.toContain("Exploitability issues");
});
it("uses Cloud destinations and keeps Configuration children unbadged", () => {
@@ -158,7 +158,9 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
- expect(items.find((item) => item.label === "Issues")).toMatchObject({
+ expect(
+ items.find((item) => item.label === "Exploitability issues"),
+ ).toMatchObject({
href: "/issues",
});
expect(configuration.children).not.toEqual(
diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts
index fe4a114f60..ff1c881b12 100644
--- a/ui/components/layout/app-sidebar/navigation-config.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.ts
@@ -168,7 +168,7 @@ export function getNavigationConfig({
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
- label: "Issues",
+ label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
diff --git a/ui/components/shared/feature-flag-route.test.tsx b/ui/components/shared/feature-flag-route.test.tsx
new file mode 100644
index 0000000000..a3049529de
--- /dev/null
+++ b/ui/components/shared/feature-flag-route.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { useFeatureFlagStateMock, redirect } = vi.hoisted(() => ({
+ useFeatureFlagStateMock: vi.fn(),
+ redirect: vi.fn(() => {
+ throw new Error("REDIRECT");
+ }),
+}));
+
+vi.mock("next/navigation", () => ({ redirect }));
+vi.mock("@/lib/feature-flags", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFeatureFlagState: useFeatureFlagStateMock,
+}));
+
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "@/lib/feature-flags";
+
+import { FeatureFlagRoute } from "./feature-flag-route";
+
+const renderRoute = () =>
+ render(
+
+ route content
+ ,
+ );
+
+describe("FeatureFlagRoute", () => {
+ beforeEach(() => {
+ useFeatureFlagStateMock.mockReset();
+ redirect.mockClear();
+ });
+
+ it("sends the user to the overview once the flag resolves off", () => {
+ // Given
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ // When / Then
+ expect(renderRoute).toThrow("REDIRECT");
+ expect(redirect).toHaveBeenCalledWith("/");
+ });
+
+ it("renders the route once the flag resolves on", () => {
+ // Given
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.ON);
+
+ // When
+ renderRoute();
+
+ // Then
+ expect(screen.getByText("route content")).toBeInTheDocument();
+ expect(redirect).not.toHaveBeenCalled();
+ });
+
+ it("renders nothing while the flag is unresolved", () => {
+ // Given
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.PENDING);
+
+ // When
+ const { container } = renderRoute();
+
+ // Then
+ expect(container).toBeEmptyDOMElement();
+ expect(redirect).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/components/shared/feature-flag-route.tsx b/ui/components/shared/feature-flag-route.tsx
new file mode 100644
index 0000000000..8a66f75b91
--- /dev/null
+++ b/ui/components/shared/feature-flag-route.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { redirect } from "next/navigation";
+import type { ReactNode } from "react";
+
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import type { FeatureFlag } from "@/lib/feature-flags";
+
+const ToOverview = (): never => redirect("/");
+
+interface FeatureFlagRouteProps {
+ flag: FeatureFlag;
+ children: ReactNode;
+}
+
+// A route behind a flag: off sends the user to the overview, pending renders
+// nothing. Same client evaluation as the menu entry, so the link and the
+// route never disagree.
+export function FeatureFlagRoute({ flag, children }: FeatureFlagRouteProps) {
+ return (
+ }>
+ {children}
+
+ );
+}
diff --git a/ui/hooks/use-issue-evidence.ts b/ui/hooks/use-issue-evidence.ts
new file mode 100644
index 0000000000..b4e5073ab1
--- /dev/null
+++ b/ui/hooks/use-issue-evidence.ts
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { getIssue, getIssueFindings } from "@/actions/issues";
+import { ISSUE_FINDINGS_LIMIT, IssueFinding } from "@/types/issues";
+
+export type IssueEvidenceState =
+ | { status: "loading" }
+ | { status: "error" }
+ | {
+ status: "loaded";
+ resources: string[];
+ findings: IssueFinding[] | undefined;
+ /** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
+ findingsTruncated: boolean;
+ scanId?: string;
+ providerId?: string;
+ };
+
+/**
+ * The issue's resources and findings. Only the detail endpoint carries the
+ * resource uids, so this fetches it, then the findings of the issue's scan.
+ */
+export const useIssueEvidence = (
+ id: string,
+ scanId?: string,
+ providerId?: string,
+) => {
+ const [attempt, setAttempt] = useState(0);
+ const [state, setState] = useState({
+ status: "loading",
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+ getIssue(id)
+ .then(async (detail) => {
+ if (cancelled) return;
+ const attributes = detail?.data?.attributes;
+ if (!attributes?.resource_uids) {
+ setState({ status: "error" });
+ return;
+ }
+ const detailScanId =
+ detail?.data.relationships?.scan?.data?.id ?? scanId;
+ const detailProviderId =
+ detail?.data.relationships?.provider?.data?.id ?? providerId;
+ const findingUids = attributes.finding_uids ?? [];
+ const findings = detailScanId
+ ? await getIssueFindings(detailScanId, findingUids)
+ : findingUids.length === 0
+ ? []
+ : undefined;
+ if (!cancelled)
+ setState({
+ status: "loaded",
+ resources: attributes.resource_uids,
+ findings,
+ findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
+ scanId: detailScanId,
+ providerId: detailProviderId,
+ });
+ })
+ .catch(() => {
+ if (!cancelled) setState({ status: "error" });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [id, scanId, providerId, attempt]);
+
+ const retry = () => {
+ setState({ status: "loading" });
+ setAttempt((value) => value + 1);
+ };
+
+ return { state, retry };
+};
diff --git a/ui/lib/feature-flags/client.test.ts b/ui/lib/feature-flags/client.test.ts
index 3591a68a5c..ef551a7104 100644
--- a/ui/lib/feature-flags/client.test.ts
+++ b/ui/lib/feature-flags/client.test.ts
@@ -1,13 +1,17 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({
+const { useFeatureFlagEnabledMock, runtimeConfig } = vi.hoisted(() => ({
useFeatureFlagEnabledMock: vi.fn(),
+ runtimeConfig: { posthogKey: "phc_key" as string | null },
}));
vi.mock("posthog-js/react", () => ({
useFeatureFlagEnabled: useFeatureFlagEnabledMock,
}));
+vi.mock("@/hooks/use-runtime-config", () => ({
+ useRuntimeConfig: () => runtimeConfig,
+}));
import { useFeatureFlag, useFeatureFlagState } from "./client";
import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
@@ -15,6 +19,7 @@ import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
describe("useFeatureFlagState", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
+ runtimeConfig.posthogKey = "phc_key";
});
it("should pass the flag key through to PostHog", () => {
@@ -29,7 +34,7 @@ describe("useFeatureFlagState", () => {
["on", true, FEATURE_FLAG_STATE.ON],
["off", false, FEATURE_FLAG_STATE.OFF],
[
- "pending (not initialized or still loading)",
+ "pending while PostHog loads flags",
undefined,
FEATURE_FLAG_STATE.PENDING,
],
@@ -44,6 +49,39 @@ describe("useFeatureFlagState", () => {
});
});
+describe("useFeatureFlagState without PostHog", () => {
+ beforeEach(() => {
+ useFeatureFlagEnabledMock.mockReset();
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+ });
+
+ it("should report off once mounted when no PostHog key is configured", () => {
+ // Given: nothing will ever init PostHog (OSS, or PostHog disabled)
+ runtimeConfig.posthogKey = null;
+
+ // When
+ const { result } = renderHook(() =>
+ useFeatureFlagState(FEATURE_FLAG.ISSUES),
+ );
+
+ // Then: a flag nobody can turn on is off, not pending forever
+ expect(result.current).toBe(FEATURE_FLAG_STATE.OFF);
+ });
+
+ it("should keep waiting while a configured PostHog has not loaded yet", () => {
+ // Given: a key is set, so some client will init PostHog, maybe lazily
+ runtimeConfig.posthogKey = "phc_key";
+
+ // When
+ const { result } = renderHook(() =>
+ useFeatureFlagState(FEATURE_FLAG.ISSUES),
+ );
+
+ // Then
+ expect(result.current).toBe(FEATURE_FLAG_STATE.PENDING);
+ });
+});
+
describe("useFeatureFlag", () => {
beforeEach(() => {
useFeatureFlagEnabledMock.mockReset();
diff --git a/ui/lib/feature-flags/client.ts b/ui/lib/feature-flags/client.ts
index 2c737d1f18..cd6b026dbb 100644
--- a/ui/lib/feature-flags/client.ts
+++ b/ui/lib/feature-flags/client.ts
@@ -1,6 +1,10 @@
"use client";
import { useFeatureFlagEnabled } from "posthog-js/react";
+import { useState } from "react";
+
+import { useMountEffect } from "@/hooks/use-mount-effect";
+import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import {
FEATURE_FLAG_STATE,
@@ -15,11 +19,21 @@ import {
// OSS builds that never init PostHog resolve every read to its fallback.
// Boolean flag with its resolution state. PostHog reports `undefined` until
-// the flags request lands (or forever when it was never initialized), `true`
-// or `false` afterwards; a flag missing from the project resolves to OFF.
+// the flags request lands, `true` or `false` afterwards; a flag missing from
+// the project resolves to OFF. Every PostHog init (the cloud root, the lazy
+// feedback survey) needs the runtime key, so without one, once mounted (the
+// first hydration render still sees the empty config), nothing will ever
+// load flags: the flag is OFF instead of pending forever.
export function useFeatureFlagState(flag: FeatureFlag): FeatureFlagState {
const value = useFeatureFlagEnabled(flag);
- if (value === undefined) return FEATURE_FLAG_STATE.PENDING;
+ const { posthogKey } = useRuntimeConfig();
+ const [mounted, setMounted] = useState(false);
+ useMountEffect(() => setMounted(true));
+ if (value === undefined) {
+ return mounted && !posthogKey
+ ? FEATURE_FLAG_STATE.OFF
+ : FEATURE_FLAG_STATE.PENDING;
+ }
return value ? FEATURE_FLAG_STATE.ON : FEATURE_FLAG_STATE.OFF;
}
diff --git a/ui/lib/feature-flags/shared.ts b/ui/lib/feature-flags/shared.ts
index eb7e11684f..adfd9d0e97 100644
--- a/ui/lib/feature-flags/shared.ts
+++ b/ui/lib/feature-flags/shared.ts
@@ -16,9 +16,9 @@ export const FEATURE_FLAG = {
export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
-// Resolution state of a boolean flag. PENDING covers both "flags still
-// loading" and "PostHog never initialized" (OSS builds): neither case can
-// tell whether the flag is on, so gates render neither branch yet.
+// Resolution state of a boolean flag. PENDING means PostHog is still loading
+// flags, so gates render neither branch yet. Without a PostHog key nothing
+// initializes it, so the flag resolves to OFF once mounted.
export const FEATURE_FLAG_STATE = {
PENDING: "pending",
ON: "on",