mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): add custom attack paths queries
- Add a custom openCypher query option in Attack Paths - Show Cartography schema guidance for the selected scan - Format query execution errors and cover the flow with tests
This commit is contained in:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
All notable changes to the **Prowler UI** are documented in this file.
|
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 (PROWLER-1119)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.21.0] (Prowler v5.21.0)
|
## [1.21.0] (Prowler v5.21.0)
|
||||||
|
|
||||||
### 🚀 Added
|
### 🚀 Added
|
||||||
|
|||||||
54
ui/actions/attack-paths/queries.adapter.test.ts
Normal file
54
ui/actions/attack-paths/queries.adapter.test.ts
Normal file
@@ -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 openCypher 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 openCypher 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 { MetaDataProps } from "@/types";
|
||||||
import {
|
import {
|
||||||
|
ATTACK_PATH_QUERY_IDS,
|
||||||
|
type AttackPathCartographySchemaAttributes,
|
||||||
AttackPathQueriesResponse,
|
AttackPathQueriesResponse,
|
||||||
AttackPathQuery,
|
AttackPathQuery,
|
||||||
|
QUERY_PARAMETER_INPUT_TYPES,
|
||||||
} from "@/types/attack-paths";
|
} from "@/types/attack-paths";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,3 +56,52 @@ export function adaptAttackPathQueriesResponse(
|
|||||||
|
|
||||||
return { data: enrichedData, metadata };
|
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 openCypher 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 query",
|
||||||
|
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,
|
handleApiResponse: handleApiResponseMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { executeQuery } from "./queries";
|
import {
|
||||||
|
executeCustomQuery,
|
||||||
|
executeQuery,
|
||||||
|
getCartographySchema,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
describe("executeQuery", () => {
|
describe("executeQuery", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -65,3 +69,91 @@ describe("executeQuery", () => {
|
|||||||
expect(handleApiResponseMock).not.toHaveBeenCalled();
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { z } from "zod";
|
|||||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||||
import {
|
import {
|
||||||
|
AttackPathCartographySchema,
|
||||||
|
AttackPathCartographySchemaResponse,
|
||||||
AttackPathQueriesResponse,
|
AttackPathQueriesResponse,
|
||||||
AttackPathQuery,
|
AttackPathQuery,
|
||||||
AttackPathQueryError,
|
AttackPathQueryError,
|
||||||
AttackPathQueryResult,
|
AttackPathQueryResult,
|
||||||
|
ExecuteCustomQueryRequest,
|
||||||
ExecuteQueryRequest,
|
ExecuteQueryRequest,
|
||||||
} from "@/types/attack-paths";
|
} from "@/types/attack-paths";
|
||||||
|
|
||||||
@@ -102,3 +105,84 @@ 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 headers = await getAuthHeaders({ contentType: true });
|
||||||
|
|
||||||
|
const requestBody: ExecuteCustomQueryRequest = {
|
||||||
|
data: {
|
||||||
|
type: "attack-paths-custom-query-run-requests",
|
||||||
|
attributes: {
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { ExecuteButton } from "./execute-button";
|
||||||
export * from "./graph";
|
export * from "./graph";
|
||||||
export * from "./node-detail";
|
export * from "./node-detail";
|
||||||
|
export { QueryDescription } from "./query-description";
|
||||||
|
export { QueryExecutionError } from "./query-execution-error";
|
||||||
export { QueryParametersForm } from "./query-parameters-form";
|
export { QueryParametersForm } from "./query-parameters-form";
|
||||||
export { QuerySelector } from "./query-selector";
|
export { QuerySelector } from "./query-selector";
|
||||||
export { ScanListTable } from "./scan-list-table";
|
export { ScanListTable } from "./scan-list-table";
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 when the selected query provides one", () => {
|
||||||
|
// Given
|
||||||
|
render(<QueryDescription query={customQuery} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const link = screen.getByRole("link", {
|
||||||
|
name: /cartography schema used by prowler for aws graphs/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(link).toHaveAttribute("href", "https://example.com/schema");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
|
import type { AttackPathQuery } from "@/types/attack-paths";
|
||||||
|
|
||||||
|
interface QueryDescriptionProps {
|
||||||
|
query: AttackPathQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
|
||||||
|
return (
|
||||||
|
<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)" }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="whitespace-pre-line">{query.attributes.description}</p>
|
||||||
|
|
||||||
|
{query.attributes.documentation_link && (
|
||||||
|
<p className="text-xs">
|
||||||
|
<a
|
||||||
|
href={query.attributes.documentation_link.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline"
|
||||||
|
>
|
||||||
|
{query.attributes.documentation_link.text}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.attributes.attribution && (
|
||||||
|
<p className="text-xs">
|
||||||
|
Source:{" "}
|
||||||
|
<a
|
||||||
|
href={query.attributes.attribution.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{query.attributes.attribution.text}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { QueryExecutionError } from "./query-execution-error";
|
||||||
|
|
||||||
|
describe("QueryExecutionError", () => {
|
||||||
|
it("renders a formatted error alert with the raw query error details", () => {
|
||||||
|
// Given
|
||||||
|
const error =
|
||||||
|
"Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)";
|
||||||
|
|
||||||
|
// When
|
||||||
|
render(<QueryExecutionError error={error} />);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /query execution failed/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/the attack paths query could not be executed/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(error)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CircleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
|
||||||
|
|
||||||
|
interface QueryExecutionErrorProps {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryExecutionError = ({ error }: QueryExecutionErrorProps) => {
|
||||||
|
return (
|
||||||
|
<Alert variant="error">
|
||||||
|
<CircleAlert className="size-4" />
|
||||||
|
<AlertTitle>Query execution failed</AlertTitle>
|
||||||
|
<AlertDescription className="w-full gap-3">
|
||||||
|
<p>The Attack Paths query could not be executed.</p>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -70,4 +70,50 @@ describe("QueryParametersForm", () => {
|
|||||||
screen.queryByText("Tag key to filter the S3 bucket."),
|
screen.queryByText("Tag key to filter the S3 bucket."),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a textarea when the parameter input type is textarea", () => {
|
||||||
|
// Given
|
||||||
|
const customQuery: AttackPathQuery = {
|
||||||
|
type: "attack-paths-scans",
|
||||||
|
id: "custom-query",
|
||||||
|
attributes: {
|
||||||
|
name: "Custom openCypher query",
|
||||||
|
short_description: "Write your own openCypher query",
|
||||||
|
description: "Run a custom query against the graph.",
|
||||||
|
provider: "aws",
|
||||||
|
attribution: null,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
label: "openCypher query",
|
||||||
|
data_type: "string",
|
||||||
|
input_type: "textarea",
|
||||||
|
placeholder: "MATCH (n) RETURN n LIMIT 25",
|
||||||
|
description: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
query: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<QueryParametersForm selectedQuery={customQuery} />
|
||||||
|
</FormProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const input = screen.getByRole("textbox", { name: /opencypher query/i });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(input.tagName).toBe("TEXTAREA");
|
||||||
|
expect(input).toHaveAttribute("data-slot", "textarea");
|
||||||
|
expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { Input } from "@/components/shadcn";
|
import { Input } from "@/components/shadcn";
|
||||||
import type { AttackPathQuery } from "@/types/attack-paths";
|
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type AttackPathQuery,
|
||||||
|
QUERY_PARAMETER_INPUT_TYPES,
|
||||||
|
} from "@/types/attack-paths";
|
||||||
|
|
||||||
interface QueryParametersFormProps {
|
interface QueryParametersFormProps {
|
||||||
selectedQuery: AttackPathQuery | null | undefined;
|
selectedQuery: AttackPathQuery | null | undefined;
|
||||||
@@ -76,8 +81,21 @@ export const QueryParametersForm = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const placeholder =
|
||||||
|
param.description ||
|
||||||
|
param.placeholder ||
|
||||||
|
`Enter ${param.label.toLowerCase()}`;
|
||||||
|
|
||||||
|
const isTextarea =
|
||||||
|
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1.5",
|
||||||
|
isTextarea && "md:col-span-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<label
|
<label
|
||||||
htmlFor={param.name}
|
htmlFor={param.name}
|
||||||
className="text-text-neutral-tertiary text-xs font-medium"
|
className="text-text-neutral-tertiary text-xs font-medium"
|
||||||
@@ -87,17 +105,24 @@ export const QueryParametersForm = ({
|
|||||||
<span className="text-text-error-primary">*</span>
|
<span className="text-text-error-primary">*</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{isTextarea ? (
|
||||||
{...field}
|
<Textarea
|
||||||
id={param.name}
|
{...field}
|
||||||
type={param.data_type === "number" ? "number" : "text"}
|
id={param.name}
|
||||||
placeholder={
|
textareaSize="lg"
|
||||||
param.description ||
|
placeholder={placeholder}
|
||||||
param.placeholder ||
|
value={field.value ?? ""}
|
||||||
`Enter ${param.label.toLowerCase()}`
|
className="min-h-40 font-mono text-xs"
|
||||||
}
|
/>
|
||||||
value={field.value ?? ""}
|
) : (
|
||||||
/>
|
<Input
|
||||||
|
{...field}
|
||||||
|
id={param.name}
|
||||||
|
type={param.data_type === "number" ? "number" : "text"}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<span className="text-xs text-red-500">{errorMessage}</span>
|
<span className="text-xs text-red-500">{errorMessage}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import { Suspense, useEffect, useRef, useState } from "react";
|
|||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildAttackPathQueries,
|
||||||
|
executeCustomQuery,
|
||||||
executeQuery,
|
executeQuery,
|
||||||
getAttackPathScans,
|
getAttackPathScans,
|
||||||
getAvailableQueries,
|
getAvailableQueries,
|
||||||
|
getCartographySchema,
|
||||||
} from "@/actions/attack-paths";
|
} from "@/actions/attack-paths";
|
||||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||||
import { AutoRefresh } from "@/components/scans";
|
import { AutoRefresh } from "@/components/scans";
|
||||||
@@ -35,6 +38,7 @@ import type {
|
|||||||
AttackPathScan,
|
AttackPathScan,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
} from "@/types/attack-paths";
|
} from "@/types/attack-paths";
|
||||||
|
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AttackPathGraph,
|
AttackPathGraph,
|
||||||
@@ -43,6 +47,8 @@ import {
|
|||||||
GraphLegend,
|
GraphLegend,
|
||||||
GraphLoading,
|
GraphLoading,
|
||||||
NodeDetailContent,
|
NodeDetailContent,
|
||||||
|
QueryDescription,
|
||||||
|
QueryExecutionError,
|
||||||
QueryParametersForm,
|
QueryParametersForm,
|
||||||
QuerySelector,
|
QuerySelector,
|
||||||
ScanListTable,
|
ScanListTable,
|
||||||
@@ -138,11 +144,21 @@ export default function AttackPathsPage() {
|
|||||||
|
|
||||||
setQueriesLoading(true);
|
setQueriesLoading(true);
|
||||||
try {
|
try {
|
||||||
const queriesData = await getAvailableQueries(scanId);
|
const [queriesData, schemaData] = await Promise.all([
|
||||||
if (queriesData?.data) {
|
getAvailableQueries(scanId),
|
||||||
setQueries(queriesData.data);
|
getCartographySchema(scanId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const availableQueries = buildAttackPathQueries(
|
||||||
|
queriesData?.data ?? [],
|
||||||
|
schemaData?.data.attributes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableQueries.length > 0) {
|
||||||
|
setQueries(availableQueries);
|
||||||
setQueriesError(null);
|
setQueriesError(null);
|
||||||
} else {
|
} else {
|
||||||
|
setQueries([]);
|
||||||
setQueriesError("Failed to load available queries");
|
setQueriesError("Failed to load available queries");
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -199,15 +215,12 @@ export default function AttackPathsPage() {
|
|||||||
graphState.setError(null);
|
graphState.setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parameters = queryBuilder.getQueryParameters() as Record<
|
const parameters = queryBuilder.getQueryParameters();
|
||||||
string,
|
const isCustomQuery =
|
||||||
string | number | boolean
|
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
|
||||||
>;
|
const result = isCustomQuery
|
||||||
const result = await executeQuery(
|
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
|
||||||
scanId,
|
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
|
||||||
queryBuilder.selectedQuery,
|
|
||||||
parameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result && "error" in result) {
|
if (result && "error" in result) {
|
||||||
const apiError = result as AttackPathQueryError;
|
const apiError = result as AttackPathQueryError;
|
||||||
@@ -384,40 +397,9 @@ export default function AttackPathsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{queryBuilder.selectedQueryData && (
|
{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">
|
<QueryDescription
|
||||||
<div className="flex items-start gap-2">
|
query={queryBuilder.selectedQueryData}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queryBuilder.selectedQuery && (
|
{queryBuilder.selectedQuery && (
|
||||||
@@ -436,9 +418,7 @@ export default function AttackPathsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{graphState.error && (
|
{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">
|
<QueryExecutionError error={graphState.error} />
|
||||||
{graphState.error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ export const DATA_TYPES = {
|
|||||||
|
|
||||||
type DataType = (typeof DATA_TYPES)[keyof typeof 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
|
// Query Types
|
||||||
export interface AttackPathQueryParameter {
|
export interface AttackPathQueryParameter {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -89,6 +101,7 @@ export interface AttackPathQueryParameter {
|
|||||||
description: string;
|
description: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
input_type?: QueryParameterInputType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttackPathQueryAttribution {
|
export interface AttackPathQueryAttribution {
|
||||||
@@ -96,6 +109,11 @@ export interface AttackPathQueryAttribution {
|
|||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttackPathQueryDocumentationLink {
|
||||||
|
text: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttackPathQueryAttributes {
|
export interface AttackPathQueryAttributes {
|
||||||
name: string;
|
name: string;
|
||||||
short_description: string;
|
short_description: string;
|
||||||
@@ -103,6 +121,7 @@ export interface AttackPathQueryAttributes {
|
|||||||
provider: string;
|
provider: string;
|
||||||
parameters: AttackPathQueryParameter[];
|
parameters: AttackPathQueryParameter[];
|
||||||
attribution: AttackPathQueryAttribution | null;
|
attribution: AttackPathQueryAttribution | null;
|
||||||
|
documentation_link?: AttackPathQueryDocumentationLink | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttackPathQuery {
|
export interface AttackPathQuery {
|
||||||
@@ -115,6 +134,24 @@ export interface AttackPathQueriesResponse {
|
|||||||
data: AttackPathQuery[];
|
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
|
// Graph Data Types
|
||||||
// Property values from graph nodes can be any primitive type or arrays
|
// Property values from graph nodes can be any primitive type or arrays
|
||||||
export type GraphNodePropertyValue =
|
export type GraphNodePropertyValue =
|
||||||
@@ -256,3 +293,16 @@ export interface ExecuteQueryRequestData {
|
|||||||
export interface ExecuteQueryRequest {
|
export interface ExecuteQueryRequest {
|
||||||
data: ExecuteQueryRequestData;
|
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