feat(ui): add syntax highlighting to finding groups remediation code (#10698)

This commit is contained in:
Alan Buscaglia
2026-04-15 12:58:35 +02:00
committed by GitHub
parent a96e5890dc
commit d5354e8b1d
4 changed files with 883 additions and 7 deletions
+1
View File
@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673)
- Syntax highlighting for remediation code blocks in finding groups drawer with provider-aware auto-detection (Shell, HCL, YAML, Bicep) [(#10698)](https://github.com/prowler-cloud/prowler/pull/10698)
### 🐞 Fixed
@@ -178,16 +178,30 @@ vi.mock("@/components/findings/markdown-container", () => ({
}));
vi.mock("@/components/shared/query-code-editor", () => ({
QUERY_EDITOR_LANGUAGE: {
OPEN_CYPHER: "openCypher",
PLAIN_TEXT: "plainText",
SHELL: "shell",
HCL: "hcl",
BICEP: "bicep",
YAML: "yaml",
},
QueryCodeEditor: ({
ariaLabel,
language,
value,
copyValue,
}: {
ariaLabel: string;
language?: string;
value: string;
copyValue?: string;
}) => (
<div data-testid="query-code-editor" data-aria-label={ariaLabel}>
<div
data-testid="query-code-editor"
data-aria-label={ariaLabel}
data-language={language}
>
<span>{ariaLabel}</span>
<span>{value}</span>
<button
@@ -512,6 +526,34 @@ describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", ()
expect(mockClipboardWriteText).toHaveBeenCalledWith("aws s3 ...");
expect(screen.getByText("$ aws s3 ...")).toBeInTheDocument();
});
it("should pass syntax highlighting languages to each remediation editor", () => {
// Given
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={checkMetaWithCommands}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const editors = screen.getAllByTestId("query-code-editor");
// Then
expect(editors[0]).toHaveAttribute("data-language", "shell");
expect(editors[1]).toHaveAttribute("data-language", "hcl");
expect(editors[2]).toHaveAttribute("data-language", "yaml");
expect(editors[0]).toHaveAttribute("data-aria-label", "CLI Command");
expect(editors[2]).toHaveAttribute("data-aria-label", "CloudFormation");
});
});
// ---------------------------------------------------------------------------
@@ -44,7 +44,11 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { QueryCodeEditor } from "@/components/shared/query-code-editor";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
type QueryEditorLanguage,
} from "@/components/shared/query-code-editor";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
@@ -81,19 +85,49 @@ function stripCodeFences(code: string): string {
.trim();
}
function resolveNativeIacConfig(providerType: string | undefined): {
label: string;
language: QueryEditorLanguage;
} {
switch (providerType) {
case "aws":
return {
label: "CloudFormation",
language: QUERY_EDITOR_LANGUAGE.YAML,
};
case "azure":
return {
label: "Bicep",
language: QUERY_EDITOR_LANGUAGE.BICEP,
};
case "kubernetes":
return {
label: "Kubernetes Manifest",
language: QUERY_EDITOR_LANGUAGE.YAML,
};
default:
return {
label: "Native IaC",
language: QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
};
}
}
function renderRemediationCodeBlock({
label,
value,
copyValue,
language = QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
}: {
label: string;
value: string;
copyValue?: string;
language?: QueryEditorLanguage;
}) {
return (
<QueryCodeEditor
ariaLabel={label}
language="plainText"
language={language}
value={value}
copyValue={copyValue}
editable={false}
@@ -351,6 +385,7 @@ export function ResourceDetailDrawerContent({
? (f?.scan?.id ?? null)
: null;
const regionFilter = searchParams.get("filter[region__in]");
const nativeIacConfig = resolveNativeIacConfig(f?.providerType);
const handleOpenCompliance = async (framework: string) => {
if (!complianceScanId || resolvingFramework) {
@@ -741,6 +776,7 @@ export function ResourceDetailDrawerContent({
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: "CLI Command",
language: QUERY_EDITOR_LANGUAGE.SHELL,
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
copyValue: stripCodeFences(
checkMeta.remediation.code.cli,
@@ -753,6 +789,7 @@ export function ResourceDetailDrawerContent({
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: "Terraform",
language: QUERY_EDITOR_LANGUAGE.HCL,
value: stripCodeFences(
checkMeta.remediation.code.terraform,
),
@@ -760,10 +797,11 @@ export function ResourceDetailDrawerContent({
</div>
)}
{checkMeta.remediation.code.nativeiac && (
{checkMeta.remediation.code.nativeiac && f && (
<div className="flex flex-col gap-1">
{renderRemediationCodeBlock({
label: "CloudFormation",
label: nativeIacConfig.label,
language: nativeIacConfig.language,
value: stripCodeFences(
checkMeta.remediation.code.nativeiac,
),
+797 -2
View File
@@ -3,6 +3,7 @@
import {
HighlightStyle,
StreamLanguage,
type StringStream,
syntaxHighlighting,
} from "@codemirror/language";
import { EditorState } from "@codemirror/state";
@@ -20,12 +21,16 @@ import { type HTMLAttributes, useState } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
const QUERY_EDITOR_LANGUAGE = {
export const QUERY_EDITOR_LANGUAGE = {
OPEN_CYPHER: "openCypher",
PLAIN_TEXT: "plainText",
SHELL: "shell",
HCL: "hcl",
BICEP: "bicep",
YAML: "yaml",
} as const;
type QueryEditorLanguage =
export type QueryEditorLanguage =
(typeof QUERY_EDITOR_LANGUAGE)[keyof typeof QUERY_EDITOR_LANGUAGE];
const OPEN_CYPHER_KEYWORDS = new Set([
@@ -98,11 +103,204 @@ const OPEN_CYPHER_FUNCTIONS = new Set([
"type",
]);
const SHELL_KEYWORDS = new Set([
"if",
"then",
"else",
"elif",
"fi",
"for",
"in",
"do",
"done",
"while",
"until",
"case",
"esac",
"function",
"return",
"export",
"local",
"readonly",
"declare",
"typeset",
"unset",
"shift",
"break",
"continue",
"select",
"time",
"trap",
]);
const SHELL_COMMANDS = new Set([
"aws",
"az",
"gcloud",
"kubectl",
"terraform",
"echo",
"grep",
"sed",
"awk",
"curl",
"wget",
"chmod",
"chown",
"mkdir",
"rm",
"cp",
"mv",
"cat",
"ls",
]);
const HCL_KEYWORDS = new Set([
"resource",
"data",
"variable",
"output",
"locals",
"module",
"provider",
"terraform",
"backend",
"required_providers",
"dynamic",
"for_each",
"count",
"depends_on",
"lifecycle",
"provisioner",
"connection",
]);
const HCL_FUNCTIONS = new Set([
"lookup",
"merge",
"join",
"split",
"length",
"element",
"concat",
"format",
"replace",
"regex",
"tolist",
"tomap",
"toset",
"try",
"can",
"file",
"templatefile",
"jsonencode",
"jsondecode",
"yamlencode",
"yamldecode",
"base64encode",
"base64decode",
"md5",
"sha256",
"cidrsubnet",
"cidrhost",
]);
const BICEP_KEYWORDS = new Set([
"resource",
"module",
"param",
"var",
"output",
"type",
"metadata",
"import",
"using",
"extension",
"targetScope",
"existing",
"if",
"for",
"in",
"true",
"false",
"null",
]);
const BICEP_DECORATORS = new Set([
"description",
"secure",
"minLength",
"maxLength",
"minValue",
"maxValue",
"allowed",
"metadata",
"batchSize",
"sys",
]);
const BICEP_FUNCTIONS = new Set([
"concat",
"format",
"toLower",
"toUpper",
"substring",
"replace",
"split",
"join",
"length",
"contains",
"empty",
"first",
"last",
"indexOf",
"array",
"union",
"intersection",
"resourceId",
"subscriptionResourceId",
"tenantResourceId",
"reference",
"listKeys",
"listAccountSas",
"uniqueString",
"guid",
"base64",
"uri",
"environment",
"subscription",
"resourceGroup",
"tenant",
]);
interface OpenCypherParserState {
inBlockComment: boolean;
inString: "'" | '"' | null;
}
interface ShellParserState {
inString: "'" | '"' | null;
}
interface HclParserState {
inBlockComment: boolean;
inString: '"' | null;
expectBlockType: boolean;
heredocTerminator: string | null;
}
interface BicepParserState {
inBlockComment: boolean;
inString: "'" | null;
expectResourceType: boolean;
}
interface YamlParserState {
inString: "'" | '"' | null;
inBlockScalar: boolean;
blockScalarIndent: number;
}
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
startState() {
return {
@@ -216,6 +414,595 @@ const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
},
});
const shellLanguage = StreamLanguage.define<ShellParserState>({
startState() {
return {
inString: null,
};
},
token(stream, state) {
if (state.inString) {
let escaped = false;
while (!stream.eol()) {
const next = stream.next();
if (escaped) {
escaped = false;
continue;
}
if (next === "\\" && state.inString === '"') {
escaped = true;
continue;
}
if (next === state.inString) {
state.inString = null;
break;
}
}
if (stream.eol()) {
state.inString = null;
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.peek() === "#") {
stream.skipToEnd();
return "comment";
}
if (stream.match(/\$\([^)]+\)/)) {
return "variableName";
}
if (stream.match(/\$\{[A-Za-z_][\w]*\}/)) {
return "variableName";
}
if (stream.match(/\$[A-Za-z_][\w]*/)) {
return "variableName";
}
const quote = stream.peek();
if (quote === "'" || quote === '"') {
state.inString = quote;
stream.next();
return "string";
}
if (stream.match(/--[A-Za-z0-9][\w-]*/)) {
return "operator";
}
if (stream.match(/-[A-Za-z0-9]+/)) {
return "operator";
}
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 (SHELL_KEYWORDS.has(normalizedValue)) {
return "keyword";
}
if (SHELL_COMMANDS.has(normalizedValue)) {
return "function";
}
return "variableName";
}
stream.next();
return null;
},
});
const hclLanguage = StreamLanguage.define<HclParserState>({
startState() {
return {
inBlockComment: false,
inString: null,
expectBlockType: false,
heredocTerminator: null,
};
},
token(stream, state) {
if (state.heredocTerminator) {
// Match the closing terminator on its own line, including indented <<-EOF forms.
if (
stream.sol() &&
stream.match(new RegExp(`^\\s*${state.heredocTerminator}\\s*$`))
) {
state.heredocTerminator = null;
return "keyword";
}
stream.skipToEnd();
return "string";
}
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;
}
}
if (stream.eol()) {
state.inString = null;
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.match("#") || stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match("/*")) {
state.inBlockComment = true;
return "comment";
}
if (stream.match(/\$\{[^}]+\}/)) {
return "variableName";
}
if (stream.peek() === '"') {
if (state.expectBlockType) {
state.expectBlockType = false;
stream.next(); // opening "
while (!stream.eol()) {
const ch = stream.next();
if (ch === "\\") {
stream.next(); // skip escaped char
} else if (ch === '"') {
break;
}
}
return "typeName";
}
state.inString = '"';
stream.next();
return "string";
}
if (stream.match(/[{}\[\]()]/)) {
return "punctuation";
}
// Heredoc (<<EOF, <<-EOF) — must be before generic operator matcher
if (stream.match(/<<-?([A-Za-z_][\w]*)/)) {
state.heredocTerminator = stream.current().replace(/^<<-?/, "");
return "keyword";
}
if (stream.match(/=>|\.\.\.|==|!=|>=|<=|[=><?:]/)) {
return "operator";
}
if (stream.match(/\b(?:true|false)\b/)) {
return "keyword";
}
if (stream.match(/\d+(?:\.\d+)?/)) {
return "number";
}
if (stream.match(/[A-Za-z_][\w-]*/)) {
const currentValue = stream.current();
const normalizedValue = currentValue.toLowerCase();
if (HCL_KEYWORDS.has(normalizedValue)) {
state.expectBlockType =
normalizedValue === "resource" || normalizedValue === "data";
return "keyword";
}
if (
HCL_FUNCTIONS.has(normalizedValue) &&
stream.match(/\s*(?=\()/, false)
) {
return "function";
}
if (state.expectBlockType) {
state.expectBlockType = false;
return "typeName";
}
if (stream.match(/\s*(?==)/, false)) {
return "propertyName";
}
return "variableName";
}
stream.next();
return null;
},
});
const yamlLanguage = StreamLanguage.define<YamlParserState>({
startState() {
return {
inString: null,
inBlockScalar: false,
blockScalarIndent: 0,
};
},
token(stream, state) {
// Block scalar continuation (| or > multiline strings)
if (state.inBlockScalar) {
// Blank lines are always part of a block scalar.
if (stream.match(/^\s*$/)) {
stream.skipToEnd();
return "string";
}
const indent = stream.indentation();
if (indent > state.blockScalarIndent) {
stream.skipToEnd();
return "string";
}
state.inBlockScalar = false;
state.blockScalarIndent = 0;
}
// Continue quoted strings across tokens
if (state.inString) {
let escaped = false;
while (!stream.eol()) {
const next = stream.next();
if (escaped) {
escaped = false;
continue;
}
if (next === "\\" && state.inString === '"') {
escaped = true;
continue;
}
if (next === state.inString) {
state.inString = null;
break;
}
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
// Comments
if (stream.peek() === "#") {
stream.skipToEnd();
return "comment";
}
// Document markers
if (stream.sol() && (stream.match("---") || stream.match("..."))) {
return "keyword";
}
// Anchors & aliases
if (stream.match(/[&*][A-Za-z_][\w]*/)) {
return "variableName";
}
// CloudFormation intrinsic tags (!Ref, !Sub, !GetAtt, etc.)
if (stream.match(/![A-Za-z][A-Za-z0-9]*/)) {
return "typeName";
}
// Tags (!!str, !!map, etc.)
if (stream.match(/!![A-Za-z]+/)) {
return "typeName";
}
// Quoted strings
const quote = stream.peek();
if (quote === "'" || quote === '"') {
state.inString = quote;
stream.next();
return "string";
}
// Block scalar indicators (| or >)
if (
stream.sol() === false &&
(stream.peek() === "|" || stream.peek() === ">")
) {
const prevChar = stream.string.charAt(stream.pos - 1);
if (prevChar === " " || prevChar === ":") {
stream.next();
// Eat optional modifiers like |-, |+, |2
stream.match(/[-+]?\d?/);
state.inBlockScalar = true;
state.blockScalarIndent = stream.indentation();
return "operator";
}
}
// List item marker
if (stream.match(/^-(?=\s)/)) {
return "punctuation";
}
// Key: value pattern — supports CloudFormation long-form intrinsics (Fn::Sub:)
if (stream.match(/[A-Za-z_][\w./-]*(?:::[A-Za-z_][\w]*)*(?=\s*:(?!:))/)) {
return "propertyName";
}
// Booleans & null (YAML spec values)
if (stream.match(/\b(?:true|false|yes|no|on|off|null)\b/i)) {
return "keyword";
}
// Numbers (integers, floats, hex, octal)
if (
stream.match(
/^[-+]?(?:0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/,
)
) {
return "number";
}
// Colon separator
if (stream.match(":")) {
return "punctuation";
}
// Braces/brackets (flow style)
if (stream.match(/[{}\[\],]/)) {
return "punctuation";
}
// Tilde (null alias)
if (stream.match("~")) {
return "keyword";
}
// Unquoted strings / values — consume word
if (stream.match(/[^\s#:,\[\]{}]+/)) {
return "string";
}
stream.next();
return null;
},
});
function readBicepStringSegment(
stream: StringStream,
includeOpeningQuote = false,
) {
if (includeOpeningQuote) {
stream.next();
}
while (!stream.eol()) {
if (stream.match("${")) {
stream.backUp(2);
break;
}
const next = stream.next();
if (next === "'" && stream.peek() === "'") {
stream.next();
continue;
}
if (next === "'") {
stream.backUp(1);
break;
}
}
}
const bicepLanguage = StreamLanguage.define<BicepParserState>({
startState() {
return {
inBlockComment: false,
inString: null,
expectResourceType: false,
};
},
token(stream, state) {
if (state.inBlockComment) {
while (!stream.eol()) {
if (stream.match("*/")) {
state.inBlockComment = false;
break;
}
stream.next();
}
return "comment";
}
if (state.inString) {
if (stream.match("${")) {
let depth = 1;
while (!stream.eol() && depth > 0) {
const next = stream.next();
if (next === "{") {
depth += 1;
continue;
}
if (next === "}") {
depth -= 1;
}
}
return "variableName";
}
readBicepStringSegment(stream);
if (stream.peek() !== "'") {
return "string";
}
if (stream.peek() === "'") {
state.inString = null;
stream.next();
}
return "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match("/*")) {
state.inBlockComment = true;
return "comment";
}
if (stream.match(/@[A-Za-z_][\w]*/)) {
const decorator = stream.current().slice(1);
if (BICEP_DECORATORS.has(decorator)) {
return "keyword";
}
return "keyword";
}
if (stream.peek() === "'") {
if (state.expectResourceType) {
state.expectResourceType = false;
// Consume the full quoted resource type including both quotes
stream.next(); // opening '
while (!stream.eol()) {
const ch = stream.next();
if (ch === "'" && stream.peek() === "'") {
stream.next(); // escaped ''
continue;
}
if (ch === "'") {
break; // closing '
}
}
return "typeName";
}
stream.next(); // consume opening '
state.inString = "'";
return "string";
}
if (stream.match(/[A-Za-z_][\w-]*(?=\s*:)/)) {
return "propertyName";
}
if (stream.match(/\?\?|==|!=|>=|<=|=|>|<|\?|:|!/)) {
return "operator";
}
if (stream.match(/[{}\[\]()]/)) {
return "punctuation";
}
if (stream.match(/-?\d+(?:\.\d+)?/)) {
return "number";
}
if (stream.match(/[A-Za-z_][\w]*/)) {
const currentValue = stream.current();
if (BICEP_KEYWORDS.has(currentValue)) {
state.expectResourceType =
currentValue === "resource" || currentValue === "module";
return "keyword";
}
if (
BICEP_FUNCTIONS.has(currentValue) &&
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" },
@@ -380,6 +1167,14 @@ export const QueryCodeEditor = ({
openCypherLanguage,
syntaxHighlighting(editorHighlightStyle),
);
} else if (language === QUERY_EDITOR_LANGUAGE.SHELL) {
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
extensions.push(yamlLanguage, syntaxHighlighting(editorHighlightStyle));
}
const handleCopy = async () => {