mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): add custom attack paths queries (#10397)
This commit is contained in:
committed by
GitHub
parent
41629137ef
commit
49ba25ba07
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.22.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
|
||||
|
||||
---
|
||||
|
||||
## [1.21.0] (Prowler v5.21.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { buildAttackPathQueries } from "./queries.adapter";
|
||||
|
||||
const presetQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "preset-query",
|
||||
attributes: {
|
||||
name: "Preset Query",
|
||||
short_description: "Returns privileged attack paths",
|
||||
description: "Returns privileged attack paths.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("buildAttackPathQueries", () => {
|
||||
it("prepends a custom query with a schema documentation link", () => {
|
||||
// Given
|
||||
const schema: AttackPathCartographySchemaAttributes = {
|
||||
id: "aws-0.129.0",
|
||||
provider: "aws",
|
||||
cartography_version: "0.129.0",
|
||||
schema_url:
|
||||
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
|
||||
raw_schema_url:
|
||||
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = buildAttackPathQueries([presetQuery], schema);
|
||||
|
||||
// Then
|
||||
expect(result[0]).toMatchObject({
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write and run your own read-only query",
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: schema.schema_url,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result[1]).toEqual(presetQuery);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { MetaDataProps } from "@/types";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
/**
|
||||
@@ -53,3 +56,52 @@ export function adaptAttackPathQueriesResponse(
|
||||
|
||||
return { data: enrichedData, metadata };
|
||||
}
|
||||
|
||||
const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
|
||||
RETURN n
|
||||
LIMIT 25`;
|
||||
|
||||
const formatSchemaDocumentationLinkText = (
|
||||
schema: AttackPathCartographySchemaAttributes,
|
||||
): string => {
|
||||
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
|
||||
};
|
||||
|
||||
const createCustomQuery = (
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery => ({
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write and run your own read-only query",
|
||||
description:
|
||||
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
|
||||
provider: "custom",
|
||||
attribution: null,
|
||||
documentation_link: schema
|
||||
? {
|
||||
text: formatSchemaDocumentationLinkText(schema),
|
||||
link: schema.schema_url,
|
||||
}
|
||||
: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
description: "",
|
||||
placeholder: CUSTOM_QUERY_PLACEHOLDER,
|
||||
required: true,
|
||||
input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const buildAttackPathQueries = (
|
||||
queries: AttackPathQuery[],
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery[] => {
|
||||
return [createCustomQuery(schema), ...queries];
|
||||
};
|
||||
|
||||
@@ -17,7 +17,11 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { executeQuery } from "./queries";
|
||||
import {
|
||||
executeCustomQuery,
|
||||
executeQuery,
|
||||
getCartographySchema,
|
||||
} from "./queries";
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
@@ -65,3 +69,139 @@ describe("executeQuery", () => {
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCustomQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "attack-paths-query-run-requests",
|
||||
id: null,
|
||||
attributes: {
|
||||
nodes: [],
|
||||
relationships: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("posts the custom query to the dedicated endpoint", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
// When
|
||||
await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"MATCH (n) RETURN n LIMIT 10",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "attack-paths-custom-query-run-requests",
|
||||
attributes: {
|
||||
query: "MATCH (n) RETURN n LIMIT 10",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty custom queries before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
" ",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Custom query cannot be empty",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects custom queries longer than 10000 characters before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"x".repeat(10001),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Custom query must be 10000 characters or fewer",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects custom queries with write operations before calling the API", async () => {
|
||||
// When
|
||||
const result = await executeCustomQuery(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"MATCH (n) SET n.name = 'updated' RETURN n",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
error: "Only read-only queries are allowed",
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCartographySchema", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("fetches the schema metadata for the selected scan", async () => {
|
||||
// Given
|
||||
const apiResponse = {
|
||||
data: {
|
||||
type: "attack-paths-cartography-schemas",
|
||||
id: "aws-0.129.0",
|
||||
attributes: {
|
||||
id: "aws-0.129.0",
|
||||
provider: "aws",
|
||||
cartography_version: "0.129.0",
|
||||
schema_url:
|
||||
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
|
||||
raw_schema_url:
|
||||
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
|
||||
},
|
||||
},
|
||||
};
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue(apiResponse);
|
||||
|
||||
// When
|
||||
const result = await getCartographySchema(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(apiResponse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
AttackPathCartographySchema,
|
||||
AttackPathCartographySchemaResponse,
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
AttackPathQueryError,
|
||||
AttackPathQueryResult,
|
||||
ExecuteCustomQueryRequest,
|
||||
ExecuteQueryRequest,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
@@ -102,3 +106,93 @@ export const executeQuery = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a custom openCypher query on an attack path scan
|
||||
*/
|
||||
export const executeCustomQuery = async (
|
||||
scanId: string,
|
||||
query: string,
|
||||
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
|
||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||
if (!validatedScanId.success) {
|
||||
console.error("Invalid scan ID format");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validatedQuery = customAttackPathQuerySchema.safeParse(query);
|
||||
if (!validatedQuery.success) {
|
||||
return {
|
||||
error:
|
||||
validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const requestBody: ExecuteCustomQueryRequest = {
|
||||
data: {
|
||||
type: "attack-paths-custom-query-run-requests",
|
||||
attributes: {
|
||||
query: validatedQuery.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`,
|
||||
{
|
||||
headers,
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
);
|
||||
|
||||
return (await handleApiResponse(response)) as
|
||||
| AttackPathQueryResult
|
||||
| AttackPathQueryError;
|
||||
} catch (error) {
|
||||
console.error("Error executing custom query on scan:", error);
|
||||
return {
|
||||
error:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
status: 503,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch cartography schema metadata for a specific attack path scan
|
||||
*/
|
||||
export const getCartographySchema = async (
|
||||
scanId: string,
|
||||
): Promise<{ data: AttackPathCartographySchema } | undefined> => {
|
||||
const validatedScanId = UUIDSchema.safeParse(scanId);
|
||||
if (!validatedScanId.success) {
|
||||
console.error("Invalid scan ID format");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`,
|
||||
{
|
||||
headers,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
const apiResponse = (await handleApiResponse(
|
||||
response,
|
||||
)) as AttackPathCartographySchemaResponse;
|
||||
|
||||
return { data: apiResponse.data };
|
||||
} catch (error) {
|
||||
console.error("Error fetching cartography schema for scan:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { ExecuteButton } from "./execute-button";
|
||||
export * from "./graph";
|
||||
export * from "./node-detail";
|
||||
export { QueryDescription } from "./query-description";
|
||||
export { QueryExecutionError } from "./query-execution-error";
|
||||
export { QueryParametersForm } from "./query-parameters-form";
|
||||
export { QuerySelector } from "./query-selector";
|
||||
export { ScanListTable } from "./scan-list-table";
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
import { QueryDescription } from "./query-description";
|
||||
|
||||
const customQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: "custom-query",
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description:
|
||||
"Run a read-only openCypher query against the selected Attack Paths scan.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: "https://example.com/schema",
|
||||
},
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("QueryDescription", () => {
|
||||
it("renders the schema documentation link inside an info alert", () => {
|
||||
// Given
|
||||
render(<QueryDescription query={customQuery} />);
|
||||
|
||||
// When
|
||||
const alert = screen.getByRole("alert");
|
||||
const link = screen.getByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://example.com/schema");
|
||||
});
|
||||
|
||||
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
|
||||
// Given
|
||||
const queryWithUnsafeLinks: AttackPathQuery = {
|
||||
...customQuery,
|
||||
attributes: {
|
||||
...customQuery.attributes,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
attribution: {
|
||||
text: "Unsafe source",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
render(<QueryDescription query={queryWithUnsafeLinks} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /unsafe source/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/cartography schema used by prowler for aws graphs/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
|
||||
interface QueryDescriptionProps {
|
||||
query: AttackPathQuery;
|
||||
}
|
||||
|
||||
const isSafeUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||
const documentationLink = query.attributes.documentation_link;
|
||||
const attribution = query.attributes.attribution;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<Info className="text-bg-data-info mt-0.5 size-4 shrink-0" />
|
||||
<AlertDescription className="w-full gap-2">
|
||||
<p className="whitespace-pre-line">{query.attributes.description}</p>
|
||||
|
||||
{documentationLink && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(documentationLink.link) ? (
|
||||
<a
|
||||
href={documentationLink.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
{documentationLink.text}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium">{documentationLink.text}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{attribution && (
|
||||
<p className="text-xs">
|
||||
{isSafeUrl(attribution.link) ? (
|
||||
<>
|
||||
Source:{" "}
|
||||
<a
|
||||
href={attribution.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{attribution.text}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Source: <span>{attribution.text}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { QueryExecutionError } from "./query-execution-error";
|
||||
|
||||
describe("QueryExecutionError", () => {
|
||||
it("renders the default title and the raw query error details without extra copy", () => {
|
||||
// Given
|
||||
const error =
|
||||
"Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)";
|
||||
|
||||
// When
|
||||
render(<QueryExecutionError error={error} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/query execution failed/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/the attack paths query could not be executed/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom title and description when provided", () => {
|
||||
// Given
|
||||
const error = "Failed to load available queries";
|
||||
|
||||
// When
|
||||
render(
|
||||
<QueryExecutionError
|
||||
title="Failed to load queries"
|
||||
description="Available Attack Paths queries could not be loaded for this scan."
|
||||
error={error}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/available attack paths queries could not be loaded for this scan/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { CircleAlert } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
|
||||
|
||||
interface QueryExecutionErrorProps {
|
||||
error: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const QueryExecutionError = ({
|
||||
error,
|
||||
title = "Query execution failed",
|
||||
description,
|
||||
}: QueryExecutionErrorProps) => {
|
||||
return (
|
||||
<Alert variant="error">
|
||||
<CircleAlert className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription className="w-full gap-3">
|
||||
{description ? <p>{description}</p> : null}
|
||||
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
|
||||
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+121
-1
@@ -1,8 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { QueryParametersForm } from "./query-parameters-form";
|
||||
|
||||
@@ -42,6 +46,64 @@ function TestForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function TestCustomQueryForm() {
|
||||
const customQuery: AttackPathQuery = {
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description: "Run a custom query against the graph.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
input_type: "textarea",
|
||||
placeholder: "MATCH (n) RETURN n LIMIT 25",
|
||||
description: "",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
query: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<QueryParametersForm selectedQuery={customQuery} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TestFormWithError() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
tag_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setError("tag_key", {
|
||||
type: "manual",
|
||||
message: "Tag key is required",
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<QueryParametersForm selectedQuery={mockQuery} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QueryParametersForm", () => {
|
||||
it("uses the field description as the placeholder instead of rendering helper text below", () => {
|
||||
// Given
|
||||
@@ -70,4 +132,62 @@ describe("QueryParametersForm", () => {
|
||||
screen.queryByText("Tag key to filter the S3 bucket."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a textarea when the parameter input type is textarea", () => {
|
||||
// Given
|
||||
render(<TestCustomQueryForm />);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", { name: /opencypher/i });
|
||||
const codeEditor = screen.getByTestId("query-code-editor");
|
||||
|
||||
// Then
|
||||
expect(input.tagName).toBe("TEXTAREA");
|
||||
expect(input).toHaveAttribute("data-slot", "textarea");
|
||||
expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25");
|
||||
expect(input).toHaveAttribute("spellcheck", "false");
|
||||
expect(input).toHaveAttribute("autocomplete", "off");
|
||||
expect(input).toHaveAttribute("autocorrect", "off");
|
||||
expect(input).toHaveAttribute("autocapitalize", "none");
|
||||
expect(input).toHaveClass(
|
||||
"minimal-scrollbar",
|
||||
"min-h-[320px]",
|
||||
"font-mono",
|
||||
"leading-6",
|
||||
);
|
||||
expect(codeEditor).toHaveClass(
|
||||
"rounded-xl",
|
||||
"border",
|
||||
"bg-bg-neutral-primary",
|
||||
);
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the design-system error token for field validation messages", async () => {
|
||||
// Given
|
||||
render(<TestFormWithError />);
|
||||
|
||||
// When
|
||||
const errorMessage = await screen.findByText("Tag key is required");
|
||||
|
||||
// Then
|
||||
expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs");
|
||||
});
|
||||
|
||||
it("connects field errors to the input for accessibility", async () => {
|
||||
// Given
|
||||
render(<TestFormWithError />);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", { name: /tag key/i });
|
||||
const errorMessage = await screen.findByText("Tag key is required");
|
||||
|
||||
// Then
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(errorMessage).toHaveAttribute("id");
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
expect.stringContaining(errorMessage.getAttribute("id") ?? ""),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+116
-50
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { Input } from "@/components/shadcn";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import { Input, Textarea } from "@/components/shadcn";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
interface QueryParametersFormProps {
|
||||
selectedQuery: AttackPathQuery | null | undefined;
|
||||
@@ -16,10 +28,7 @@ interface QueryParametersFormProps {
|
||||
export const QueryParametersForm = ({
|
||||
selectedQuery,
|
||||
}: QueryParametersFormProps) => {
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
const { control } = useFormContext();
|
||||
|
||||
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
|
||||
return null;
|
||||
@@ -36,23 +45,26 @@ export const QueryParametersForm = ({
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
{selectedQuery.attributes.parameters.map((param) => (
|
||||
<Controller
|
||||
<FormField
|
||||
key={param.name}
|
||||
name={param.name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
render={({ field, fieldState }) => {
|
||||
if (param.data_type === "boolean") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={param.name}
|
||||
checked={field.value === true || field.value === "true"}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
aria-label={param.label}
|
||||
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
|
||||
/>
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
field.value === true || field.value === "true"
|
||||
}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
aria-label={param.label}
|
||||
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{param.label}
|
||||
@@ -64,44 +76,98 @@ export const QueryParametersForm = ({
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = (() => {
|
||||
const error = errors[param.name];
|
||||
if (error && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const placeholder =
|
||||
param.description ||
|
||||
param.placeholder ||
|
||||
`Enter ${param.label.toLowerCase()}`;
|
||||
|
||||
const isTextarea =
|
||||
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
|
||||
const isCustomCodeEditor =
|
||||
selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
param.name === "query" &&
|
||||
isTextarea;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor={param.name}
|
||||
className="text-text-neutral-tertiary text-xs font-medium"
|
||||
>
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={param.name}
|
||||
type={param.data_type === "number" ? "number" : "text"}
|
||||
placeholder={
|
||||
param.description ||
|
||||
param.placeholder ||
|
||||
`Enter ${param.label.toLowerCase()}`
|
||||
}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<span className="text-xs text-red-500">{errorMessage}</span>
|
||||
<FormItem
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5",
|
||||
isTextarea && "md:col-span-2",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{!isCustomCodeEditor && (
|
||||
<FormLabel className="text-text-neutral-tertiary text-xs font-medium">
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{isCustomCodeEditor ? (
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
|
||||
fieldState.invalid && "border-border-error-primary",
|
||||
)}
|
||||
>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium">
|
||||
{param.label}
|
||||
{param.required && (
|
||||
<span className="text-text-error-primary">*</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-[11px]">
|
||||
Read-only
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
aria-label={param.label}
|
||||
variant="ghost"
|
||||
textareaSize="lg"
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
className="minimal-scrollbar min-h-[320px] rounded-none border-0 bg-transparent font-mono text-xs leading-6 hover:bg-transparent focus:bg-transparent focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
) : (
|
||||
<FormControl>
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
{...field}
|
||||
textareaSize="lg"
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...field}
|
||||
type={
|
||||
param.data_type === "number" ? "number" : "text"
|
||||
}
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
+123
@@ -1,7 +1,12 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
} from "@/lib/attack-paths/custom-query";
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
|
||||
|
||||
import { useQueryBuilder } from "./use-query-builder";
|
||||
|
||||
@@ -38,6 +43,27 @@ const mockQueries: AttackPathQuery[] = [
|
||||
parameters: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write your own query",
|
||||
description: "Run a custom query against the graph.",
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
label: "openCypher",
|
||||
data_type: "string",
|
||||
description: "",
|
||||
required: true,
|
||||
input_type: "textarea",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("useQueryBuilder", () => {
|
||||
@@ -77,4 +103,101 @@ describe("useQueryBuilder", () => {
|
||||
);
|
||||
expect(result.current.getQueryParameters()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects whitespace-only custom queries before execution", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("query", " ");
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
"Custom query cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects custom queries longer than the supported limit", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue(
|
||||
"query",
|
||||
"x".repeat(CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH + 1),
|
||||
);
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects custom queries containing write operations", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() => useQueryBuilder(mockQueries));
|
||||
|
||||
act(() => {
|
||||
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedQueryData?.id).toBe(
|
||||
ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.form.setValue("query", "CREATE (n:Test) RETURN n");
|
||||
});
|
||||
|
||||
// When
|
||||
let isValid = true;
|
||||
await act(async () => {
|
||||
isValid = await result.current.form.trigger("query");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(isValid).toBe(false);
|
||||
expect(result.current.form.getFieldState("query").error?.message).toBe(
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
);
|
||||
expect(result.current.isExecutionBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,28 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||
import {
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
customAttackPathQuerySchema,
|
||||
} from "@/lib/attack-paths/custom-query";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
const getValidationSchema = (query?: AttackPathQuery) => {
|
||||
const schemaObject: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
query?.attributes.parameters.forEach((param) => {
|
||||
let fieldSchema: z.ZodTypeAny = z
|
||||
.string()
|
||||
.min(1, `${param.label} is required`);
|
||||
const isCustomQueryParameter =
|
||||
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
param.name === "query" &&
|
||||
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
|
||||
|
||||
let fieldSchema: z.ZodTypeAny = isCustomQueryParameter
|
||||
? customAttackPathQuerySchema
|
||||
: z.string().min(1, `${param.label} is required`);
|
||||
|
||||
if (param.data_type === "number") {
|
||||
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
|
||||
@@ -93,6 +106,11 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
return form.formState.isValid;
|
||||
};
|
||||
|
||||
const isExecutionBlocked =
|
||||
selectedQueryData?.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
|
||||
form.formState.errors.query?.message ===
|
||||
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE;
|
||||
|
||||
return {
|
||||
selectedQuery,
|
||||
selectedQueryData,
|
||||
@@ -101,5 +119,6 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
handleQueryChange,
|
||||
getQueryParameters,
|
||||
isFormValid,
|
||||
isExecutionBlocked,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,9 +7,12 @@ import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import {
|
||||
buildAttackPathQueries,
|
||||
executeCustomQuery,
|
||||
executeQuery,
|
||||
getAttackPathScans,
|
||||
getAvailableQueries,
|
||||
getCartographySchema,
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
@@ -35,6 +38,7 @@ import type {
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
} from "@/types/attack-paths";
|
||||
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
AttackPathGraph,
|
||||
@@ -43,6 +47,8 @@ import {
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
QueryDescription,
|
||||
QueryExecutionError,
|
||||
QueryParametersForm,
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
@@ -138,11 +144,21 @@ export default function AttackPathsPage() {
|
||||
|
||||
setQueriesLoading(true);
|
||||
try {
|
||||
const queriesData = await getAvailableQueries(scanId);
|
||||
if (queriesData?.data) {
|
||||
setQueries(queriesData.data);
|
||||
const [queriesData, schemaData] = await Promise.all([
|
||||
getAvailableQueries(scanId),
|
||||
getCartographySchema(scanId),
|
||||
]);
|
||||
|
||||
const availableQueries = buildAttackPathQueries(
|
||||
queriesData?.data ?? [],
|
||||
schemaData?.data.attributes,
|
||||
);
|
||||
|
||||
if (availableQueries.length > 0) {
|
||||
setQueries(availableQueries);
|
||||
setQueriesError(null);
|
||||
} else {
|
||||
setQueries([]);
|
||||
setQueriesError("Failed to load available queries");
|
||||
toast({
|
||||
title: "Error",
|
||||
@@ -199,15 +215,12 @@ export default function AttackPathsPage() {
|
||||
graphState.setError(null);
|
||||
|
||||
try {
|
||||
const parameters = queryBuilder.getQueryParameters() as Record<
|
||||
string,
|
||||
string | number | boolean
|
||||
>;
|
||||
const result = await executeQuery(
|
||||
scanId,
|
||||
queryBuilder.selectedQuery,
|
||||
parameters,
|
||||
);
|
||||
const parameters = queryBuilder.getQueryParameters();
|
||||
const isCustomQuery =
|
||||
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||
const result = isCustomQuery
|
||||
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
|
||||
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
|
||||
|
||||
if (result && "error" in result) {
|
||||
const apiError = result as AttackPathQueryError;
|
||||
@@ -371,9 +384,10 @@ export default function AttackPathsPage() {
|
||||
{queriesLoading ? (
|
||||
<p className="text-sm">Loading queries...</p>
|
||||
) : queriesError ? (
|
||||
<p className="text-text-danger dark:text-text-danger text-sm">
|
||||
{queriesError}
|
||||
</p>
|
||||
<QueryExecutionError
|
||||
title="Failed to load queries"
|
||||
error={queriesError}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FormProvider {...queryBuilder.form}>
|
||||
@@ -384,40 +398,9 @@ export default function AttackPathsPage() {
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
style={{ color: "var(--bg-data-info)" }}
|
||||
/>
|
||||
<p className="whitespace-pre-line">
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{queryBuilder.selectedQueryData.attributes
|
||||
.attribution && (
|
||||
<p className="mt-2 text-xs">
|
||||
Source:{" "}
|
||||
<a
|
||||
href={
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.link
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{
|
||||
queryBuilder.selectedQueryData.attributes
|
||||
.attribution.text
|
||||
}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<QueryDescription
|
||||
query={queryBuilder.selectedQueryData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{queryBuilder.selectedQuery && (
|
||||
@@ -430,15 +413,16 @@ export default function AttackPathsPage() {
|
||||
<div className="flex justify-end gap-3">
|
||||
<ExecuteButton
|
||||
isLoading={graphState.loading}
|
||||
isDisabled={!queryBuilder.selectedQuery}
|
||||
isDisabled={
|
||||
!queryBuilder.selectedQuery ||
|
||||
queryBuilder.isExecutionBlocked
|
||||
}
|
||||
onExecute={handleExecuteQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{graphState.error && (
|
||||
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
|
||||
{graphState.error}
|
||||
</div>
|
||||
<QueryExecutionError error={graphState.error} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,4 +18,5 @@ export * from "./separator/separator";
|
||||
export * from "./skeleton/skeleton";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
export * from "./textarea/textarea";
|
||||
export * from "./tooltip";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH = 10000;
|
||||
export const CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE =
|
||||
"Only read-only queries are allowed";
|
||||
const CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS =
|
||||
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
||||
const CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS = [
|
||||
/\bCREATE\b/i,
|
||||
/\bMERGE\b/i,
|
||||
/\bSET\b/i,
|
||||
/\bREMOVE\b/i,
|
||||
/\bDELETE\b/i,
|
||||
/\bDETACH\s+DELETE\b/i,
|
||||
/\bDROP\b/i,
|
||||
/\bLOAD\s+CSV\b/i,
|
||||
/\bapoc\.(?:load|import|export|cypher|systemdb|config|periodic|do|trigger|custom)\b/i,
|
||||
] as const;
|
||||
|
||||
const containsBlockedOperation = (query: string): boolean => {
|
||||
const normalizedQuery = query.replace(
|
||||
CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS,
|
||||
"",
|
||||
);
|
||||
|
||||
return CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS.some((pattern) =>
|
||||
pattern.test(normalizedQuery),
|
||||
);
|
||||
};
|
||||
|
||||
export const customAttackPathQuerySchema = z
|
||||
.string()
|
||||
.max(
|
||||
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
|
||||
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
|
||||
)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: "Custom query cannot be empty",
|
||||
})
|
||||
.refine((value) => !containsBlockedOperation(value), {
|
||||
message: CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
|
||||
});
|
||||
@@ -81,6 +81,18 @@ export const DATA_TYPES = {
|
||||
|
||||
type DataType = (typeof DATA_TYPES)[keyof typeof DATA_TYPES];
|
||||
|
||||
export const QUERY_PARAMETER_INPUT_TYPES = {
|
||||
TEXT: "text",
|
||||
TEXTAREA: "textarea",
|
||||
} as const;
|
||||
|
||||
export type QueryParameterInputType =
|
||||
(typeof QUERY_PARAMETER_INPUT_TYPES)[keyof typeof QUERY_PARAMETER_INPUT_TYPES];
|
||||
|
||||
export const ATTACK_PATH_QUERY_IDS = {
|
||||
CUSTOM: "__custom-open-cypher__",
|
||||
} as const;
|
||||
|
||||
// Query Types
|
||||
export interface AttackPathQueryParameter {
|
||||
name: string;
|
||||
@@ -89,6 +101,7 @@ export interface AttackPathQueryParameter {
|
||||
description: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
input_type?: QueryParameterInputType;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryAttribution {
|
||||
@@ -96,6 +109,11 @@ export interface AttackPathQueryAttribution {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryDocumentationLink {
|
||||
text: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface AttackPathQueryAttributes {
|
||||
name: string;
|
||||
short_description: string;
|
||||
@@ -103,6 +121,7 @@ export interface AttackPathQueryAttributes {
|
||||
provider: string;
|
||||
parameters: AttackPathQueryParameter[];
|
||||
attribution: AttackPathQueryAttribution | null;
|
||||
documentation_link?: AttackPathQueryDocumentationLink | null;
|
||||
}
|
||||
|
||||
export interface AttackPathQuery {
|
||||
@@ -115,6 +134,24 @@ export interface AttackPathQueriesResponse {
|
||||
data: AttackPathQuery[];
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchemaAttributes {
|
||||
id: string;
|
||||
provider: string;
|
||||
cartography_version: string;
|
||||
schema_url: string;
|
||||
raw_schema_url: string;
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchema {
|
||||
type: "attack-paths-cartography-schemas";
|
||||
id: string;
|
||||
attributes: AttackPathCartographySchemaAttributes;
|
||||
}
|
||||
|
||||
export interface AttackPathCartographySchemaResponse {
|
||||
data: AttackPathCartographySchema;
|
||||
}
|
||||
|
||||
// Graph Data Types
|
||||
// Property values from graph nodes can be any primitive type or arrays
|
||||
export type GraphNodePropertyValue =
|
||||
@@ -256,3 +293,16 @@ export interface ExecuteQueryRequestData {
|
||||
export interface ExecuteQueryRequest {
|
||||
data: ExecuteQueryRequestData;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequestAttributes {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequestData {
|
||||
type: "attack-paths-custom-query-run-requests";
|
||||
attributes: ExecuteCustomQueryRequestAttributes;
|
||||
}
|
||||
|
||||
export interface ExecuteCustomQueryRequest {
|
||||
data: ExecuteCustomQueryRequestData;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user