mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 07:44:21 +00:00
chore(ui): merge master into expired-session fix
This commit is contained in:
568 files changed
+23697
-2930
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { AuthForm } from "@/components/auth/oss";
|
||||
import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper";
|
||||
import { isGoogleOAuthEnabled } from "@/lib/helper";
|
||||
import {
|
||||
getAuthUrl,
|
||||
isGithubOAuthEnabled,
|
||||
isGoogleOAuthEnabled,
|
||||
} from "@/lib/helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
const SignUp = async ({
|
||||
@@ -13,7 +17,7 @@ const SignUp = async ({
|
||||
typeof resolvedSearchParams?.invitation_token === "string"
|
||||
? resolvedSearchParams.invitation_token
|
||||
: null;
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const isCloudEnv = isCloud();
|
||||
|
||||
const GOOGLE_AUTH_URL = getAuthUrl("google");
|
||||
const GITHUB_AUTH_URL = getAuthUrl("github");
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
|
||||
import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
|
||||
|
||||
describe("LighthouseOverviewBanner", () => {
|
||||
it("renders Toni copy and opens a prompted chat when connected", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
|
||||
expect(link).toHaveTextContent("Lighthouse AI");
|
||||
expect(link).toHaveTextContent("Find and remediate what actually matters.");
|
||||
});
|
||||
|
||||
it("links to Lighthouse settings when no connected configuration exists", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseOverviewBanner
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
}),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
});
|
||||
|
||||
it("isolates its stacking so content never paints over the sticky navbar", () => {
|
||||
// Given / When: the banner's inner z-10 must stay scoped to the card —
|
||||
// without isolation it ties the sticky header's z-10 and wins by DOM order
|
||||
render(
|
||||
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen
|
||||
.getByRole("link", { name: /Find and remediate what actually matters\./ })
|
||||
.querySelector("[data-slot='card']");
|
||||
expect(card).toHaveClass("isolate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "../_lib/overview-banner";
|
||||
|
||||
import { OverviewBanner } from "./overview-banner";
|
||||
|
||||
describe("OverviewBanner", () => {
|
||||
it("renders Toni copy and opens a prompted chat when connected", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT);
|
||||
expect(link).toHaveTextContent("Lighthouse AI");
|
||||
expect(link).toHaveTextContent("Find and remediate what actually matters.");
|
||||
});
|
||||
|
||||
it("links to Lighthouse settings when no connected configuration exists", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "/lighthouse/settings");
|
||||
// In-app hrefs stay in the current tab
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
});
|
||||
|
||||
it("opens the AI agents docs in a new tab", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Connect all your agents to Prowler Cloud/,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", DOCS_URLS.AI_AGENTS);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link).toHaveTextContent(
|
||||
"Turn your favorite agent into a Cloud Security Expert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the purple agents palette with the same animated layers", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Connect all your agents to Prowler Cloud/,
|
||||
});
|
||||
const gradient = link.querySelector(".overview-banner-gradient");
|
||||
expect(gradient).toHaveClass("overview-banner-gradient-agents");
|
||||
expect(gradient?.querySelector(".animate-first")).toBeInTheDocument();
|
||||
expect(gradient?.querySelector(".animate-second")).toBeInTheDocument();
|
||||
expect(gradient?.querySelector(".animate-third")).toBeInTheDocument();
|
||||
expect(
|
||||
gradient?.querySelector(".overview-banner-gradient-primary-press"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Lighthouse banner on the default green palette", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", {
|
||||
name: /Find and remediate what actually matters\./,
|
||||
});
|
||||
const gradient = link.querySelector(".overview-banner-gradient");
|
||||
expect(gradient).not.toHaveClass("overview-banner-gradient-agents");
|
||||
});
|
||||
|
||||
it("scopes the blur filter id per instance so stacked banners keep their gradient", () => {
|
||||
// Given / When: url(#id) resolves against the FIRST match in the document,
|
||||
// so two banners sharing one id would both resolve to the same filter
|
||||
const { container } = render(
|
||||
<>
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const filterIds = Array.from(
|
||||
container.querySelectorAll("filter"),
|
||||
(filter) => filter.id,
|
||||
);
|
||||
expect(filterIds).toHaveLength(2);
|
||||
expect(new Set(filterIds).size).toBe(2);
|
||||
});
|
||||
|
||||
it("isolates its stacking so content never paints over the sticky navbar", () => {
|
||||
// Given / When: the banner's inner z-10 must stay scoped to the card —
|
||||
// without isolation it ties the sticky header's z-10 and wins by DOM order
|
||||
render(
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen
|
||||
.getByRole("link", { name: /Find and remediate what actually matters\./ })
|
||||
.querySelector("[data-slot='card']");
|
||||
expect(card).toHaveClass("isolate");
|
||||
});
|
||||
});
|
||||
+58
-21
@@ -1,29 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ArrowRight, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { type ReactNode, useId, useRef, useState } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner";
|
||||
import {
|
||||
OVERVIEW_BANNER_VARIANT,
|
||||
type OverviewBannerVariant,
|
||||
} from "../_lib/overview-banner";
|
||||
|
||||
interface LighthouseOverviewBannerProps {
|
||||
href: LighthouseOverviewBannerHref;
|
||||
// Content lives here rather than at the call site so the icons stay inside the
|
||||
// client boundary — LighthouseIcon uses useId and cannot render on the server.
|
||||
const OVERVIEW_BANNER_CONTENT = {
|
||||
lighthouse: {
|
||||
icon: <LighthouseIcon className="size-5" />,
|
||||
title: "Lighthouse AI",
|
||||
description: "Find and remediate what actually matters.",
|
||||
},
|
||||
agents: {
|
||||
icon: <Bot className="size-5" />,
|
||||
title: "Connect all your agents to Prowler Cloud",
|
||||
description: "Turn your favorite agent into a Cloud Security Expert.",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
OverviewBannerVariant,
|
||||
{ icon: ReactNode; title: string; description: string }
|
||||
>;
|
||||
|
||||
interface OverviewBannerProps {
|
||||
variant: OverviewBannerVariant;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function LighthouseOverviewBanner({
|
||||
href,
|
||||
}: LighthouseOverviewBannerProps) {
|
||||
export function OverviewBanner({ variant, href }: OverviewBannerProps) {
|
||||
const { icon, title, description } = OVERVIEW_BANNER_CONTENT[variant];
|
||||
// Absolute hrefs leave the app, so they open in a new tab.
|
||||
const isExternal = href.startsWith("http");
|
||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||
const curXRef = useRef(0);
|
||||
const curYRef = useRef(0);
|
||||
const tgXRef = useRef(0);
|
||||
const tgYRef = useRef(0);
|
||||
const [isSafari, setIsSafari] = useState(false);
|
||||
// Several banners render per page and url(#id) resolves against the FIRST
|
||||
// matching id in the document, so the filter id must be per-instance.
|
||||
const blurFilterId = `overview-banner-blur-${useId().replace(/[«»:]/g, "")}`;
|
||||
|
||||
useMountEffect(() => {
|
||||
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
|
||||
@@ -61,19 +87,22 @@ export function LighthouseOverviewBanner({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group focus-visible:ring-border-input-primary block rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: undefined)}
|
||||
className="group focus-visible:ring-border-input-primary block h-full rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
// isolate: the content's internal z-10 (above the gradient layers)
|
||||
// must not compete with the page's sticky header, which is also z-10.
|
||||
className="group-hover:border-border-input-primary relative isolate overflow-hidden transition-colors"
|
||||
className="group-hover:border-border-input-primary relative isolate h-full overflow-hidden transition-colors"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
<filter id={blurFilterId}>
|
||||
<feGaussianBlur
|
||||
in="SourceGraphic"
|
||||
stdDeviation="10"
|
||||
@@ -92,33 +121,41 @@ export function LighthouseOverviewBanner({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 blur-lg",
|
||||
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
|
||||
"overview-banner-gradient pointer-events-none absolute inset-0 blur-lg",
|
||||
variant === OVERVIEW_BANNER_VARIANT.AGENTS
|
||||
? "overview-banner-gradient-agents"
|
||||
: undefined,
|
||||
isSafari ? "blur-2xl" : undefined,
|
||||
)}
|
||||
style={
|
||||
isSafari
|
||||
? undefined
|
||||
: { filter: `url(#${blurFilterId}) blur(40px)` }
|
||||
}
|
||||
>
|
||||
<div className="animate-first lighthouse-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-first overview-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div className="animate-second lighthouse-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-second overview-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div className="animate-third lighthouse-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
|
||||
<div className="animate-third overview-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
|
||||
|
||||
<div
|
||||
ref={interactiveRef}
|
||||
className="lighthouse-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
|
||||
className="overview-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="relative z-10 flex min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
|
||||
<CardContent className="relative z-10 flex h-full min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
|
||||
<LighthouseIcon className="size-5" />
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-text-neutral-primary text-sm font-medium">
|
||||
Lighthouse AI
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Find and remediate what actually matters.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const OVERVIEW_BANNER_VARIANT = {
|
||||
LIGHTHOUSE: "lighthouse",
|
||||
AGENTS: "agents",
|
||||
} as const;
|
||||
|
||||
export type OverviewBannerVariant =
|
||||
(typeof OVERVIEW_BANNER_VARIANT)[keyof typeof OVERVIEW_BANNER_VARIANT];
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { AttackSurface } from "./_components/attack-surface";
|
||||
|
||||
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
filterProvidersByScope,
|
||||
parseFilterIds,
|
||||
} from "../../_lib/provider-scope";
|
||||
|
||||
import { RiskPlotClient } from "./risk-plot-client";
|
||||
|
||||
export async function RiskPlotSSR({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
|
||||
import { RiskRadarViewClient } from "./risk-radar-view-client";
|
||||
|
||||
export async function RiskRadarViewSSR({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ResourcesInventory } from "./_components/resources-inventory";
|
||||
|
||||
export const ResourcesInventorySSR = async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { RiskSeverityChart } from "./_components/risk-severity-chart";
|
||||
|
||||
export const RiskSeverityChartSSR = async ({
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/types/severities";
|
||||
|
||||
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
|
||||
|
||||
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
|
||||
|
||||
interface FindingSeverityOverTimeProps {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
|
||||
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
|
||||
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ThreatScore } from "./_components/threat-score";
|
||||
|
||||
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
|
||||
|
||||
export const ComplianceWatchlistSSR = async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ServiceWatchlist } from "./_components/service-watchlist";
|
||||
|
||||
export const ServiceWatchlistSSR = async ({
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
}));
|
||||
|
||||
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
|
||||
|
||||
import {
|
||||
createAlert,
|
||||
deleteAlert,
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Button, useToast } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import type { MetaDataProps } from "@/types";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { MetaDataProps, ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { toAlertPayload } from "../_lib/alert-adapter";
|
||||
@@ -29,6 +27,7 @@ import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
|
||||
import { AlertFormModal } from "./alert-form-modal";
|
||||
import { AlertsEmptyState } from "./alerts-empty-state";
|
||||
import { AlertsTable } from "./alerts-table";
|
||||
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
ToastAction,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { ToastAction, useToast } from "@/components/shadcn";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
|
||||
|
||||
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
|
||||
|
||||
describe("AttackPathsStatusPanel", () => {
|
||||
|
||||
+5
-3
@@ -31,9 +31,11 @@ import {
|
||||
getNodeColor,
|
||||
getPathEdges,
|
||||
GRAPH_EDGE_HIGHLIGHT_COLOR,
|
||||
isProwlerFindingNode,
|
||||
resolveHiddenFindingIds,
|
||||
} from "../../_lib";
|
||||
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
|
||||
import { layoutWithDagre } from "../../_lib/layout";
|
||||
|
||||
import { FindingNode } from "./nodes/finding-node";
|
||||
import { InternetNode } from "./nodes/internet-node";
|
||||
import { ResourceNode } from "./nodes/resource-node";
|
||||
@@ -387,7 +389,7 @@ const GraphCanvas = ({
|
||||
const findingToResources = new Map<string, Set<string>>();
|
||||
|
||||
nodes.forEach((n) => {
|
||||
if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
|
||||
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
|
||||
});
|
||||
|
||||
const resourcesWithFindings = new Set<string>();
|
||||
@@ -459,7 +461,7 @@ const GraphCanvas = ({
|
||||
hidden: hiddenFindingIds.has(node.id),
|
||||
className: cn(
|
||||
node.className,
|
||||
isFindingNode(node.data.graphNode.labels) ||
|
||||
isProwlerFindingNode(node.data.graphNode.labels) ||
|
||||
resourcesWithFindings.has(node.id)
|
||||
? "cursor-pointer"
|
||||
: "cursor-default",
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ import {
|
||||
GRAPH_NODE_COLORS,
|
||||
} from "../../_lib/graph-colors";
|
||||
import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
|
||||
import { isProwlerFindingNode } from "../../_lib/node-types";
|
||||
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
|
||||
|
||||
const LEGEND_PREVIEW = {
|
||||
@@ -197,7 +198,7 @@ const edgeItems: LegendEdgeItem[] = [
|
||||
];
|
||||
|
||||
const isFindingNode = (node: GraphNode): boolean =>
|
||||
node.labels.some((label) => label.toLowerCase().includes("finding"));
|
||||
isProwlerFindingNode(node.labels);
|
||||
|
||||
const getGraphEdges = (
|
||||
data: AttackPathGraphData,
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
|
||||
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
|
||||
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface FindingNodeData {
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors } from "../../../_lib";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface InternetNodeData {
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
|
||||
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
|
||||
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface ResourceNodeData {
|
||||
|
||||
+20
@@ -50,6 +50,15 @@ const resourceNode: GraphNode = {
|
||||
},
|
||||
};
|
||||
|
||||
const guardDutyNode: GraphNode = {
|
||||
id: "guard-duty-node-id",
|
||||
labels: ["GuardDutyFinding"],
|
||||
properties: {
|
||||
id: "guard-duty-123",
|
||||
title: "Port probe",
|
||||
},
|
||||
};
|
||||
|
||||
describe("NodeDetailPanel", () => {
|
||||
it("renders the view finding button only for finding nodes", () => {
|
||||
const { rerender } = render(<NodeDetailPanel node={findingNode} />);
|
||||
@@ -65,6 +74,17 @@ describe("NodeDetailPanel", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the view finding button for cloud-provider finding resources", () => {
|
||||
// Given/When
|
||||
render(<NodeDetailPanel node={guardDutyNode} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /view finding/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Node findings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onViewFinding with the node finding id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onViewFinding = vi.fn();
|
||||
|
||||
+4
-6
@@ -11,6 +11,8 @@ import {
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { isProwlerFindingNode } from "../../_lib";
|
||||
|
||||
import { NodeFindings } from "./node-findings";
|
||||
import { NodeOverview } from "./node-overview";
|
||||
import { NodeResources } from "./node-resources";
|
||||
@@ -37,9 +39,7 @@ export const NodeDetailContent = ({
|
||||
onViewFinding?: (findingId: string) => void;
|
||||
viewFindingLoading?: boolean;
|
||||
}) => {
|
||||
const isProwlerFinding = node?.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const isProwlerFinding = isProwlerFindingNode(node.labels);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -105,9 +105,7 @@ export const NodeDetailPanel = ({
|
||||
}: NodeDetailPanelProps) => {
|
||||
const isOpen = node !== null;
|
||||
|
||||
const isProwlerFinding = node?.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const isProwlerFinding = node ? isProwlerFindingNode(node.labels) : false;
|
||||
const findingId = node ? String(node.properties?.id || node.id) : "";
|
||||
|
||||
return (
|
||||
|
||||
+2
-4
@@ -5,7 +5,7 @@ import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
|
||||
|
||||
import { formatNodeLabels } from "../../_lib";
|
||||
import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
|
||||
|
||||
interface NodeOverviewProps {
|
||||
node: GraphNode;
|
||||
@@ -25,9 +25,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const isFinding = isProwlerFindingNode(node.labels);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
RESOURCE_NODE_DIMENSIONS,
|
||||
} from "./node-dimensions";
|
||||
import { getNodeLabelDisplay } from "./node-label-lines";
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
import { resolveNodeVisual } from "./node-visuals";
|
||||
|
||||
interface ExportGraphOptions {
|
||||
@@ -67,8 +68,7 @@ const downloadDataUrl = (dataUrl: string, filename: string) => {
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const isFindingNode = (labels: string[]) =>
|
||||
labels.some((label) => label.toLowerCase().includes("finding"));
|
||||
const isFindingNode = isProwlerFindingNode;
|
||||
|
||||
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
|
||||
if (graphData.edges?.length) return graphData.edges;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
|
||||
/**
|
||||
* Color constants for attack path graph visualization
|
||||
* Colors chosen to work well in both light and dark themes
|
||||
@@ -67,7 +69,7 @@ export const getNodeColor = (
|
||||
labels: string[],
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
const isFinding = isProwlerFindingNode(labels);
|
||||
if (isFinding && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
|
||||
@@ -100,7 +102,7 @@ export const getNodeBorderColor = (
|
||||
labels: string[],
|
||||
properties?: Record<string, unknown>,
|
||||
): string => {
|
||||
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
const isFinding = isProwlerFindingNode(labels);
|
||||
if (isFinding && properties?.severity) {
|
||||
const severity = String(properties.severity).toLowerCase();
|
||||
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import type { AttackPathGraphData } from "@/types/attack-paths";
|
||||
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
|
||||
export const resolveHiddenFindingIds = ({
|
||||
expandedResources,
|
||||
findingNodeIds,
|
||||
@@ -103,11 +105,11 @@ export const computeFilteredSubgraph = (
|
||||
// Also include findings directly connected to the selected node
|
||||
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
|
||||
edges.forEach((edge) => {
|
||||
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
const sourceIsFinding = isProwlerFindingNode(
|
||||
nodeLabelMap.get(edge.source) ?? [],
|
||||
);
|
||||
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
const targetIsFinding = isProwlerFindingNode(
|
||||
nodeLabelMap.get(edge.target) ?? [],
|
||||
);
|
||||
|
||||
// Include findings connected to the selected node
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
resolveHiddenFindingIds,
|
||||
} from "./graph-utils";
|
||||
export { layoutWithDagre } from "./layout";
|
||||
export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
|
||||
export {
|
||||
NODE_CATEGORY,
|
||||
type NodeCategory,
|
||||
|
||||
@@ -17,6 +17,18 @@ const resourceNode: GraphNode = {
|
||||
properties: { name: "bucket-1" },
|
||||
};
|
||||
|
||||
const guardDutyNode: GraphNode = {
|
||||
id: "guard-duty-1",
|
||||
labels: ["GuardDutyFinding"],
|
||||
properties: { title: "Port probe", severity: "high" },
|
||||
};
|
||||
|
||||
const inspectorNode: GraphNode = {
|
||||
id: "inspector-1",
|
||||
labels: ["AWSInspectorFinding"],
|
||||
properties: { title: "Package vulnerability", severity: "high" },
|
||||
};
|
||||
|
||||
const internetNode: GraphNode = {
|
||||
id: "internet-1",
|
||||
labels: ["Internet"],
|
||||
@@ -54,6 +66,42 @@ describe("layoutWithDagre", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cloud-provider finding resources as resource nodes", () => {
|
||||
const { rfNodes } = layoutWithDagre(
|
||||
[findingNode, guardDutyNode, inspectorNode],
|
||||
[],
|
||||
);
|
||||
|
||||
const byId = new Map(rfNodes.map((n) => [n.id, n]));
|
||||
|
||||
expect(byId.get("finding-1")?.type).toBe("finding");
|
||||
expect(byId.get("guard-duty-1")).toMatchObject({
|
||||
type: "resource",
|
||||
width: 136,
|
||||
height: 124,
|
||||
});
|
||||
expect(byId.get("inspector-1")?.type).toBe("resource");
|
||||
});
|
||||
|
||||
it("does not animate edges that only touch cloud-provider finding resources", () => {
|
||||
const { rfEdges } = layoutWithDagre(
|
||||
[resourceNode, guardDutyNode],
|
||||
[
|
||||
{
|
||||
id: "e1",
|
||||
source: "guard-duty-1",
|
||||
target: "resource-1",
|
||||
type: "AFFECTS",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(rfEdges[0]).toMatchObject({
|
||||
animated: false,
|
||||
className: "resource-edge",
|
||||
});
|
||||
});
|
||||
|
||||
it("is deterministic: same input produces equal output across runs", () => {
|
||||
const nodes = [findingNode, resourceNode];
|
||||
const edges: GraphEdge[] = [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
INTERNET_NODE_DIMENSIONS,
|
||||
RESOURCE_NODE_DIMENSIONS,
|
||||
} from "./node-dimensions";
|
||||
import { isProwlerFindingNode } from "./node-types";
|
||||
|
||||
// Container relationships that get reversed for proper hierarchy
|
||||
const CONTAINER_RELATIONS = new Set([
|
||||
@@ -34,8 +35,7 @@ const NODE_TYPE = {
|
||||
|
||||
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
|
||||
|
||||
export const isFindingNode = (labels: string[]): boolean =>
|
||||
labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
const isFindingNode = isProwlerFindingNode;
|
||||
|
||||
const getNodeType = (labels: string[]): NodeType => {
|
||||
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const normalizeNodeLabel = (label: string): string =>
|
||||
label.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
export const isProwlerFindingLabel = (label: string): boolean =>
|
||||
normalizeNodeLabel(label) === "prowlerfinding";
|
||||
|
||||
export const isProwlerFindingNode = (labels: string[]): boolean =>
|
||||
labels.some(isProwlerFindingLabel);
|
||||
@@ -193,6 +193,28 @@ describe("resolveNodeVisual", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve cloud-provider finding resources as non-finding nodes", () => {
|
||||
// Given
|
||||
const guardDutyNode = buildNode(["GuardDutyFinding"], {
|
||||
title: "Port probe",
|
||||
severity: "high",
|
||||
});
|
||||
const inspectorNode = buildNode(["AWSInspectorFinding"], {
|
||||
title: "Package vulnerability",
|
||||
severity: "high",
|
||||
});
|
||||
|
||||
// When
|
||||
const guardDutyVisual = resolveNodeVisual(guardDutyNode);
|
||||
const inspectorVisual = resolveNodeVisual(inspectorNode);
|
||||
|
||||
// Then
|
||||
expect(guardDutyVisual.category).not.toBe(NODE_CATEGORY.FINDING);
|
||||
expect(guardDutyVisual.description).toBe("Guard Duty Finding");
|
||||
expect(inspectorVisual.category).not.toBe(NODE_CATEGORY.FINDING);
|
||||
expect(inspectorVisual.description).toBe("Aws Inspector Finding");
|
||||
});
|
||||
|
||||
it("should resolve finding icons from severity", () => {
|
||||
// Given
|
||||
const findingNodes = [
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
|
||||
|
||||
import { formatNodeLabel } from "./format";
|
||||
import { isProwlerFindingLabel } from "./node-types";
|
||||
|
||||
export const NODE_CATEGORY = {
|
||||
FINDING: "finding",
|
||||
@@ -396,8 +397,7 @@ const normalizeLabel = (label: string): string =>
|
||||
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
|
||||
label in KNOWN_NODE_VISUALS;
|
||||
|
||||
const isFindingLabel = (label: string): boolean =>
|
||||
normalizeLabel(label).includes("finding");
|
||||
const isFindingLabel = isProwlerFindingLabel;
|
||||
|
||||
const isInternetLabel = (label: string): boolean =>
|
||||
normalizeLabel(label) === "internet";
|
||||
|
||||
+4
-4
@@ -35,8 +35,8 @@ vi.mock("@/actions/findings", async () => {
|
||||
});
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import { getPathEdges } from "./_lib";
|
||||
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
|
||||
import { getPathEdges, isProwlerFindingNode } from "./_lib";
|
||||
import { layoutWithDagre } from "./_lib/layout";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
@@ -268,7 +268,7 @@ describe("running a query", () => {
|
||||
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
|
||||
|
||||
const visibleNodes = fixture.queryResult.nodes.filter(
|
||||
(node) => !isFindingNode(node.labels),
|
||||
(node) => !isProwlerFindingNode(node.labels),
|
||||
);
|
||||
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
|
||||
const visibleEdges = (fixture.queryResult.relationships ?? [])
|
||||
@@ -477,7 +477,7 @@ describe("exploring the graph", () => {
|
||||
|
||||
const findingIds = new Set(
|
||||
(fixture.queryResult?.nodes ?? [])
|
||||
.filter((node) => isFindingNode(node.labels))
|
||||
.filter((node) => isProwlerFindingNode(node.labels))
|
||||
.map((node) => node.id),
|
||||
);
|
||||
const visibleEdges = (fixture.queryResult?.relationships ?? [])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { isProwlerFindingNode } from "./_lib";
|
||||
import type { PageFixture } from "./attack-paths-page.fixtures";
|
||||
|
||||
export class AttackPathPageHarness {
|
||||
@@ -458,9 +459,7 @@ export class AttackPathPageHarness {
|
||||
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
|
||||
const findingIds = new Set(
|
||||
(this.fixture.queryResult?.nodes ?? [])
|
||||
.filter((n) =>
|
||||
n.labels.some((l) => l.toLowerCase().includes("finding")),
|
||||
)
|
||||
.filter((n) => isProwlerFindingNode(n.labels))
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const resourceWithFindingIds = new Set<string>();
|
||||
@@ -528,9 +527,7 @@ export class AttackPathPageHarness {
|
||||
async expandAllFindings(): Promise<void> {
|
||||
const findingIds = new Set(
|
||||
(this.fixture.queryResult?.nodes ?? [])
|
||||
.filter((n) =>
|
||||
n.labels.some((l) => l.toLowerCase().includes("finding")),
|
||||
)
|
||||
.filter((n) => isProwlerFindingNode(n.labels))
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const resourceWithFindingIds = new Set<string>();
|
||||
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
|
||||
import {
|
||||
attackPathsTour,
|
||||
type AttackPathsTourTarget,
|
||||
pickDemoQuery,
|
||||
pickDemoScan,
|
||||
} from "@/lib/tours/attack-paths.tour";
|
||||
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
|
||||
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -60,7 +60,7 @@ import type { GraphHandle } from "./_components/graph/attack-path-graph";
|
||||
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
|
||||
import { useGraphState } from "./_hooks/use-graph-state";
|
||||
import { useQueryBuilder } from "./_hooks/use-query-builder";
|
||||
import { exportGraphAsPNG } from "./_lib";
|
||||
import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
|
||||
import {
|
||||
ATTACK_PATHS_VIEW_STATES,
|
||||
getAttackPathsViewState,
|
||||
@@ -325,9 +325,7 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
const isFinding = isProwlerFindingNode(node.labels);
|
||||
|
||||
if (isFinding) {
|
||||
if (findingNavigationInFlightRef.current) {
|
||||
@@ -347,9 +345,7 @@ export default function AttackPathsPage() {
|
||||
if (edge.source !== node.id && edge.target !== node.id) return false;
|
||||
const otherId = edge.source === node.id ? edge.target : edge.source;
|
||||
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
|
||||
return otherNode?.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
return otherNode ? isProwlerFindingNode(otherNode.labels) : false;
|
||||
});
|
||||
|
||||
if (hasFindings) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { COMPLIANCE_TAB } from "../_types";
|
||||
|
||||
import { CompliancePageTabs } from "./compliance-page-tabs";
|
||||
import { getComplianceTab } from "./compliance-page-tabs.shared";
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
parseCrossProviderFilters,
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
CROSS_PROVIDER_OVERVIEW_TYPE,
|
||||
} from "../_types";
|
||||
|
||||
import { CrossProviderOverview } from "./cross-provider-overview";
|
||||
|
||||
vi.mock("../_actions/cross-provider", () => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
|
||||
|
||||
import type { CrossProviderRequirementExtras } from "../_types";
|
||||
|
||||
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
|
||||
|
||||
const { clientAccordionContentMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderBreakdownEntry } from "../_types";
|
||||
|
||||
import { ProviderCoverageCard } from "./provider-coverage-card";
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
extractSortAndKey,
|
||||
hasDateOrScanFilter,
|
||||
} from "@/lib";
|
||||
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
@@ -68,6 +69,13 @@ export default async function Findings({
|
||||
metadataInfoData?.data?.attributes?.resource_types || [];
|
||||
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
const fetchFindingGroupFilterOptions = hasHistoricalData
|
||||
? getFindingGroups
|
||||
: getLatestFindingGroups;
|
||||
const checkOptions = await getFindingGroupFilterOptions({
|
||||
fetchFindingGroups: fetchFindingGroupFilterOptions,
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
|
||||
const completedScans = scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
@@ -110,6 +118,7 @@ export default async function Findings({
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
checkOptions={checkOptions}
|
||||
trailingControls={
|
||||
<SeedFromFindingsButton
|
||||
filterBag={filters}
|
||||
@@ -145,6 +154,10 @@ const SSRDataTable = async ({
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const expandedCheckIdParam = searchParams.expandedCheckId;
|
||||
const expandedCheckId = Array.isArray(expandedCheckIdParam)
|
||||
? expandedCheckIdParam[0]
|
||||
: expandedCheckIdParam;
|
||||
|
||||
const { encodedSort } = extractSortAndKey(searchParams);
|
||||
const hasHistoricalData = hasDateOrScanFilter(filters);
|
||||
@@ -178,6 +191,7 @@ const SSRDataTable = async ({
|
||||
metadata={findingGroupsData?.meta}
|
||||
resolvedFilters={filters}
|
||||
hasHistoricalData={hasHistoricalData}
|
||||
expandedCheckId={expandedCheckId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles";
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
import { ProviderIcon } from "../config/provider-icon";
|
||||
|
||||
import { ChatComposerPanel } from "./composer";
|
||||
import { ChatEmptyState } from "./empty-state";
|
||||
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
|
||||
|
||||
@@ -170,11 +170,12 @@ describe("LighthousePanelChat", () => {
|
||||
// When
|
||||
render(<LighthousePanelChat />);
|
||||
|
||||
// Then: composer is live and the empty state lists recent chats
|
||||
// Then: composer is live and the empty state lists recent chats.
|
||||
// Sessions load on a later render than the composer, so await them.
|
||||
expect(
|
||||
await screen.findByRole("textbox", { name: "Message" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Recent chats")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Recent chats")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Counting critical findings"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
LIGHTHOUSE_CHAT_SURFACE,
|
||||
LighthouseV2ChatView,
|
||||
} from "../chat/lighthouse-v2-chat-view";
|
||||
|
||||
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
|
||||
|
||||
const PANEL_CHAT_STATUS = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Suspense } from "react";
|
||||
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
|
||||
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
export const BEDROCK_AUTH_MODES = {
|
||||
@@ -44,7 +45,7 @@ function ConnectContent() {
|
||||
}
|
||||
|
||||
export default function ConnectLLMProviderPage() {
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from "@/actions/lighthouse-v1/lighthouse";
|
||||
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
|
||||
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { NavigationHeader } from "@/components/shadcn";
|
||||
import { Button, NavigationHeader } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Suspense } from "react";
|
||||
|
||||
import { SelectModel } from "@/components/lighthouse-v1/select-model";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
function SelectModelContent() {
|
||||
@@ -27,7 +28,7 @@ function SelectModelContent() {
|
||||
}
|
||||
|
||||
export default function SelectModelPage() {
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
FieldError,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { toggleMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleData } from "@/actions/mute-rules/types";
|
||||
import { Switch } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Switch, useToast } from "@/components/shadcn";
|
||||
|
||||
interface MuteRuleEnabledToggleProps {
|
||||
muteRule: MuteRuleData;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type MuteRuleTableData } from "./mute-rule-target-previews";
|
||||
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
@@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
|
||||
|
||||
const longMuteRule: MuteRuleTableData = {
|
||||
type: "mute-rules",
|
||||
id: "mute-rule-1",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Overview page", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("renders the overview banners before the provider filters", () => {
|
||||
// Given
|
||||
const firstBannerPosition = source.indexOf("<OverviewBanner");
|
||||
const firstProviderFilterPosition = source.indexOf(
|
||||
"<ProviderAccountSelectors",
|
||||
);
|
||||
|
||||
// When
|
||||
const bannersRenderBeforeFilters =
|
||||
firstBannerPosition < firstProviderFilterPosition;
|
||||
|
||||
// Then
|
||||
expect(firstBannerPosition).toBeGreaterThan(-1);
|
||||
expect(firstProviderFilterPosition).toBeGreaterThan(-1);
|
||||
expect(bannersRenderBeforeFilters).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
AppSidebarModeSync,
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { LighthouseOverviewBanner } from "./_overview/_components/lighthouse-overview-banner";
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
import {
|
||||
AttackSurfaceSkeleton,
|
||||
AttackSurfaceSSR,
|
||||
@@ -58,17 +60,30 @@ export default async function Home({
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
{/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a
|
||||
local server the agents banner is the only child and fills the row. */}
|
||||
<div className="mb-6 flex flex-col gap-6 lg:flex-row">
|
||||
{lighthouseBannerHref ? (
|
||||
<div className="min-w-0 lg:flex-1">
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
|
||||
href={lighthouseBannerHref}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 lg:flex-1">
|
||||
<OverviewBanner
|
||||
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
|
||||
href={DOCS_URLS.AI_AGENTS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
{lighthouseBannerHref ? (
|
||||
<div className="mb-6">
|
||||
<LighthouseOverviewBanner href={lighthouseBannerHref} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
FieldLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { Button, Card } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Button, Card, useToast } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { DataTable } from "@/components/shadcn/table";
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
|
||||
import { auth } from "@/auth.config";
|
||||
import { PageReady } from "@/components/onboarding";
|
||||
import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
import {
|
||||
appendPendingScheduleRowsToPage,
|
||||
buildScheduledTabRows,
|
||||
@@ -20,8 +22,6 @@ import {
|
||||
getScanJobsUserFilters,
|
||||
pickScheduleProviderFilters,
|
||||
} from "@/components/scans/scans.utils";
|
||||
import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
Reference in new issue
Block a user