diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index b9b7aac51..275ca0988 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -103,7 +103,10 @@ export const getSecretsBotHelper = async ({ environment: string; secretPath: string; }) => { - const content: Record = {}; + const content: Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean } + > = {}; const key = await getKey({ workspaceId: workspaceId }); let folderId = "root"; @@ -165,6 +168,8 @@ export const getSecretsBotHelper = async ({ }); content[secretKey].comment = commentValue; } + + content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding; }); }); @@ -194,6 +199,8 @@ export const getSecretsBotHelper = async ({ }); content[secretKey].comment = commentValue; } + + content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding; }); await expandSecrets(workspaceId.toString(), key, content); diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index ec40c9016..ca90fefe8 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -1203,7 +1203,7 @@ const formatMultiValueEnv = (val?: string) => { export const expandSecrets = async ( workspaceId: string, rootEncKey: string, - secrets: Record + secrets: Record ) => { const expandedSec: Record = {}; const interpolatedSec: Record = {}; @@ -1221,7 +1221,10 @@ export const expandSecrets = async ( for (const key of Object.keys(secrets)) { if (expandedSec?.[key]) { - secrets[key].value = formatMultiValueEnv(expandedSec[key]); + // should not do multi line encoding if user has set it to skip + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedSec[key] + : formatMultiValueEnv(expandedSec[key]); continue; } @@ -1236,7 +1239,9 @@ export const expandSecrets = async ( key ); - secrets[key].value = formatMultiValueEnv(expandedVal); + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); } return secrets; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index d0c4ed775..c82cc5b4a 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -65,10 +65,10 @@ import sodium from "libsodium-wrappers"; import { standardRequest } from "../config/request"; const getSecretKeyValuePair = ( - secrets: Record + secrets: Record ) => - Object.keys(secrets).reduce>((prev, key) => { - if (secrets[key]) prev[key] = secrets[key]?.value || ""; + Object.keys(secrets).reduce>((prev, key) => { + prev[key] = secrets?.[key] === null ? null : secrets?.[key]?.value; return prev; }, {}); @@ -325,40 +325,42 @@ const syncSecretsGCPSecretManager = async ({ name: string; createTime: string; } - + interface GCPSMListSecretsRes { secrets?: GCPSecret[]; totalSize?: number; nextPageToken?: string; } - + let gcpSecrets: GCPSecret[] = []; - + const pageSize = 100; let pageToken: string | undefined; let hasMorePages = true; - const filterParam = integration.metadata.secretGCPLabel - ? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}` + const filterParam = integration.metadata.secretGCPLabel + ? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}` : ""; - + while (hasMorePages) { const params = new URLSearchParams({ pageSize: String(pageSize), ...(pageToken ? { pageToken } : {}) }); - const res: GCPSMListSecretsRes = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`, - { - params, - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + const res: GCPSMListSecretsRes = ( + await standardRequest.get( + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } - } - )).data; - + ) + ).data; + if (res.secrets) { const filteredSecrets = res.secrets?.filter((gcpSecret) => { const arr = gcpSecret.name.split("/"); @@ -366,54 +368,58 @@ const syncSecretsGCPSecretManager = async ({ let isValid = true; - if (integration.metadata.secretPrefix && !key.startsWith(integration.metadata.secretPrefix)) { + if ( + integration.metadata.secretPrefix && + !key.startsWith(integration.metadata.secretPrefix) + ) { isValid = false; } if (integration.metadata.secretSuffix && !key.endsWith(integration.metadata.secretSuffix)) { isValid = false; } - + return isValid; }); gcpSecrets = gcpSecrets.concat(filteredSecrets); } - + if (!res.nextPageToken) { hasMorePages = false; } - + pageToken = res.nextPageToken; } - - const res: { [key: string]: string; } = {}; - + + const res: { [key: string]: string } = {}; + interface GCPLatestSecretVersionAccess { name: string; payload: { data: string; - } + }; } - + for await (const gcpSecret of gcpSecrets) { const arr = gcpSecret.name.split("/"); const key = arr[arr.length - 1]; - const secretLatest: GCPLatestSecretVersionAccess = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + const secretLatest: GCPLatestSecretVersionAccess = ( + await standardRequest.get( + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } - } - )).data; + ) + ).data; - res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8"); } - + for await (const key of Object.keys(secrets)) { if (!(key in res)) { // case: create secret @@ -423,11 +429,14 @@ const syncSecretsGCPSecretManager = async ({ replication: { automatic: {} }, - ...(integration.metadata.secretGCPLabel ? { - labels: { - [integration.metadata.secretGCPLabel.labelName]: integration.metadata.secretGCPLabel.labelValue - } - } : {}) + ...(integration.metadata.secretGCPLabel + ? { + labels: { + [integration.metadata.secretGCPLabel.labelName]: + integration.metadata.secretGCPLabel.labelValue + } + } + : {}) }, { params: { @@ -439,7 +448,7 @@ const syncSecretsGCPSecretManager = async ({ } } ); - + await standardRequest.post( `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, { @@ -456,7 +465,7 @@ const syncSecretsGCPSecretManager = async ({ ); } } - + for await (const key of Object.keys(res)) { if (!(key in secrets)) { // case: delete secret @@ -489,7 +498,7 @@ const syncSecretsGCPSecretManager = async ({ } } } -} +}; /** * Sync/push [secrets] to Azure Key Vault with vault URI [integration.app] @@ -729,15 +738,12 @@ const syncSecretsAWSParameterStore = async ({ } = {}; if (parameterList) { - awsParameterStoreSecretsObj = parameterList.reduce( - (obj: any, secret: any) => { - return ({ - ...obj, - [secret.Name.substring(integration.path.length)]: secret - }); - }, - {} - ); + awsParameterStoreSecretsObj = parameterList.reduce((obj: any, secret: any) => { + return { + ...obj, + [secret.Name.substring(integration.path.length)]: secret + }; + }, {}); } // Identify secrets to create @@ -1869,8 +1875,10 @@ const syncSecretsGitLab = async ({ value: string; environment_scope: string; } - - const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; + + const gitLabApiUrl = integrationAuth.url + ? `${integrationAuth.url}/api` + : INTEGRATION_GITLAB_API_URL; const getAllEnvVariables = async (integrationAppId: string, accessToken: string) => { const headers = { @@ -1880,7 +1888,9 @@ const syncSecretsGitLab = async ({ }; let allEnvVariables: GitLabSecret[] = []; - let url: string | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; + let url: + | string + | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; while (url) { const response: any = await standardRequest.get(url, { headers }); @@ -1901,23 +1911,27 @@ const syncSecretsGitLab = async ({ const allEnvVariables = await getAllEnvVariables(integration?.appId, accessToken); const getSecretsRes: GitLabSecret[] = allEnvVariables - .filter( - (secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment - ) + .filter((secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment) .filter((gitLabSecret) => { let isValid = true; - if (integration.metadata.secretPrefix && !gitLabSecret.key.startsWith(integration.metadata.secretPrefix)) { + if ( + integration.metadata.secretPrefix && + !gitLabSecret.key.startsWith(integration.metadata.secretPrefix) + ) { isValid = false; } - if (integration.metadata.secretSuffix && !gitLabSecret.key.endsWith(integration.metadata.secretSuffix)) { + if ( + integration.metadata.secretSuffix && + !gitLabSecret.key.endsWith(integration.metadata.secretSuffix) + ) { isValid = false; } - + return isValid; }); - + for await (const key of Object.keys(secrets)) { const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { @@ -2371,41 +2385,43 @@ const syncSecretsTeamCity = async ({ if (integration.targetEnvironment && integration.targetEnvironmentId) { // case: sync to specific build-config in TeamCity project - const res = (await standardRequest.get( - `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - } - )) - .data - .property - .filter((parameter) => !parameter.inherited) - .reduce((obj: any, secret: TeamCitySecret) => { - const secretName = secret.name.replace(/^env\./, ""); - return { - ...obj, - [secretName]: secret.value - }; - }, {}); - + const res = ( + await standardRequest.get( + `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ).data.property + .filter((parameter) => !parameter.inherited) + .reduce((obj: any, secret: TeamCitySecret) => { + const secretName = secret.name.replace(/^env\./, ""); + return { + ...obj, + [secretName]: secret.value + }; + }, {}); + for await (const key of Object.keys(secrets)) { if (!(key in res) || (key in res && secrets[key].value !== res[key])) { // case: secret does not exist in TeamCity or secret value has changed // -> create/update secret - await standardRequest.post(`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, - { - name:`env.${key}`, - value: secrets[key].value - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", + await standardRequest.post( + `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, + { + name: `env.${key}`, + value: secrets[key].value }, - }); + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); } } @@ -3034,4 +3050,4 @@ const syncSecretsNorthflank = async ({ ); }; -export { syncSecrets }; \ No newline at end of file +export { syncSecrets }; diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 04a6458e6..28ac4428b 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -257,7 +257,9 @@ export const CreateSecretRawV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - secretValue: z.string().trim(), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), secretComment: z.string().trim(), skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) @@ -274,7 +276,9 @@ export const UpdateSecretByNameRawV3 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - secretValue: z.string().trim(), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), secretPath: z.string().trim().default("/"), skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index fcd9ccb8f..9d5c113da 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -11,6 +11,8 @@ import { secretKeys } from "@app/hooks/api/secrets/queries"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; import { UserWsKeyPair, WsTag } from "@app/hooks/api/types"; +import { secretSnapshotKeys } from "~/hooks/api/secretSnapshots/queries"; + import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types"; import { SecretDetailSidebar } from "./SecretDetaiSidebar"; import { SecretItem } from "./SecretItem"; @@ -218,8 +220,13 @@ export const SecretListView = ({ queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath }) + ); handlePopUpClose("secretDetail"); - createNotification({ type: "success", text: "Successfully saved secrets" @@ -242,6 +249,12 @@ export const SecretListView = ({ queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath }) + ); handlePopUpClose("deleteSecret"); handlePopUpClose("secretDetail"); createNotification({ diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts index 29ea7a12e..d15f2fbf4 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { z } from "zod"; export enum SecretActionType { @@ -7,11 +8,16 @@ export enum SecretActionType { } export const formSchema = z.object({ - key: z.string(), - value: z.string(), - idOverride: z.string().optional(), - valueOverride: z.string().optional(), - overrideAction: z.string().optional(), + key: z.string().trim(), + value: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), + idOverride: z.string().trim().optional(), + valueOverride: z + .string() + .optional() + .transform((val) => + typeof val === "string" ? (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()) : val + ), + overrideAction: z.string().trim().optional(), comment: z.string().trim().optional(), skipMultilineEncoding: z.boolean().optional(), tags: z