diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2c17bcc5d6..c20aa5bf99 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts new file mode 100644 index 0000000000..cd6bafd206 --- /dev/null +++ b/ui/actions/attack-paths/queries.adapter.test.ts @@ -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); + }); +}); diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index fd256739e1..016abde60e 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -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]; +}; diff --git a/ui/actions/attack-paths/queries.test.ts b/ui/actions/attack-paths/queries.test.ts index 6c2be5f15d..ab3afc447f 100644 --- a/ui/actions/attack-paths/queries.test.ts +++ b/ui/actions/attack-paths/queries.test.ts @@ -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); + }); +}); diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts index bc9068d52e..228c1cca0c 100644 --- a/ui/actions/attack-paths/queries.ts +++ b/ui/actions/attack-paths/queries.ts @@ -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 => { + 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; + } +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts index eac86fccc7..83161cc4ef 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -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"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx new file mode 100644 index 0000000000..cc61c16a4d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx @@ -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(); + + // 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(); + + // 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(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx new file mode 100644 index 0000000000..d1cb6e85fe --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx @@ -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 ( + + + +

{query.attributes.description}

+ + {documentationLink && ( +

+ {isSafeUrl(documentationLink.link) ? ( + + {documentationLink.text} + + ) : ( + {documentationLink.text} + )} +

+ )} + + {attribution && ( +

+ {isSafeUrl(attribution.link) ? ( + <> + Source:{" "} + + {attribution.text} + + + ) : ( + <> + Source: {attribution.text} + + )} +

+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx new file mode 100644 index 0000000000..1c7fb29712 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx @@ -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(); + + // 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( + , + ); + + // 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(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx new file mode 100644 index 0000000000..084bcf6e16 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -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 ( + + + {title} + + {description ?

{description}

: null} +
+
+            {error}
+          
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx index ac2b81be7f..694da8babe 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx @@ -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 ( + + + + ); +} + +function TestFormWithError() { + const form = useForm({ + defaultValues: { + tag_key: "", + }, + }); + + useEffect(() => { + form.setError("tag_key", { + type: "manual", + message: "Tag key is required", + }); + }, [form]); + + return ( + + + + ); +} + 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(); + + // 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(); + + // 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(); + + // 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") ?? ""), + ); + }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx index 24937c0b7b..6ac5883e66 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx @@ -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) => ( - { + render={({ field, fieldState }) => { if (param.data_type === "boolean") { return ( -
+ -
+ + ); } - 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 ( -
- - - {errorMessage && ( - {errorMessage} + + > + {!isCustomCodeEditor && ( + + {param.label} + {param.required && ( + * + )} + + )} + {isCustomCodeEditor ? ( +
+
+ + {param.label} + {param.required && ( + * + )} + + + Read-only + +
+ +