mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-17 03:21:51 -05:00
fix: updated encryption algorithm and added sort function (#5485)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
@@ -22,6 +22,10 @@ export const GET = async (
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (survey.type !== "link") {
|
||||
return responses.badRequestResponse("Single use links are only available for link surveys");
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
return responses.badRequestResponse("Single use links are not enabled for this survey");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricEncrypt: vi.fn(),
|
||||
symmetricDecrypt: vi.fn(),
|
||||
decryptAES128: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
|
||||
59
apps/web/lib/crypto.test.ts
Normal file
59
apps/web/lib/crypto.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createCipheriv, randomBytes } from "crypto";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
generateLocalSignedUrl,
|
||||
getHash,
|
||||
symmetricDecrypt,
|
||||
symmetricEncrypt,
|
||||
validateLocalSignedUrl,
|
||||
} from "./crypto";
|
||||
|
||||
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
|
||||
|
||||
const key = "0".repeat(32);
|
||||
const plain = "hello";
|
||||
|
||||
describe("crypto", () => {
|
||||
test("encrypt + decrypt roundtrip", () => {
|
||||
const cipher = symmetricEncrypt(plain, key);
|
||||
expect(symmetricDecrypt(cipher, key)).toBe(plain);
|
||||
});
|
||||
|
||||
test("decrypt V2 GCM payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
expect(symmetricDecrypt(payload, key)).toBe(plain);
|
||||
});
|
||||
|
||||
test("decrypt legacy (single-colon) payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const legacy = `${iv.toString("hex")}:${enc}`;
|
||||
expect(symmetricDecrypt(legacy, key)).toBe(plain);
|
||||
});
|
||||
|
||||
test("getHash returns a non-empty string", () => {
|
||||
const h = getHash("abc");
|
||||
expect(typeof h).toBe("string");
|
||||
expect(h.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("signed URL generation & validation", () => {
|
||||
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
|
||||
expect(uuid).toHaveLength(32);
|
||||
expect(typeof timestamp).toBe("number");
|
||||
expect(typeof signature).toBe("string");
|
||||
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
|
||||
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
|
||||
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import crypto from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "./constants";
|
||||
|
||||
const ALGORITHM = "aes256";
|
||||
const ALGORITHM_V1 = "aes256";
|
||||
const ALGORITHM_V2 = "aes-256-gcm";
|
||||
const INPUT_ENCODING = "utf8";
|
||||
const OUTPUT_ENCODING = "hex";
|
||||
const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex";
|
||||
const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex";
|
||||
const IV_LENGTH = 16; // AES blocksize
|
||||
|
||||
/**
|
||||
@@ -17,15 +18,12 @@ const IV_LENGTH = 16; // AES blocksize
|
||||
*/
|
||||
export const symmetricEncrypt = (text: string, key: string) => {
|
||||
const _key = Buffer.from(key, BUFFER_ENCODING);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// @ts-ignore -- the package needs to be built
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM_V2, _key, iv);
|
||||
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
|
||||
ciphered += cipher.final(OUTPUT_ENCODING);
|
||||
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
|
||||
|
||||
return ciphertext;
|
||||
const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING);
|
||||
return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -33,38 +31,68 @@ export const symmetricEncrypt = (text: string, key: string) => {
|
||||
* @param text Value to decrypt
|
||||
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
|
||||
*/
|
||||
export const symmetricDecrypt = (text: string, key: string) => {
|
||||
|
||||
const symmetricDecryptV1 = (text: string, key: string): string => {
|
||||
const _key = Buffer.from(key, BUFFER_ENCODING);
|
||||
|
||||
const components = text.split(":");
|
||||
const iv_from_ciphertext = Buffer.from(components.shift() || "", OUTPUT_ENCODING);
|
||||
// @ts-ignore -- the package needs to be built
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
|
||||
const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING);
|
||||
const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext);
|
||||
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
|
||||
deciphered += decipher.final(INPUT_ENCODING);
|
||||
|
||||
return deciphered;
|
||||
};
|
||||
|
||||
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
/**
|
||||
*
|
||||
* @param text Value to decrypt
|
||||
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
|
||||
*/
|
||||
|
||||
// create an aes128 encryption function
|
||||
export const encryptAES128 = (encryptionKey: string, data: string): string => {
|
||||
// @ts-ignore -- the package needs to be built
|
||||
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
|
||||
let encrypted = cipher.update(data, "utf-8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return encrypted;
|
||||
};
|
||||
// create an aes128 decryption function
|
||||
export const decryptAES128 = (encryptionKey: string, data: string): string => {
|
||||
// @ts-ignore -- the package needs to be built
|
||||
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
|
||||
let decrypted = cipher.update(data, "hex", "utf-8");
|
||||
decrypted += cipher.final("utf-8");
|
||||
const symmetricDecryptV2 = (text: string, key: string): string => {
|
||||
// split into [ivHex, encryptedHex, tagHex]
|
||||
const [ivHex, encryptedHex, tagHex] = text.split(":");
|
||||
const _key = Buffer.from(key, BUFFER_ENCODING);
|
||||
const iv = Buffer.from(ivHex, OUTPUT_ENCODING);
|
||||
const decipher = createDecipheriv(ALGORITHM_V2, _key, iv);
|
||||
decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING));
|
||||
let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING);
|
||||
decrypted += decipher.final(INPUT_ENCODING);
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypts an encrypted payload, automatically handling multiple encryption versions.
|
||||
*
|
||||
* If the payload contains exactly one “:”, it is treated as a legacy V1 format
|
||||
* and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption
|
||||
* via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication
|
||||
* errors or bad formats).
|
||||
*
|
||||
* @param payload - The encrypted string to decrypt.
|
||||
* @param key - The secret key used for decryption.
|
||||
* @returns The decrypted plaintext.
|
||||
*/
|
||||
|
||||
export function symmetricDecrypt(payload: string, key: string): string {
|
||||
// If it's clearly V1 (only one “:”), skip straight to V1
|
||||
if (payload.split(":").length === 2) {
|
||||
return symmetricDecryptV1(payload, key);
|
||||
}
|
||||
|
||||
// Otherwise try GCM first, then fall back to CBC
|
||||
try {
|
||||
return symmetricDecryptV2(payload, key);
|
||||
} catch (err) {
|
||||
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const generateLocalSignedUrl = (
|
||||
fileName: string,
|
||||
environmentId: string,
|
||||
@@ -73,7 +101,7 @@ export const generateLocalSignedUrl = (
|
||||
const uuid = randomBytes(16).toString("hex");
|
||||
const timestamp = Date.now();
|
||||
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
|
||||
const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex");
|
||||
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
|
||||
return { signature, uuid, timestamp };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TTemplate, TTemplateFilter } from "@formbricks/types/templates";
|
||||
import { TemplateTags, getRoleBasedStyling } from "./template-tags";
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
getRoleMapping: () => [{ value: "marketing", label: "Marketing" }],
|
||||
getChannelMapping: () => [
|
||||
{ value: "email", label: "Email Survey" },
|
||||
{ value: "chat", label: "Chat Survey" },
|
||||
{ value: "sms", label: "SMS Survey" },
|
||||
],
|
||||
getIndustryMapping: () => [
|
||||
{ value: "indA", label: "Industry A" },
|
||||
{ value: "indB", label: "Industry B" },
|
||||
],
|
||||
}));
|
||||
|
||||
const baseTemplate = {
|
||||
role: "marketing",
|
||||
channels: ["email"],
|
||||
industries: ["indA"],
|
||||
preset: { questions: [] },
|
||||
} as unknown as TTemplate;
|
||||
|
||||
const noFilter: TTemplateFilter[] = [null, null];
|
||||
|
||||
describe("TemplateTags", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getRoleBasedStyling for productManager", () => {
|
||||
expect(getRoleBasedStyling("productManager")).toBe("border-blue-300 bg-blue-50 text-blue-500");
|
||||
});
|
||||
|
||||
test("getRoleBasedStyling for sales", () => {
|
||||
expect(getRoleBasedStyling("sales")).toBe("border-emerald-300 bg-emerald-50 text-emerald-500");
|
||||
});
|
||||
|
||||
test("getRoleBasedStyling for customerSuccess", () => {
|
||||
expect(getRoleBasedStyling("customerSuccess")).toBe("border-violet-300 bg-violet-50 text-violet-500");
|
||||
});
|
||||
|
||||
test("getRoleBasedStyling for peopleManager", () => {
|
||||
expect(getRoleBasedStyling("peopleManager")).toBe("border-pink-300 bg-pink-50 text-pink-500");
|
||||
});
|
||||
|
||||
test("getRoleBasedStyling default case", () => {
|
||||
expect(getRoleBasedStyling(undefined)).toBe("border-slate-300 bg-slate-50 text-slate-500");
|
||||
});
|
||||
|
||||
test("renders role tag with correct styling and label", () => {
|
||||
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
|
||||
const role = screen.getByText("Marketing");
|
||||
expect(role).toHaveClass("border-orange-300", "bg-orange-50", "text-orange-500");
|
||||
});
|
||||
|
||||
test("single channel shows label without suffix", () => {
|
||||
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
|
||||
expect(screen.getByText("Email Survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("two channels concatenated with 'common.or'", () => {
|
||||
const tpl = { ...baseTemplate, channels: ["email", "chat"] } as unknown as TTemplate;
|
||||
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
|
||||
expect(screen.getByText("Chat common.or Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("three channels shows 'environments.surveys.templates.all_channels'", () => {
|
||||
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms"] } as unknown as TTemplate;
|
||||
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
|
||||
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("more than three channels hides channel tag", () => {
|
||||
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms", "email"] } as unknown as TTemplate;
|
||||
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
|
||||
expect(screen.queryByText(/Survey|common\.or|all_channels/)).toBeNull();
|
||||
});
|
||||
|
||||
test("single industry shows mapped label", () => {
|
||||
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
|
||||
expect(screen.getByText("Industry A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("multiple industries shows 'multiple_industries'", () => {
|
||||
const tpl = { ...baseTemplate, industries: ["indA", "indB"] } as unknown as TTemplate;
|
||||
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
|
||||
expect(screen.getByText("environments.surveys.templates.multiple_industries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("selectedFilter[1] overrides industry tag", () => {
|
||||
render(<TemplateTags template={baseTemplate} selectedFilter={[null, "marketing"]} />);
|
||||
expect(screen.getByText("Marketing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders branching logic icon when questions have logic", () => {
|
||||
const tpl = { ...baseTemplate, preset: { questions: [{ logic: [1] }] } } as unknown as TTemplate;
|
||||
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
|
||||
expect(document.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
||||
@@ -17,7 +16,7 @@ interface TemplateTagsProps {
|
||||
|
||||
type NonNullabeChannel = NonNullable<TProjectConfigChannel>;
|
||||
|
||||
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
|
||||
export const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
|
||||
switch (role) {
|
||||
case "productManager":
|
||||
return "border-blue-300 bg-blue-50 text-blue-500";
|
||||
@@ -44,7 +43,8 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
|
||||
if (label) return t(label);
|
||||
return undefined;
|
||||
})
|
||||
.sort();
|
||||
.filter((label): label is string => !!label)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const removeSurveySuffix = (label: string | undefined) => label?.replace(" Survey", "");
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
.react-calendar__month-view__weekdays {
|
||||
text-decoration-style: dotted !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-line: underline !important;
|
||||
}
|
||||
|
||||
.react-calendar__month-view__days__day--weekend {
|
||||
|
||||
@@ -59,6 +59,7 @@ export default defineConfig({
|
||||
"modules/organization/settings/api-keys/components/*.tsx",
|
||||
"modules/survey/hooks/*.tsx",
|
||||
"modules/survey/components/question-form-input/index.tsx",
|
||||
"modules/survey/components/template-list/components/template-tags.tsx",
|
||||
"modules/survey/lib/client-utils.ts",
|
||||
"modules/survey/list/components/survey-card.tsx",
|
||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||
@@ -74,6 +75,7 @@ export default defineConfig({
|
||||
"modules/analysis/**/*.tsx",
|
||||
"modules/analysis/**/*.ts",
|
||||
"modules/survey/editor/components/end-screen-form.tsx",
|
||||
"lib/crypto.ts",
|
||||
"lib/utils/billing.ts"
|
||||
],
|
||||
exclude: [
|
||||
|
||||
@@ -207,7 +207,7 @@ export const ZResponseFilterCriteria = z.object({
|
||||
|
||||
export const ZResponseContact = z.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TResponseContact = z.infer<typeof ZResponseContact>;
|
||||
|
||||
@@ -2409,7 +2409,7 @@ export const ZSurveyQuestionSummaryOpenText = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2444,7 +2444,7 @@ export const ZSurveyQuestionSummaryMultipleChoice = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2562,7 +2562,7 @@ export const ZSurveyQuestionSummaryDate = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2584,7 +2584,7 @@ export const ZSurveyQuestionSummaryFileUpload = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2641,7 +2641,7 @@ export const ZSurveyQuestionSummaryHiddenFields = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2663,7 +2663,7 @@ export const ZSurveyQuestionSummaryAddress = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2685,7 +2685,7 @@ export const ZSurveyQuestionSummaryContactInfo = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
@@ -2711,7 +2711,7 @@ export const ZSurveyQuestionSummaryRanking = z.object({
|
||||
contact: z
|
||||
.object({
|
||||
id: ZId,
|
||||
userId: z.string(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
contactAttributes: ZContactAttributes.nullable(),
|
||||
|
||||
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Coverage
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css
|
||||
|
||||
Reference in New Issue
Block a user