diff --git a/backend/src/db/migrations/20250725171821_add-secret-detection-ignore-keys.ts b/backend/src/db/migrations/20250725171821_add-secret-detection-ignore-keys.ts new file mode 100644 index 000000000..dfdf5ecfc --- /dev/null +++ b/backend/src/db/migrations/20250725171821_add-secret-detection-ignore-keys.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Project, "secretDetectionIgnoreKeys"))) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.specificType("secretDetectionIgnoreKeys", "text[]"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Project, "secretDetectionIgnoreKeys")) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("secretDetectionIgnoreKeys"); + }); + } +} diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 059565a94..fd8b273f2 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -30,7 +30,8 @@ export const ProjectsSchema = z.object({ hasDeleteProtection: z.boolean().default(false).nullable().optional(), secretSharing: z.boolean().default(true), showSnapshotsLegacy: z.boolean().default(false), - defaultProduct: z.string().nullable().optional() + defaultProduct: z.string().nullable().optional(), + secretDetectionIgnoreKeys: z.string().array().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index 9489f4658..917f992d3 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -1,11 +1,20 @@ import { AxiosError } from "axios"; import { exec } from "child_process"; +import { join } from "path"; +import picomatch from "picomatch"; import RE2 from "re2"; -import { readFindingsFile } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { + createTempFolder, + deleteTempFolder, + readFindingsFile, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; import { BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { titleCaseToCamelCase } from "@app/lib/fn"; import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secret-scanning-v2-enums"; @@ -46,6 +55,19 @@ export function scanDirectory(inputPath: string, outputPath: string, configPath? }); } +export function scanFile(inputPath: string): Promise { + return new Promise((resolve, reject) => { + const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git`; + exec(command, (error) => { + if (error && error.code === 77) { + reject(error); + } else { + resolve(); + } + }); + }); +} + export const scanGitRepositoryAndGetFindings = async ( scanPath: string, findingsPath: string, @@ -140,3 +162,48 @@ export const parseScanErrorMessage = (err: unknown): string => { ? errorMessage : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; + +export const scanSecretPolicyViolations = async ( + secretPath: string, + secrets: { secretKey: string; secretValue: string }[], + ignoreKeys: string[] +) => { + const appCfg = getConfig(); + + if (!appCfg.PARAMS_FOLDER_SECRET_DETECTION_ENABLED) { + return; + } + const paramFolderSecretDetectionPaths = appCfg.PARAMS_FOLDER_SECRET_DETECTION_PATHS?.map((el) => el.secretPath) ?? []; + const isPathMatched = paramFolderSecretDetectionPaths.some((pattern) => + picomatch.isMatch(secretPath, pattern, { strictSlashes: false }) + ); + + if (!isPathMatched) { + return; + } + + const tempFolder = await createTempFolder(); + try { + let iter = 0; + for await (const secret of secrets) { + if (ignoreKeys.includes(secret.secretKey)) { + // eslint-disable-next-line no-continue + continue; + } + + iter += 1; + const secretFilePath = join(tempFolder, `${iter}.txt`); + await writeTextToFile(secretFilePath, `${secret.secretKey}=${secret.secretValue}`); + try { + await scanFile(secretFilePath); + } catch (error) { + throw new BadRequestError({ + message: `Secret value detected in ${secret.secretKey}. Please add this instead to the designated secrets path in the project.`, + name: "SecretPolicyViolation" + }); + } + } + } finally { + await deleteTempFolder(tempFolder); + } +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b6c00985a..f42431195 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -704,7 +704,8 @@ export const PROJECTS = { hasDeleteProtection: "Enable or disable delete protection for the project.", secretSharing: "Enable or disable secret sharing for the project.", showSnapshotsLegacy: "Enable or disable legacy snapshots for the project.", - defaultProduct: "The default product in which the project will open" + defaultProduct: "The default product in which the project will open", + secretDetectionIgnoreKeys: "The list of secret keys to ignore for secret detection." }, GET_KEY: { workspaceId: "The ID of the project to get the key from." diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 986963e47..af47a6537 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -204,6 +204,18 @@ const envSchema = z WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()), ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true"), + // Special Detection Feature + PARAMS_FOLDER_SECRET_DETECTION_PATHS: zpStr( + z + .string() + .optional() + .transform((val) => { + if (!val) return undefined; + return JSON.parse(val) as { secretPath: string }[]; + }) + ), + PARAMS_FOLDER_SECRET_DETECTION_ENABLED: zodStrBool.default("false"), + // HSM HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ea4cf9676..01f58494b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1231,6 +1231,7 @@ export const registerRoutes = async ( const secretV2BridgeService = secretV2BridgeServiceFactory({ folderDAL, + projectDAL, secretVersionDAL: secretVersionV2BridgeDAL, folderCommitService, secretQueueService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index beef663b9..1564b4bcb 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -264,7 +264,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ auditLogsRetentionDays: true, hasDeleteProtection: true, secretSharing: true, - showSnapshotsLegacy: true + showSnapshotsLegacy: true, + secretDetectionIgnoreKeys: true }); export const SanitizedTagSchema = SecretTagsSchema.pick({ diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6cc50dc5c..57aa42fae 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -52,7 +52,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { defaultAuthOrgAuthEnforced: z.boolean().nullish(), defaultAuthOrgAuthMethod: z.string().nullish(), isSecretScanningDisabled: z.boolean(), - kubernetesAutoFetchServiceAccountToken: z.boolean() + kubernetesAutoFetchServiceAccountToken: z.boolean(), + paramsFolderSecretDetectionEnabled: z.boolean() }) }) } @@ -67,7 +68,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { fipsEnabled: crypto.isFipsModeEnabled(), isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, - kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN + kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, + paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED } }; } diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 05aade960..5992b1a33 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -369,7 +369,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .describe(PROJECTS.UPDATE.slug), secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing), showSnapshotsLegacy: z.boolean().optional().describe(PROJECTS.UPDATE.showSnapshotsLegacy), - defaultProduct: z.nativeEnum(ProjectType).optional().describe(PROJECTS.UPDATE.defaultProduct) + defaultProduct: z.nativeEnum(ProjectType).optional().describe(PROJECTS.UPDATE.defaultProduct), + secretDetectionIgnoreKeys: z.array(z.string()).optional().describe(PROJECTS.UPDATE.secretDetectionIgnoreKeys) }), response: { 200: z.object({ @@ -392,7 +393,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { hasDeleteProtection: req.body.hasDeleteProtection, slug: req.body.slug, secretSharing: req.body.secretSharing, - showSnapshotsLegacy: req.body.showSnapshotsLegacy + showSnapshotsLegacy: req.body.showSnapshotsLegacy, + secretDetectionIgnoreKeys: req.body.secretDetectionIgnoreKeys }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 27925c4b9..a166d40c3 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -645,7 +645,7 @@ export const projectServiceFactory = ({ const updateProject = async ({ actor, actorId, actorOrgId, actorAuthMethod, update, filter }: TUpdateProjectDTO) => { const project = await projectDAL.findProjectByFilter(filter); - const { permission } = await permissionService.getProjectPermission({ + const { permission, hasRole } = await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -667,6 +667,12 @@ export const projectServiceFactory = ({ } } + if (update.secretDetectionIgnoreKeys && !hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ + message: "Only admins can update secret detection ignore keys" + }); + } + const updatedProject = await projectDAL.updateById(project.id, { name: update.name, description: update.description, @@ -676,7 +682,8 @@ export const projectServiceFactory = ({ slug: update.slug, secretSharing: update.secretSharing, defaultProduct: update.defaultProduct, - showSnapshotsLegacy: update.showSnapshotsLegacy + showSnapshotsLegacy: update.showSnapshotsLegacy, + secretDetectionIgnoreKeys: update.secretDetectionIgnoreKeys }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index b8c37a858..82f9a0b8f 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -96,6 +96,7 @@ export type TUpdateProjectDTO = { slug?: string; secretSharing?: boolean; showSnapshotsLegacy?: boolean; + secretDetectionIgnoreKeys?: string[]; }; } & Omit; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 2fa0ffe9b..f4f815fbd 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -25,6 +25,7 @@ import { import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; +import { scanSecretPolicyViolations } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -38,6 +39,7 @@ import { ActorType } from "../auth/auth-type"; import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TReminderServiceFactory } from "../reminder/reminder-types"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; @@ -88,6 +90,7 @@ import { TSecretVersionV2TagDALFactory } from "./secret-version-tag-dal"; type TSecretV2BridgeServiceFactoryDep = { secretDAL: TSecretV2BridgeDALFactory; + projectDAL: Pick; secretVersionDAL: TSecretVersionV2DALFactory; kmsService: Pick; secretVersionTagDAL: Pick; @@ -126,6 +129,7 @@ export type TSecretV2BridgeServiceFactory = ReturnType ({ secretKey: el, secretPath, environment })) @@ -506,6 +522,20 @@ export const secretV2BridgeServiceFactory = ({ const { secretName, secretValue } = inputSecret; + if (secretValue) { + const project = await projectDAL.findById(projectId); + await scanSecretPolicyViolations( + secretPath, + [ + { + secretKey: inputSecret.newSecretName || secretName, + secretValue + } + ], + project.secretDetectionIgnoreKeys || [] + ); + } + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId @@ -1585,6 +1615,9 @@ export const secretV2BridgeServiceFactory = ({ if (secrets.length) throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` }); + const project = await projectDAL.findById(projectId); + await scanSecretPolicyViolations(secretPath, inputSecrets, project.secretDetectionIgnoreKeys || []); + // get all tags const sanitizedTagIds = inputSecrets.flatMap(({ tagIds = [] }) => tagIds); const tags = sanitizedTagIds.length ? await secretTagDAL.findManyTagsById(projectId, sanitizedTagIds) : []; @@ -1925,6 +1958,18 @@ export const secretV2BridgeServiceFactory = ({ }); await $validateSecretReferences(projectId, permission, secretReferences, tx); + const project = await projectDAL.findById(projectId); + await scanSecretPolicyViolations( + secretPath, + secretsToUpdate + .filter((el) => el.secretValue) + .map((el) => ({ + secretKey: el.newSecretName || el.secretKey, + secretValue: el.secretValue as string + })), + project.secretDetectionIgnoreKeys || [] + ); + const bulkUpdatedSecrets = await fnSecretBulkUpdate({ folderId, orgId: actorOrgId, diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 6685e5b77..51ff5a908 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -51,6 +51,7 @@ export type TServerConfig = { invalidatingCache: boolean; fipsEnabled: boolean; envOverrides?: Record; + paramsFolderSecretDetectionEnabled: boolean; }; export type TUpdateServerConfigDTO = { diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 6408d0e22..e4e9d2dd1 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -281,7 +281,8 @@ export const useUpdateProject = () => { newProjectDescription, newSlug, secretSharing, - showSnapshotsLegacy + showSnapshotsLegacy, + secretDetectionIgnoreKeys }) => { const { data } = await apiRequest.patch<{ workspace: Workspace }>( `/api/v1/workspace/${projectID}`, @@ -290,7 +291,8 @@ export const useUpdateProject = () => { description: newProjectDescription, slug: newSlug, secretSharing, - showSnapshotsLegacy + showSnapshotsLegacy, + secretDetectionIgnoreKeys } ); return data.workspace; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 7d6f68821..e96b7099e 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -40,6 +40,7 @@ export type Workspace = { hasDeleteProtection: boolean; secretSharing: boolean; showSnapshotsLegacy: boolean; + secretDetectionIgnoreKeys: string[]; }; export type WorkspaceEnv = { @@ -81,6 +82,7 @@ export type UpdateProjectDTO = { newSlug?: string; secretSharing?: boolean; showSnapshotsLegacy?: boolean; + secretDetectionIgnoreKeys?: string[]; }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index 5f817a46a..022951dff 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -1,12 +1,17 @@ +import { useServerConfig } from "@app/context"; + import { AutoCapitalizationSection } from "../AutoCapitalizationSection"; import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection"; import { EnvironmentSection } from "../EnvironmentSection"; import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection"; +import { SecretDetectionIgnoreKeysSection } from "../SecretDetectionIgnoreKeysSection/SecretDetectionIgnoreKeysSection"; import { SecretSharingSection } from "../SecretSharingSection"; import { SecretSnapshotsLegacySection } from "../SecretSnapshotsLegacySection"; import { SecretTagsSection } from "../SecretTagsSection"; export const SecretSettingsTab = () => { + const { config } = useServerConfig(); + return (
@@ -15,6 +20,7 @@ export const SecretSettingsTab = () => { + {config.paramsFolderSecretDetectionEnabled && }
); diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/SecretDetectionIgnoreKeysSection/SecretDetectionIgnoreKeysSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/SecretDetectionIgnoreKeysSection/SecretDetectionIgnoreKeysSection.tsx new file mode 100644 index 000000000..bc3f04b98 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/SecretDetectionIgnoreKeysSection/SecretDetectionIgnoreKeysSection.tsx @@ -0,0 +1,147 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { useUpdateProject } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; + +const formSchema = z.object({ + ignoreKeys: z + .object({ + key: z.string().trim().min(1, "Secret key name is required") + }) + .array() + .default([]) +}); + +type TForm = z.infer; + +export const SecretDetectionIgnoreKeysSection = () => { + const { currentWorkspace } = useWorkspace(); + const { membership } = useProjectPermission(); + const { mutateAsync: updateProject } = useUpdateProject(); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit, + reset + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + ignoreKeys: [] + } + }); + + const ignoreKeysFormFields = useFieldArray({ + control, + name: "ignoreKeys" + }); + + useEffect(() => { + const existingIgnoreKeys = currentWorkspace?.secretDetectionIgnoreKeys || []; + reset({ + ignoreKeys: + existingIgnoreKeys.length > 0 ? existingIgnoreKeys.map((key) => ({ key })) : [{ key: "" }] // Show one empty field by default + }); + }, [currentWorkspace?.secretDetectionIgnoreKeys, reset]); + + const handleIgnoreKeysSubmit = async ({ ignoreKeys }: TForm) => { + try { + await updateProject({ + projectID: currentWorkspace.id, + secretDetectionIgnoreKeys: ignoreKeys.map((item) => item.key) + }); + + createNotification({ + text: "Successfully updated secret detection ignore keys", + type: "success" + }); + } catch { + createNotification({ + text: "Failed updating secret detection ignore keys", + type: "error" + }); + } + }; + + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + + if (!currentWorkspace) return null; + + return ( +
+
+

Secret Detection Ignore Keys

+
+

+ Define secret keys that should be ignored when scanning parameter folders for misplaced + secrets. These keys will not trigger policy violation alerts even if they contain sensitive + data. +

+ +
+
+

Ignored Secret Keys

+
+ {ignoreKeysFormFields.fields.map(({ id: ignoreKeyFieldId }, i) => ( +
+
+ {i === 0 && Secret Key Name} + ( + + + + )} + /> +
+ ignoreKeysFormFields.remove(i)} + isDisabled={!isAdmin} + > + + +
+ ))} +
+ +
+
+
+ + +
+
+ ); +};