diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 74441a54a1..4db4d48cb0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.23.0] (Prowler UNRELEASED) +### 🔄 Changed + +- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445) + ### 🐞 Fixed - Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index 016abde60e..0b3120bb0a 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -93,7 +93,9 @@ const createCustomQuery = ( description: "", placeholder: CUSTOM_QUERY_PLACEHOLDER, required: true, - input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA, + input_type: QUERY_PARAMETER_INPUT_TYPES.CODE_EDITOR, + editor_language: "openCypher", + requirement_badge: "Read-only*", }, ], }, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx new file mode 100644 index 0000000000..b65591414d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { QueryCodeEditor } from "./query-code-editor"; + +describe("QueryCodeEditor", () => { + it("renders a code editor surface with the compact read-only badge", () => { + // Given + render( + {}} + />, + ); + + // When + const editor = screen.getByRole("textbox", { name: /opencypher/i }); + + // Then + expect(editor).toHaveAttribute("contenteditable", "true"); + expect(screen.getByText("openCypher")).toBeInTheDocument(); + expect(screen.getByText("Read-only*")).toBeInTheDocument(); + expect(screen.getByText("MATCH (n) RETURN n LIMIT 25")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("propagates content changes and exposes the invalid state in the container", async () => { + // Given + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // When + await user.type(screen.getByRole("textbox", { name: /opencypher/i }), "A"); + + // Then + expect(onChange).toHaveBeenCalled(); + expect(screen.getByTestId("query-code-editor")).toHaveClass( + "border-border-error-primary", + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx new file mode 100644 index 0000000000..0f0866c1a2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx @@ -0,0 +1,399 @@ +"use client"; + +import { + HighlightStyle, + StreamLanguage, + syntaxHighlighting, +} from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import CodeMirror, { + EditorView, + highlightActiveLineGutter, + lineNumbers, + placeholder as codeEditorPlaceholder, +} from "@uiw/react-codemirror"; +import { useTheme } from "next-themes"; +import { type HTMLAttributes } from "react"; + +import { Badge } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; + +const OPEN_CYPHER_KEYWORDS = new Set([ + "all", + "and", + "as", + "asc", + "ascending", + "by", + "call", + "case", + "contains", + "create", + "delete", + "desc", + "descending", + "detach", + "distinct", + "else", + "end", + "exists", + "false", + "in", + "is", + "limit", + "match", + "merge", + "not", + "null", + "optional", + "or", + "order", + "remove", + "return", + "set", + "skip", + "then", + "true", + "unwind", + "where", + "with", + "xor", + "yield", +]); + +const OPEN_CYPHER_FUNCTIONS = new Set([ + "collect", + "coalesce", + "count", + "exists", + "head", + "id", + "keys", + "labels", + "last", + "length", + "nodes", + "properties", + "range", + "reduce", + "relationships", + "size", + "startnode", + "sum", + "tail", + "timestamp", + "tolower", + "toupper", + "trim", + "type", +]); + +interface OpenCypherParserState { + inBlockComment: boolean; + inString: "'" | '"' | null; +} + +const openCypherLanguage = StreamLanguage.define({ + startState() { + return { + inBlockComment: false, + inString: null, + }; + }, + token(stream, state) { + if (state.inBlockComment) { + while (!stream.eol()) { + if (stream.match("*/")) { + state.inBlockComment = false; + break; + } + stream.next(); + } + + return "comment"; + } + + if (state.inString) { + let escaped = false; + + while (!stream.eol()) { + const next = stream.next(); + + if (escaped) { + escaped = false; + continue; + } + + if (next === "\\") { + escaped = true; + continue; + } + + if (next === state.inString) { + state.inString = null; + break; + } + } + + // OpenCypher only supports single-line strings — reset at EOL so an + // unclosed quote does not bleed into subsequent lines. + if (stream.eol()) { + state.inString = null; + } + + return "string"; + } + + if (stream.eatSpace()) { + return null; + } + + if (stream.match("//")) { + stream.skipToEnd(); + return "comment"; + } + + if (stream.match("/*")) { + state.inBlockComment = true; + return "comment"; + } + + const quote = stream.peek(); + if (quote === "'" || quote === '"') { + state.inString = quote; + stream.next(); + return "string"; + } + + if (stream.match(/\$[A-Za-z_][\w]*/)) { + return "variableName"; + } + + if (stream.match(/:[A-Za-z_][\w]*/)) { + return "typeName"; + } + + if (stream.match(/[()[\]{},.;]/)) { + return "punctuation"; + } + + if (stream.match(/[<>!=~|&+\-/*%^]+/)) { + return "operator"; + } + + if (stream.match(/\d+(?:\.\d+)?/)) { + return "number"; + } + + if (stream.match(/[A-Za-z_][\w]*/)) { + const currentValue = stream.current(); + const normalizedValue = currentValue.toLowerCase(); + + if (OPEN_CYPHER_KEYWORDS.has(normalizedValue)) { + return "keyword"; + } + + if ( + OPEN_CYPHER_FUNCTIONS.has(normalizedValue) && + stream.match(/\s*(?=\()/, false) + ) { + return "function"; + } + + return "variableName"; + } + + stream.next(); + return null; + }, +}); + +const lightHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#0550ae", fontWeight: "600" }, + { tag: tags.string, color: "#0a3069" }, + { tag: tags.number, color: "#8250df" }, + { tag: [tags.typeName, tags.className], color: "#953800" }, + { tag: [tags.variableName, tags.propertyName], color: "#24292f" }, + { tag: tags.function(tags.variableName), color: "#8250df" }, + { tag: tags.operator, color: "#57606a" }, + { tag: tags.comment, color: "#6e7781", fontStyle: "italic" }, + { tag: tags.punctuation, color: "#57606a" }, +]); + +const darkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#79c0ff", fontWeight: "600" }, + { tag: tags.string, color: "#a5d6ff" }, + { tag: tags.number, color: "#d2a8ff" }, + { tag: [tags.typeName, tags.className], color: "#ffa657" }, + { tag: [tags.variableName, tags.propertyName], color: "#e6edf3" }, + { tag: tags.function(tags.variableName), color: "#d2a8ff" }, + { tag: tags.operator, color: "#8b949e" }, + { tag: tags.comment, color: "#8b949e", fontStyle: "italic" }, + { tag: tags.punctuation, color: "#8b949e" }, +]); + +const MONO_FONT = + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; + +const baseThemeRules: Record> = { + "&": { + backgroundColor: "transparent", + color: "var(--text-neutral-primary)", + fontFamily: MONO_FONT, + fontSize: "12px", + }, + "&.cm-focused": { + outline: "none", + }, + ".cm-scroller": { + minHeight: "320px", + overflow: "auto", + fontFamily: MONO_FONT, + lineHeight: "1.5rem", + }, + ".cm-content": { + padding: "16px", + caretColor: "var(--text-neutral-primary)", + }, + ".cm-line": { + padding: "0 0 0 8px", + }, + ".cm-gutters": { + backgroundColor: "var(--bg-neutral-secondary)", + color: "var(--text-neutral-tertiary)", + borderRight: "1px solid var(--border-neutral-secondary)", + minWidth: "44px", + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 10px 0 12px", + }, + ".cm-activeLineGutter": { + backgroundColor: "var(--bg-neutral-secondary)", + color: "var(--text-neutral-secondary)", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--text-neutral-primary)", + }, + ".cm-placeholder": { + color: "var(--text-neutral-tertiary)", + }, +}; + +const LIGHT_SELECTION_BG = "rgba(9, 105, 218, 0.18)"; +const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)"; + +const lightTheme = EditorView.theme( + { + ...baseThemeRules, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": + { backgroundColor: LIGHT_SELECTION_BG }, + }, + { dark: false }, +); + +const darkTheme = EditorView.theme( + { + ...baseThemeRules, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": + { backgroundColor: DARK_SELECTION_BG }, + }, + { dark: true }, +); + +interface QueryCodeEditorProps + extends Omit, "onChange"> { + ariaLabel: string; + language?: "openCypher"; + value: string; + placeholder?: string; + invalid?: boolean; + requirementBadge?: string; + onChange: (value: string) => void; + onBlur?: () => void; +} + +export const QueryCodeEditor = ({ + id, + className, + ariaLabel, + language = "openCypher", + value, + placeholder, + invalid = false, + requirementBadge, + onChange, + onBlur, + ...props +}: QueryCodeEditorProps) => { + const { resolvedTheme } = useTheme(); + const isDarkMode = resolvedTheme === "dark"; + const editorTheme = isDarkMode ? darkTheme : lightTheme; + const editorHighlightStyle = isDarkMode + ? darkHighlightStyle + : lightHighlightStyle; + + return ( +
+
+ + {ariaLabel} + + {requirementBadge ? ( + + {requirementBadge} + + ) : null} +
+ + { + onBlur?.(); + return false; + }, + }), + ]} + /> +
+ ); +}; 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 694da8babe..d8493f4426 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 @@ -61,10 +61,12 @@ function TestCustomQueryForm() { name: "query", label: "openCypher", data_type: "string", - input_type: "textarea", + input_type: "code-editor", placeholder: "MATCH (n) RETURN n LIMIT 25", description: "", required: true, + editor_language: "openCypher", + requirement_badge: "Read-only*", }, ], }, @@ -133,7 +135,7 @@ describe("QueryParametersForm", () => { ).not.toBeInTheDocument(); }); - it("renders a textarea when the parameter input type is textarea", () => { + it("renders a code editor when the parameter input type is code-editor", () => { // Given render(); @@ -142,25 +144,18 @@ describe("QueryParametersForm", () => { 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(input.tagName).toBe("DIV"); + expect(input).toHaveAttribute("contenteditable", "true"); + expect(codeEditor).toHaveAttribute("data-language", "openCypher"); expect(codeEditor).toHaveClass( "rounded-xl", "border", "bg-bg-neutral-primary", ); - expect(screen.getByText("Read-only")).toBeInTheDocument(); + expect(screen.getByText("Read-only*")).toBeInTheDocument(); + expect(screen.getByText("openCypher")).toBeInTheDocument(); + expect(screen.queryByText("openCypher*")).not.toBeInTheDocument(); + expect(screen.queryByText("Read-only")).not.toBeInTheDocument(); }); it("uses the design-system error token for field validation messages", async () => { 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 6ac5883e66..94edbf2a68 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 @@ -12,11 +12,12 @@ import { } from "@/components/ui/form"; import { cn } from "@/lib/utils"; import { - ATTACK_PATH_QUERY_IDS, type AttackPathQuery, QUERY_PARAMETER_INPUT_TYPES, } from "@/types/attack-paths"; +import { QueryCodeEditor } from "./query-code-editor"; + interface QueryParametersFormProps { selectedQuery: AttackPathQuery | null | undefined; } @@ -88,19 +89,17 @@ export const QueryParametersForm = ({ const isTextarea = param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA; - const isCustomCodeEditor = - selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM && - param.name === "query" && - isTextarea; + const isCodeEditor = + param.input_type === QUERY_PARAMETER_INPUT_TYPES.CODE_EDITOR; return ( - {!isCustomCodeEditor && ( + {!isCodeEditor && ( {param.label} {param.required && ( @@ -108,42 +107,19 @@ export const QueryParametersForm = ({ )} )} - {isCustomCodeEditor ? ( -
-
- - {param.label} - {param.required && ( - * - )} - - - Read-only - -
- -