diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts new file mode 100644 index 000000000..c5e832d54 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceSchema, + UpdateGitLabDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/gitlab"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitLabSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitLab, + server, + responseSchema: GitLabDataSourceSchema, + createSchema: CreateGitLabDataSourceSchema, + updateSchema: UpdateGitLabDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts index 2258f9c82..81e393f7c 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -1,3 +1,4 @@ +import { registerGitLabSecretScanningRouter } from "@app/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router"; import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; import { registerBitbucketSecretScanningRouter } from "./bitbucket-secret-scanning-router"; @@ -10,5 +11,6 @@ export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< (server: FastifyZodProvider) => Promise > = { [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter, - [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter + [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter, + [SecretScanningDataSource.GitLab]: registerGitLabSecretScanningRouter }; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 0a672437d..e22b9afe2 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -4,6 +4,7 @@ import { SecretScanningConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { BitbucketDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; import { SecretScanningFindingStatus, SecretScanningScanStatus @@ -24,7 +25,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [ GitHubDataSourceListItemSchema, - BitbucketDataSourceListItemSchema + BitbucketDataSourceListItemSchema, + GitLabDataSourceListItemSchema ]); export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts index f51c75daa..f4b4c03a9 100644 --- a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts @@ -18,7 +18,8 @@ import { TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, TSecretScanningFactoryPostInitialization, - TSecretScanningFactoryTeardown + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; @@ -302,6 +303,13 @@ export const BitbucketSecretScanningFactory = () => { ); }; + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TBitbucketDataSourceInput["config"], + TBitbucketDataSourceWithConnection + > = async () => { + // no validation required + }; + return { initialize, postInitialization, @@ -309,6 +317,7 @@ export const BitbucketSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts index e09b8f88b..175e5d8cc 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -20,7 +20,8 @@ import { TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, TSecretScanningFactoryPostInitialization, - TSecretScanningFactoryTeardown + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -64,7 +65,14 @@ export const GitHubSecretScanningFactory = () => { }; const teardown: TSecretScanningFactoryTeardown = async () => { - // no termination required + // no teardown required + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitHubDataSourceInput["config"], + TGitHubDataSourceWithConnection + > = async () => { + // no validation required }; const listRawResources: TSecretScanningFactoryListRawResources = async ( @@ -238,6 +246,7 @@ export const GitHubSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts new file mode 100644 index 000000000..b472a2f3f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitLab", + type: SecretScanningDataSource.GitLab, + connection: AppConnection.GitLab +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts new file mode 100644 index 000000000..89caff802 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts @@ -0,0 +1,8 @@ +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export enum GitLabWebHookEvent { + Push = "Push Hook" +} diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts new file mode 100644 index 000000000..b83d82d51 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts @@ -0,0 +1,409 @@ +import { Camelize, GitbeakerRequestError, GroupHookSchema, ProjectHookSchema } from "@gitbeaker/rest"; +import { join } from "path"; + +import { scanContentAndGetFindings } 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 { + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryParams, + TSecretScanningFactoryPostInitialization, + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { + getGitLabConnectionClient, + getGitLabInstanceUrl, + TGitLabConnection +} from "@app/services/app-connection/gitlab"; + +import { GitLabDataSourceScope } from "./gitlab-secret-scanning-enums"; +import { + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan +} from "./gitlab-secret-scanning-types"; + +const getMainDomain = (instanceUrl: string) => { + const url = new URL(instanceUrl); + const { hostname } = url; + const parts = hostname.split("."); + + if (parts.length >= 2) { + return parts.slice(-2).join("."); + } + + return hostname; +}; + +export const GitLabSecretScanningFactory = ({ appConnectionDAL, kmsService }: TSecretScanningFactoryParams) => { + const initialize: TSecretScanningFactoryInitialize< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ payload: { config, name }, connection }, callback) => { + const token = alphaNumericNanoId(64); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + let hook: Camelize; + try { + hook = await client.ProjectHooks.add(projectId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: `${error.message}: ${error.cause?.description ?? "Unknown Error"}` }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + } + + // group scope + const { groupId } = config; + + const group = await client.Groups.show(groupId); + + if (!group) { + throw new BadRequestError({ message: `Could not find group with ID ${groupId}.` }); + } + + let hook: Camelize; + try { + hook = await client.GroupHooks.add(groupId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: error.message }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const postInitialization: TSecretScanningFactoryPostInitialization< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ connection, dataSourceId, credentials, payload: { config } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + const hookUrl = `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`; + const { hookId } = credentials; + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + try { + await client.ProjectHooks.edit(projectId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + + return; + } + + // group-scope + const { groupId } = config; + + try { + await client.GroupHooks.edit(groupId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { connection, config } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + // scott: even though we have this data we want to get potentially updated name + return [ + { + name: project.pathWithNamespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + } + ]; + } + + // group-scope + + const { groupId, includeProjects } = config; + + const projects = await client.Groups.allProjects(groupId, { + archived: false + }); + + const filteredProjects: typeof projects = []; + if (!includeProjects || includeProjects.includes("*")) { + filteredProjects.push(...projects); + } else { + filteredProjects.push(...projects.filter((project) => includeProjects.includes(project.pathWithNamespace))); + } + + return filteredProjects.map(({ id, pathWithNamespace }) => ({ + name: pathWithNamespace, + externalId: id.toString(), + type: SecretScanningResource.Project + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const { connection } = dataSource; + + const instanceUrl = await getGitLabInstanceUrl(connection.credentials.instanceUrl); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const user = await client.Users.showCurrentUser(); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitLabProjectRegex.test(resourceName)) { + throw new Error("Invalid GitLab project name"); + } + + await cloneRepository({ + cloneUrl: `https://${user.username}:${connection.credentials.accessToken}@${getMainDomain(instanceUrl)}/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const teardown: TSecretScanningFactoryTeardown< + TGitLabDataSourceWithConnection, + TGitLabDataSourceCredentials + > = async ({ dataSource: { connection, config }, credentials: { hookId } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + return; + } + + const { groupId } = config; + try { + await client.GroupHooks.remove(groupId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitLabResourceDiffScan["payload"] + > = ({ project }) => { + return { + name: project.path_with_namespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const { connection } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const { commits, project } = payload; + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitDiffs = await client.Commits.showDiff(project.id, commit.id); + + for (const commitDiff of commitDiffs) { + // eslint-disable-next-line no-continue + if (commitDiff.deletedFile) continue; + + // eslint-disable-next-line no-await-in-loop + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${commitDiff.diff}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(commitDiff.diff, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(commitDiff.diff, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + const authorName = commit.author.name; + const authorEmail = commit.author.email; + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: commitDiff.newPath, + Commit: commit.id, + Author: authorName, + Email: authorEmail, + Message: commit.message, + Fingerprint: `${commit.id}:${commitDiff.newPath}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://gitlab.com/${resourceName}/blob/${commit.id}/${commitDiff.newPath}#L${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitLabDataSourceInput["config"], + TGitLabDataSourceWithConnection + > = async ({ config, dataSource }) => { + if (dataSource.config.scope !== config.scope) { + throw new BadRequestError({ message: "Cannot change Data Source scope after creation." }); + } + }; + + return { + listRawResources, + getFullScanPath, + initialize, + postInitialization, + teardown, + getDiffScanResourcePayload, + getDiffScanFindingsPayload, + validateConfigUpdate + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts new file mode 100644 index 000000000..e70e63c11 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitLabDataSourceConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + groupId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.groupId), + groupName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.groupName), + includeProjects: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitLabProjectRegex.test(value), "Invalid project name format") + ) + .nonempty("One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITLAB.includeProjects) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + projectName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.projectName), + projectId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.projectId) + }) +]); + +export const GitLabDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const CreateGitLabDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const UpdateGitLabDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitLab) + .extend({ + config: GitLabDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabDataSourceListItemSchema = z + .object({ + name: z.literal("GitLab"), + connection: z.literal(AppConnection.GitLab), + type: z.literal(SecretScanningDataSource.GitLab) + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Project), + dataSourceType: z.literal(SecretScanningDataSource.GitLab), + details: GitRepositoryScanFindingDetailsSchema +}); + +export const GitLabDataSourceCredentialsSchema = z.object({ + token: z.string(), + hookId: z.number() +}); diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts new file mode 100644 index 000000000..98d20f642 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts @@ -0,0 +1,94 @@ +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + THandleGitLabPushEvent +} from "./gitlab-secret-scanning-types"; + +export const gitlabSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick, + kmsService: Pick +) => { + const handlePushEvent = async ({ payload, token, dataSourceId }: THandleGitLabPushEvent) => { + if (!payload.total_commits_count || !payload.project) { + logger.warn( + `secretScanningV2PushEvent: GitLab - Insufficient data [changes=${ + payload.total_commits_count ?? 0 + }] [projectName=${payload.project.path_with_namespace}] [projectId=${payload.project.id}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + id: dataSourceId, + type: SecretScanningDataSource.GitLab + })) as TGitLabDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitLab - Could not find data source [dataSourceId=${dataSourceId}] [projectId=${payload.project.id}]` + ); + return; + } + + const { isAutoScanEnabled, config, encryptedCredentials, projectId } = dataSource; + + if (!encryptedCredentials) { + logger.info( + `secretScanningV2PushEvent: GitLab - Could not find encrypted credentials [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedCredentials = decryptor({ cipherTextBlob: encryptedCredentials }); + + const credentials = JSON.parse(decryptedCredentials.toString()) as TGitLabDataSourceCredentials; + + if (token !== credentials.token) { + logger.error( + `secretScanningV2PushEvent: GitLab - Invalid webhook token [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if ( + config.scope === GitLabDataSourceScope.Project + ? config.projectId.toString() === payload.project_id.toString() + : config.includeProjects.includes("*") || config.includeProjects.includes(payload.project.path_with_namespace) + ) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitLab, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to repository not being present in config [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + } + }; + + return { + handlePushEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts new file mode 100644 index 000000000..105757e38 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitLabConnection } from "@app/services/app-connection/gitlab"; + +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceCredentialsSchema, + GitLabDataSourceListItemSchema, + GitLabDataSourceSchema, + GitLabFindingSchema +} from "./gitlab-secret-scanning-schemas"; + +export type TGitLabDataSource = z.infer; + +export type TGitLabDataSourceInput = z.infer; + +export type TGitLabDataSourceListItem = z.infer; + +export type TGitLabFinding = z.infer; + +export type TGitLabDataSourceWithConnection = TGitLabDataSource & { + connection: TGitLabConnection; +}; + +export type TGitLabDataSourceCredentials = z.infer; + +export type TGitLabDataSourcePushEventPayload = { + object_kind: "push"; + event_name: "push"; + before: string; + after: string; + ref: string; + ref_protected: boolean; + checkout_sha: string; + user_id: number; + user_name: string; + user_username: string; + user_email: string; + user_avatar: string; + project_id: number; + project: { + id: number; + name: string; + description: string; + web_url: string; + avatar_url: string | null; + git_ssh_url: string; + git_http_url: string; + namespace: string; + visibility_level: number; + path_with_namespace: string; + default_branch: string; + homepage: string; + url: string; + ssh_url: string; + http_url: string; + }; + repository: { + name: string; + url: string; + description: string; + homepage: string; + git_http_url: string; + git_ssh_url: string; + visibility_level: number; + }; + commits: { + id: string; + message: string; + title: string; + timestamp: string; + url: string; + author: { + name: string; + email: string; + }; + added: string[]; + modified: string[]; + removed: string[]; + }[]; + total_commits_count: number; +}; + +export type THandleGitLabPushEvent = { + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + token: string; +}; + +export type TQueueGitLabResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitLab; + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts new file mode 100644 index 000000000..00009ca3b --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts @@ -0,0 +1,3 @@ +export * from "./gitlab-secret-scanning-constants"; +export * from "./gitlab-secret-scanning-schemas"; +export * from "./gitlab-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts index 2ca1f4882..40e664d01 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -1,5 +1,6 @@ import { BitbucketSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory"; import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; +import { GitLabSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory"; import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; import { @@ -19,5 +20,6 @@ type TSecretScanningFactoryImplementation = TSecretScanningFactory< export const SECRET_SCANNING_FACTORY_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation, - [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation + [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation, + [SecretScanningDataSource.GitLab]: GitLabSecretScanningFactory as TSecretScanningFactoryImplementation }; 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..2a2650611 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 @@ -6,6 +6,7 @@ import { readFindingsFile } from "@app/ee/services/secret-scanning/secret-scanni 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 { GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/gitlab"; import { titleCaseToCamelCase } from "@app/lib/fn"; import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secret-scanning-v2-enums"; @@ -13,7 +14,8 @@ import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListIte const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, - [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION + [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, + [SecretScanningDataSource.GitLab]: GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION }; export const listSecretScanningDataSourceOptions = () => { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts index c84d6056a..fd29e0b74 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -3,15 +3,18 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { [SecretScanningDataSource.GitHub]: "GitHub", - [SecretScanningDataSource.Bitbucket]: "Bitbucket" + [SecretScanningDataSource.Bitbucket]: "Bitbucket", + [SecretScanningDataSource.GitLab]: "GitLab" }; export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" }, - [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" } + [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" }, + [SecretScanningDataSource.GitLab]: { verb: "push", noun: "projects" } }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 7e670af96..8621b039b 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -16,6 +16,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -48,6 +49,7 @@ type TSecretRotationV2QueueServiceFactoryDep = { projectMembershipDAL: Pick; projectDAL: Pick; kmsService: Pick; + appConnectionDAL: Pick; auditLogService: Pick; keyStore: Pick; }; @@ -62,7 +64,8 @@ export const secretScanningV2QueueServiceFactory = async ({ smtpService, kmsService, auditLogService, - keyStore + keyStore, + appConnectionDAL }: TSecretRotationV2QueueServiceFactoryDep) => { const queueDataSourceFullScan = async ( dataSource: TSecretScanningDataSourceWithConnection, @@ -71,7 +74,10 @@ export const secretScanningV2QueueServiceFactory = async ({ try { const { type } = dataSource; - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + kmsService, + appConnectionDAL + }); const rawResources = await factory.listRawResources(dataSource); @@ -171,7 +177,10 @@ export const secretScanningV2QueueServiceFactory = async ({ let connection: TAppConnection | null = null; if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const findingsPath = join(tempFolder, "findings.json"); @@ -329,7 +338,10 @@ export const secretScanningV2QueueServiceFactory = async ({ dataSourceId, dataSourceType }: Pick) => { - const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const resourcePayload = factory.getDiffScanResourcePayload(payload); @@ -391,7 +403,10 @@ export const secretScanningV2QueueServiceFactory = async ({ if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const tempFolder = await createTempFolder(); diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index 41da217d8..6bef41e10 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -46,6 +46,7 @@ import { import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; @@ -53,12 +54,14 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { bitbucketSecretScanningService } from "./bitbucket/bitbucket-secret-scanning-service"; +import { gitlabSecretScanningService } from "./gitlab/gitlab-secret-scanning-service"; import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; export type TSecretScanningV2ServiceFactoryDep = { secretScanningV2DAL: TSecretScanningV2DALFactory; appConnectionService: Pick; + appConnectionDAL: Pick; permissionService: Pick; licenseService: Pick; secretScanningV2Queue: Pick< @@ -76,6 +79,7 @@ export const secretScanningV2ServiceFactory = ({ appConnectionService, licenseService, secretScanningV2Queue, + appConnectionDAL, kmsService }: TSecretScanningV2ServiceFactoryDep) => { const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( @@ -255,7 +259,10 @@ export const secretScanningV2ServiceFactory = ({ ); } - const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type]({ + appConnectionDAL, + kmsService + }); try { const createdDataSource = await factory.initialize( @@ -363,6 +370,31 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); + let connection: TAppConnection | null = null; + if (dataSource.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSource.type], + dataSource.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type]({ + appConnectionDAL, + kmsService + }); + + if (payload.config) { + await factory.validateConfigUpdate({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + config: payload.config as TSecretScanningDataSourceWithConnection["config"] + }); + } + try { const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); @@ -416,7 +448,10 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + appConnectionDAL, + kmsService + }); let connection: TAppConnection | null = null; if (dataSource.connection) { @@ -903,6 +938,7 @@ export const secretScanningV2ServiceFactory = ({ findSecretScanningConfigByProjectId, upsertSecretScanningConfig, github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue), - bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) + bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService), + gitlab: gitlabSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts index 6bd8251b6..6c5d86d16 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -21,14 +21,25 @@ import { TGitHubFinding, TQueueGitHubResourceDiffScan } from "@app/ee/services/secret-scanning-v2/github"; +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceListItem, + TGitLabDataSourceWithConnection, + TGitLabFinding, + TQueueGitLabResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/gitlab"; import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; import { SecretScanningDataSource, SecretScanningFindingStatus, SecretScanningScanStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt?: Date | null; @@ -52,15 +63,25 @@ export type TSecretScanningScanWithDetails = TSecretScanningScans & { export type TSecretScanningDataSourceWithConnection = | TGitHubDataSourceWithConnection - | TBitbucketDataSourceWithConnection; + | TBitbucketDataSourceWithConnection + | TGitLabDataSourceWithConnection; -export type TSecretScanningDataSourceInput = TGitHubDataSourceInput | TBitbucketDataSourceInput; +export type TSecretScanningDataSourceInput = + | TGitHubDataSourceInput + | TBitbucketDataSourceInput + | TGitLabDataSourceInput; -export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem | TBitbucketDataSourceListItem; +export type TSecretScanningDataSourceListItem = + | TGitHubDataSourceListItem + | TBitbucketDataSourceListItem + | TGitLabDataSourceListItem; -export type TSecretScanningDataSourceCredentials = TBitbucketDataSourceCredentials | undefined; +export type TSecretScanningDataSourceCredentials = + | TBitbucketDataSourceCredentials + | TGitLabDataSourceCredentials + | undefined; -export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding; +export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding | TGitLabFinding; export type TListSecretScanningDataSourcesByProjectId = { projectId: string; @@ -112,7 +133,10 @@ export type TQueueSecretScanningDataSourceFullScan = { scanId: string; }; -export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan | TQueueBitbucketResourceDiffScan; +export type TQueueSecretScanningResourceDiffScan = + | TQueueGitHubResourceDiffScan + | TQueueBitbucketResourceDiffScan + | TQueueGitLabResourceDiffScan; export type TQueueSecretScanningSendNotification = { dataSource: TSecretScanningDataSources; @@ -170,6 +194,11 @@ export type TSecretScanningFactoryInitialize< callback: (parameters: { credentials?: C; externalId?: string }) => Promise ) => Promise; +export type TSecretScanningFactoryValidateConfigUpdate< + C extends TSecretScanningDataSourceInput["config"], + T extends TSecretScanningDataSourceWithConnection +> = (params: { config: C; dataSource: T }) => Promise; + export type TSecretScanningFactoryPostInitialization< P extends TSecretScanningDataSourceInput, T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, @@ -181,17 +210,23 @@ export type TSecretScanningFactoryTeardown< C extends TSecretScanningDataSourceCredentials = undefined > = (params: { dataSource: T; credentials: C }) => Promise; +export type TSecretScanningFactoryParams = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + export type TSecretScanningFactory< T extends TSecretScanningDataSourceWithConnection, P extends TQueueSecretScanningResourceDiffScan["payload"], I extends TSecretScanningDataSourceInput, C extends TSecretScanningDataSourceCredentials | undefined = undefined -> = () => { +> = (params: TSecretScanningFactoryParams) => { listRawResources: TSecretScanningFactoryListRawResources; getFullScanPath: TSecretScanningFactoryGetFullScanPath; initialize: TSecretScanningFactoryInitialize; postInitialization: TSecretScanningFactoryPostInitialization; teardown: TSecretScanningFactoryTeardown; + validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate; getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts index 671d4e16b..392a088a6 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema, BitbucketFindingSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceSchema, GitLabFindingSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType", [ @@ -18,5 +20,10 @@ export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType" JSON.stringify({ title: "Bitbucket" }) + ), + GitLabFindingSchema.describe( + JSON.stringify({ + title: "GitLab" + }) ) ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2bdbe3331..29e863571 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2701,6 +2701,14 @@ export const SecretScanningDataSources = { GITHUB: { includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' }, + GITLAB: { + includeProjects: 'The projects to include when scanning. Defaults to all projects (["*"]).', + scope: "The GitLab scope scanning should occur at (project or group level).", + projectId: "The ID of the project to scan.", + projectName: "The name of the project to scan.", + groupId: "The ID of the group to scan projects from.", + groupName: "The name of the group to scan projects from." + }, BITBUCKET: { workspaceSlug: "The workspace to scan.", includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index a57705526..959fc741e 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -11,3 +11,5 @@ export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-] export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); export const BasicRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); + +export const GitLabProjectRegex = new RE2(/^[a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)+$/); diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts index 1323fa4ad..3c0813be3 100644 --- a/backend/src/server/plugins/secret-scanner-v2.ts +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -4,6 +4,8 @@ import { Probot } from "probot"; import { z } from "zod"; import { TBitbucketPushEvent } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types"; +import { TGitLabDataSourcePushEventPayload } from "@app/ee/services/secret-scanning-v2/gitlab"; +import { GitLabWebHookEvent } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -113,4 +115,36 @@ export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvide return res.send("ok"); } }); + + // gitlab push event webhook + server.route({ + method: "POST", + url: "/gitlab", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const event = req.headers["x-gitlab-event"] as GitLabWebHookEvent; + const token = req.headers["x-gitlab-token"] as string; + const dataSourceId = req.headers["x-data-source-id"] as string; + + if (event !== GitLabWebHookEvent.Push) { + return res.status(400).send({ message: `Event type not supported: ${event as string}` }); + } + + if (!token) { + return res.status(401).send({ message: "Unauthorized: Missing token" }); + } + + if (!dataSourceId) return res.status(400).send({ message: "Data Source ID header is required" }); + + await server.services.secretScanningV2.gitlab.handlePushEvent({ + dataSourceId, + payload: req.body as TGitLabDataSourcePushEventPayload, + token + }); + + return res.send("ok"); + } + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b2b0c9924..f17493714 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1931,7 +1931,8 @@ export const registerRoutes = async ( projectMembershipDAL, smtpService, kmsService, - keyStore + keyStore, + appConnectionDAL }); const secretScanningV2Service = secretScanningV2ServiceFactory({ @@ -1940,7 +1941,8 @@ export const registerRoutes = async ( licenseService, secretScanningV2DAL, secretScanningV2Queue, - kmsService + kmsService, + appConnectionDAL }); // setup the communication with license key server diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts index d874d1447..cb4e27e94 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts @@ -222,6 +222,37 @@ export const validateGitLabConnectionCredentials = async (config: TGitLabConnect return inputCredentials; }; +export const getGitLabConnectionClient = async ( + appConnection: TGitLabConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + let { accessToken } = appConnection.credentials; + + if ( + appConnection.method === GitLabConnectionMethod.OAuth && + appConnection.credentials.refreshToken && + new Date(appConnection.credentials.expiresAt) < new Date() + ) { + accessToken = await refreshGitLabToken( + appConnection.credentials.refreshToken, + appConnection.id, + appConnection.orgId, + appConnectionDAL, + kmsService, + appConnection.credentials.instanceUrl + ); + } + + const client = await getGitLabClient( + accessToken, + appConnection.credentials.instanceUrl, + appConnection.method === GitLabConnectionMethod.OAuth + ); + + return client; +}; + export const listGitLabProjects = async ({ appConnection, appConnectionDAL, diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx new file mode 100644 index 000000000..1ed9f8cea --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx new file mode 100644 index 000000000..5c26f478d --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx new file mode 100644 index 000000000..48e9b9fb4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx new file mode 100644 index 000000000..42bca6419 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx new file mode 100644 index 000000000..299e2f0a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx new file mode 100644 index 000000000..952fa1e89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx new file mode 100644 index 000000000..2d6ffbed3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx new file mode 100644 index 000000000..da4623bae --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx new file mode 100644 index 000000000..5ccad9c16 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx new file mode 100644 index 000000000..e48ee40d1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/docs.json b/docs/docs.json index ebbc31500..6529df5fc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -220,7 +220,8 @@ "pages": [ "documentation/platform/secret-scanning/overview", "documentation/platform/secret-scanning/bitbucket", - "documentation/platform/secret-scanning/github" + "documentation/platform/secret-scanning/github", + "documentation/platform/secret-scanning/gitlab" ] } ] @@ -1199,6 +1200,21 @@ "api-reference/endpoints/secret-scanning/data-sources/github/scan", "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" ] + }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/create", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/update", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/delete", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource" + ] } ] }, diff --git a/docs/documentation/platform/secret-scanning/gitlab.mdx b/docs/documentation/platform/secret-scanning/gitlab.mdx new file mode 100644 index 000000000..52f52f505 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/gitlab.mdx @@ -0,0 +1,103 @@ +--- +title: "GitLab Secret Scanning" +sidebarTitle: "GitLab" +description: "Learn how to configure secret scanning for GitLab." +--- + +## Prerequisites + +- Create a [GitLab Connection](/integrations/app-connections/gitlab) with Secret Scanning permissions + +## Create a GitLab Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **GitLab** option. + ![Select GitLab](/images/platform/secret-scanning/gitlab/step-2.png) + + 3. Configure which workspace and repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/gitlab/step-3.png) + + - **GitLab Connection** - the connection that has access to the repositories you want to scan. + - **Scope** - the GitLab scope to scan secrets in. + - **Project** - scan an individual GitLab project. + - **Group** - scan one or more projects belonging to a GitLab group. + - **Scan Repositories** - when using **Group Scope**, select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/gitlab/step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/gitlab/step-5.png) + + 6. Your **GitLab Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/gitlab/step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/gitlab/step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/gitlab/step-8.png) + + + To create a GitLab Data Source, make an API request to the [Create GitLab Data Source](/api-reference/endpoints/secret-scanning/data-sources/gitlab/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/gitlab \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-gitlab-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my gitlab data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-gitlab-source", + "description": "my gitlab data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "gitlab", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "gitlab", + "name": "my-gitlab-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + } + } + ``` + + diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-bottom.png rename to docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top.png b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-top.png rename to docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-project-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index b81c0fbcd..60e9236ca 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -26,8 +26,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/gitlab/oauth/callback`. - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top.png) - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom.png) + Depending on your use case, add one or more of the following scopes to your application: + + + + For Secret Syncs, your application will require the `api` scope: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png) + + + For Secret Scanning, your application will require the `api` and `read_repository` scopes: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png) + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. @@ -96,16 +110,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Personal Access Tokens](/images/app-connections/gitlab/gitlab-add-access-token.png) - - - For Secret Syncs, your token will require the ability to access the API: Fill in the token details: - **Token name**: A descriptive name for the token (e.g., "connection-token") - **Expiration date**: Set an appropriate expiration date - - **Select scopes**: Choose the **api** scope for full API access + - **Select scopes**: Depending on your use case, add one or more of the following scopes: - ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form.png) + + + For Secret Syncs, your token will require the `api` scope: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png) + + For Secret Scanning, your token will require the `api` and `read_repository` scopes: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png) + @@ -134,17 +154,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Project Access Tokens](/images/app-connections/gitlab/gitlab-project-access-token-list.png) - - - For Secret Syncs, your token will require the ability to access the API and be at least an **Owner**: - Fill in the token details: - - **Token name**: A descriptive name for the token - - **Expiration date**: Set an appropriate expiration date - - **Select role**: Choose **Owner** or higher role - - **Select scopes**: Choose the **api** scope for API access + Fill in the token details: + - **Token name**: A descriptive name for the token + - **Expiration date**: Set an appropriate expiration date + - **Select role and scopes**: Depending on your use case, add the required role and one or more of the following scopes: - ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form.png) - + + + For Secret Syncs, your token will require the `api` scope and at least the **Owner** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png) + + + For Secret Scanning, your token will require the `api` and `read_repository` scopes and the **Maintainer** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png) + diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx new file mode 100644 index 000000000..825ee550d --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx @@ -0,0 +1,274 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue, SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TGitLabGroup, + TGitLabProject, + useGitLabConnectionListGroups, + useGitLabConnectionListProjects +} from "@app/hooks/api/appConnections/gitlab"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllProjects = "all-projects", + SelectProjects = "select-projects" +} + +export const GitLabDataSourceConfigFields = () => { + const { control, watch, setValue, formState } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + console.log(formState); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const scope = watch("config.scope"); + const groupName = watch("config.groupName"); + const includeProjects = watch("config.includeProjects"); + + const { data: projects, isPending: isProjectsPending } = useGitLabConnectionListProjects( + connectionId, + { enabled: Boolean(connectionId) } + ); + + const { data: groups, isPending: isGroupsPending } = useGitLabConnectionListGroups(connectionId, { + enabled: Boolean(connectionId) && scope === GitLabDataSourceScope.Group + }); + + const scanMethod = + !includeProjects || includeProjects.includes("*") + ? ScanMethod.AllProjects + : ScanMethod.SelectProjects; + + useEffect(() => { + if (!includeProjects) { + setValue("config.includeProjects", ["*"]); + } + }, [includeProjects]); + + const clearAllFields = () => { + setValue("config.includeProjects", []); + setValue("config.projectName", ""); + setValue("config.groupName", ""); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.projectId", undefined); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.groupId", undefined); + }; + + return ( + <> + + ( + +

Specify the GitLab scope scanning should be performed at:

+
    +
  • +

    + Project: Scan an + individual GitLab project. +

    +
  • +
  • +

    + Group: Scan one or more + projects belonging to a GitLab group. +

    +
  • +
+ + } + > + + + )} + /> + {scope === GitLabDataSourceScope.Project ? ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(project.id, 10))} + onChange={(newValue) => { + const project = newValue as SingleValue; + + onChange(project ? Number.parseInt(project.id, 10) : null); + setValue("config.projectName", project?.name ?? ""); + }} + options={projects} + placeholder="Select project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + ) : ( + <> + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the group you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(group.id, 10))} + onChange={(newValue) => { + const group = newValue as SingleValue; + + onChange(group ? Number.parseInt(group.id, 10) : null); + setValue("config.groupName", group?.name ?? ""); + setValue("config.includeProjects", ["*"]); + }} + options={groups} + placeholder="Select group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + + + + {scanMethod === ScanMethod.SelectProjects && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value.includes(project.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map((p) => p.name) + : null + ); + }} + options={projects?.filter((project) => project.name.startsWith(groupName))} + placeholder="Select projects..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + )} + + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx index cbdc0f0b8..c4334c29a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -7,10 +7,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceConfigFields } from "./BitbucketDataSourceConfigFields"; import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; +import { GitLabDataSourceConfigFields } from "./GitLabDataSourceConfigFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceConfigFields }; export const SecretScanningDataSourceConfigFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx new file mode 100644 index 000000000..ad421e57e --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx @@ -0,0 +1,49 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConfigReviewSection } from "./shared"; + +export const GitLabDataSourceReviewFields = () => { + const { watch } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + const [config, connection] = watch(["config", "connection"]); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectName, projectId } = config; + return ( + + {connection && {connection.name}} + + {config.scope} + + {projectName || projectId} + + ); + } + + // group-scope + + const { includeProjects, groupName, groupId } = config; + const shouldScanAll = includeProjects.includes("*"); + + return ( + + {connection && {connection.name}} + + {config.scope} + + {groupName || groupId} + + {shouldScanAll ? "All" : includeProjects.join(", ")} + + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx index e29dc9ebd..69d630e8a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx @@ -6,10 +6,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceReviewFields } from "./BitbucketDataSourceReviewFields"; import { GitHubDataSourceReviewFields } from "./GitHubDataSourceReviewFields"; +import { GitLabDataSourceReviewFields } from "./GitLabDataSourceReviewFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceReviewFields }; export const SecretScanningDataSourceReviewFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts new file mode 100644 index 000000000..ef577a7d2 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { BaseSecretScanningDataSourceSchema } from "./base-secret-scanning-data-source-schema"; + +export const GitLabDataSourceSchema = z + .object({ + type: z.literal(SecretScanningDataSource.GitLab), + config: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group), + groupId: z.number(), + groupName: z.string(), + includeProjects: z + .array(z.string().min(1).max(256)) + .min(1, "One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project), + projectId: z.number(), + projectName: z.string() + }) + ]) + }) + .merge(BaseSecretScanningDataSourceSchema({ isConnectionRequired: true })); diff --git a/frontend/src/components/secret-scanning/forms/schemas/index.ts b/frontend/src/components/secret-scanning/forms/schemas/index.ts index 23e6d86e4..82a25e1ae 100644 --- a/frontend/src/components/secret-scanning/forms/schemas/index.ts +++ b/frontend/src/components/secret-scanning/forms/schemas/index.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema } from "./bitbucket-data-source-schema"; import { GitHubDataSourceSchema } from "./github-data-source-schema"; +import { GitLabDataSourceSchema } from "./gitlab-data-source-schema"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export type TSecretScanningDataSourceForm = z.infer; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx index 42387e6df..fe73ea5d5 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx @@ -16,8 +16,8 @@ import { import { TGitLabGroup, TGitLabProject, - useGitlabConnectionListGroups, - useGitlabConnectionListProjects + useGitLabConnectionListGroups, + useGitLabConnectionListProjects } from "@app/hooks/api/appConnections/gitlab"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; @@ -70,11 +70,11 @@ export const GitLabSyncFields = () => { const scope = useWatch({ name: "destinationConfig.scope", control }); const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control }); - const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, { + const { data: groups, isLoading: isGroupsLoading } = useGitLabConnectionListGroups(connectionId, { enabled: Boolean(connectionId) && scope === GitLabSyncScope.Group }); - const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects( + const { data: projects, isLoading: isProjectsLoading } = useGitLabConnectionListProjects( connectionId, { enabled: Boolean(connectionId) diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8c8475767..d554ba446 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -94,7 +94,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, - [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, + [AppConnection.GitLab]: { name: "GitLab", image: "GitLab.png" }, [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, diff --git a/frontend/src/helpers/secretScanningV2.ts b/frontend/src/helpers/secretScanningV2.ts index 8021142b5..588c61c1b 100644 --- a/frontend/src/helpers/secretScanningV2.ts +++ b/frontend/src/helpers/secretScanningV2.ts @@ -24,6 +24,11 @@ export const SECRET_SCANNING_DATA_SOURCE_MAP: Record< name: "Bitbucket", image: "Bitbucket.png", size: 45 + }, + [SecretScanningDataSource.GitLab]: { + name: "GitLab", + image: "GitLab.png", + size: 45 } }; @@ -32,7 +37,8 @@ export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record< AppConnection > = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const RESOURCE_DESCRIPTION_HELPER: Record< @@ -58,6 +64,13 @@ export const RESOURCE_DESCRIPTION_HELPER: Record< singularNoun: "repository", pluralTitle: "Repositories", singularTitle: "Repository" + }, + [SecretScanningDataSource.GitLab]: { + verb: "push", + pluralNoun: "projects", + singularNoun: "project", + pluralTitle: "Projects", + singularTitle: "project" } }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index b4c0df352..63eb0d0f6 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -133,7 +133,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, - [SecretSync.GitLab]: AppConnection.Gitlab, + [SecretSync.GitLab]: AppConnection.GitLab, [SecretSync.CloudflarePages]: AppConnection.Cloudflare, [SecretSync.CloudflareWorkers]: AppConnection.Cloudflare, [SecretSync.Supabase]: AppConnection.Supabase, diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c3a1fec05..6969d55bd 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -26,7 +26,7 @@ export enum AppConnection { Heroku = "heroku", Render = "render", Flyio = "flyio", - Gitlab = "gitlab", + GitLab = "gitlab", Cloudflare = "cloudflare", Bitbucket = "bitbucket", Zabbix = "zabbix", diff --git a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx index 155d56a23..baa9deecf 100644 --- a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx +++ b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx @@ -13,7 +13,7 @@ const gitlabConnectionKeys = { [...gitlabConnectionKeys.all, "groups", connectionId] as const }; -export const useGitlabConnectionListProjects = ( +export const useGitLabConnectionListProjects = ( connectionId: string, options?: Omit< UseQueryOptions< @@ -38,7 +38,7 @@ export const useGitlabConnectionListProjects = ( }); }; -export const useGitlabConnectionListGroups = ( +export const useGitLabConnectionListGroups = ( connectionId: string, options?: Omit< UseQueryOptions< diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 7dc5907e3..3c2e02af4 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -124,7 +124,7 @@ export type TFlyioConnectionOption = TAppConnectionOptionBase & { }; export type TGitlabConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; oauthClientId?: string; }; @@ -223,7 +223,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; - [AppConnection.Gitlab]: TGitlabConnectionOption; + [AppConnection.GitLab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; [AppConnection.Zabbix]: TZabbixConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts index 667475126..d4f215dd7 100644 --- a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts @@ -8,7 +8,7 @@ export enum GitLabConnectionMethod { OAuth = "oauth" } -export type TGitLabConnection = TRootAppConnection & { app: AppConnection.Gitlab } & ( +export type TGitLabConnection = TRootAppConnection & { app: AppConnection.GitLab } & ( | { method: GitLabConnectionMethod.AccessToken; credentials: { diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2924c3009..c9e2d6745 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -173,7 +173,7 @@ export type TAppConnectionMap = { [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; - [AppConnection.Gitlab]: TGitLabConnection; + [AppConnection.GitLab]: TGitLabConnection; [AppConnection.Cloudflare]: TCloudflareConnection; [AppConnection.Bitbucket]: TBitbucketConnection; [AppConnection.Zabbix]: TZabbixConnection; diff --git a/frontend/src/hooks/api/secretScanningV2/enums.ts b/frontend/src/hooks/api/secretScanningV2/enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/frontend/src/hooks/api/secretScanningV2/enums.ts +++ b/frontend/src/hooks/api/secretScanningV2/enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts new file mode 100644 index 000000000..82f1002e0 --- /dev/null +++ b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts @@ -0,0 +1,31 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { SecretScanningDataSource } from "../enums"; +import { TSecretScanningDataSourceBase } from "./shared"; + +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export type TGitLabDataSource = TSecretScanningDataSourceBase & { + type: SecretScanningDataSource.GitLab; + config: + | { + groupId: number; + groupName?: string; + includeProjects: string[]; + scope: GitLabDataSourceScope.Group; + } + | { + projectName?: string; + projectId: number; + scope: GitLabDataSourceScope.Project; + }; +}; + +export type TGitLabDataSourceOption = { + name: string; + type: SecretScanningDataSource.GitLab; + connection: AppConnection.GitLab; +}; diff --git a/frontend/src/hooks/api/secretScanningV2/types/index.ts b/frontend/src/hooks/api/secretScanningV2/types/index.ts index 078a7051d..2c45c9bcf 100644 --- a/frontend/src/hooks/api/secretScanningV2/types/index.ts +++ b/frontend/src/hooks/api/secretScanningV2/types/index.ts @@ -1,3 +1,7 @@ +import { + TGitLabDataSource, + TGitLabDataSourceOption +} from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; import { DiscriminativePick } from "@app/types"; import { @@ -11,7 +15,10 @@ import { import { TBitbucketDataSource, TBitbucketDataSourceOption } from "./bitbucket-data-source"; import { TGitHubDataSource, TGitHubDataSourceOption } from "./github-data-source"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = + | TGitHubDataSource + | TBitbucketDataSource + | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt: string | null; @@ -24,7 +31,10 @@ export type TListSecretScanningDataSources = { dataSources: TSecretScanningDataSourceWithDetails[]; }; -export type TSecretScanningDataSourceOption = TGitHubDataSourceOption | TBitbucketDataSourceOption; +export type TSecretScanningDataSourceOption = + | TGitHubDataSourceOption + | TBitbucketDataSourceOption + | TGitLabDataSourceOption; export type TListSecretScanningDataSourceOptions = { dataSourceOptions: TSecretScanningDataSourceOption[]; diff --git a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts index ed88ab685..cf5fc5e0e 100644 --- a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts @@ -29,7 +29,7 @@ export type TGitLabSync = TRootSecretSync & { shouldHideSecrets?: boolean; }; connection: { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; name: string; id: string; }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index d4b87af81..a6f7594ba 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -137,7 +137,7 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Flyio: return ; - case AppConnection.Gitlab: + case AppConnection.GitLab: return ; case AppConnection.Cloudflare: return ; @@ -245,7 +245,7 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Flyio: return ; - case AppConnection.Gitlab: + case AppConnection.GitLab: return ; case AppConnection.Cloudflare: return ; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitLabConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitLabConnectionForm.tsx index 038e14ca8..8cbc2c54c 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitLabConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitLabConnectionForm.tsx @@ -38,7 +38,7 @@ type Props = { const formSchema = z.discriminatedUnion("method", [ genericAppConnectionFieldsSchema.extend({ - app: z.literal(AppConnection.Gitlab), + app: z.literal(AppConnection.GitLab), method: z.literal(GitLabConnectionMethod.AccessToken), credentials: z.object({ accessToken: z.string().min(1, "Access token is required"), @@ -54,7 +54,7 @@ const formSchema = z.discriminatedUnion("method", [ }) }), genericAppConnectionFieldsSchema.extend({ - app: z.literal(AppConnection.Gitlab), + app: z.literal(AppConnection.GitLab), method: z.literal(GitLabConnectionMethod.OAuth), credentials: z.object({ code: z.string().min(1, "Code is required"), @@ -79,7 +79,7 @@ export const GitLabConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr const { option: { oauthClientId }, isLoading - } = useGetAppConnectionOption(AppConnection.Gitlab); + } = useGetAppConnectionOption(AppConnection.GitLab); const form = useForm({ resolver: zodResolver(formSchema), @@ -88,7 +88,7 @@ export const GitLabConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr ? { ...appConnection, credentials: { code: "custom" } } : (appConnection ?? ({ - app: AppConnection.Gitlab, + app: AppConnection.GitLab, method: GitLabConnectionMethod.AccessToken, credentials: { accessToken: "", @@ -207,7 +207,7 @@ export const GitLabConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr render={({ field: { value, onChange }, fieldState: { error } }) => ( > = { [AppConnection.GitHub]: "githubConnectionFormData", [AppConnection.GitHubRadar]: "githubRadarConnectionFormData", - [AppConnection.Gitlab]: "gitlabConnectionFormData", + [AppConnection.GitLab]: "gitlabConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData", @@ -141,17 +141,17 @@ export const OAuthCallbackPage = () => { }; const handleGitlab = useCallback(async () => { - const formData = getFormData(AppConnection.Gitlab); + const formData = getFormData(AppConnection.GitLab); if (formData === null) return null; - clearState(AppConnection.Gitlab); + clearState(AppConnection.GitLab); const { connectionId, name, description, returnUrl, isUpdate } = formData; try { if (isUpdate && connectionId) { await updateAppConnection.mutateAsync({ - app: AppConnection.Gitlab, + app: AppConnection.GitLab, connectionId, credentials: { code: code as string @@ -159,7 +159,7 @@ export const OAuthCallbackPage = () => { }); } else { await createAppConnection.mutateAsync({ - app: AppConnection.Gitlab, + app: AppConnection.GitLab, name, description, method: GitLabConnectionMethod.OAuth, @@ -521,7 +521,7 @@ export const OAuthCallbackPage = () => { data = await handleGithub(); } else if (appConnection === AppConnection.GitHubRadar) { data = await handleGithubRadar(); - } else if (appConnection === AppConnection.Gitlab) { + } else if (appConnection === AppConnection.GitLab) { data = await handleGitlab(); } else if (appConnection === AppConnection.AzureKeyVault) { data = await handleAzureKeyVault(); diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay.tsx index 874dc034f..55f2b9a32 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay.tsx @@ -5,6 +5,7 @@ import { import { BitbucketDataSourceConfigDisplay } from "./BitbucketDataSourceConfigDisplay"; import { GitHubDataSourceConfigDisplay } from "./GitHubDataSourceConfigDisplay"; +import { GitLabDataSourceConfigDisplay } from "./GitLabDataSourceConfigDisplay"; type Props = { dataSource: TSecretScanningDataSource; @@ -16,6 +17,8 @@ export const DataSourceConfigDisplay = ({ dataSource }: Props) => { return ; case SecretScanningDataSource.Bitbucket: return ; + case SecretScanningDataSource.GitLab: + return ; default: throw new Error( `Unhandled dataSource type ${(dataSource as TSecretScanningDataSource).type}` diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/GitLabDataSourceConfigDisplay.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/GitLabDataSourceConfigDisplay.tsx new file mode 100644 index 000000000..2f2f63d06 --- /dev/null +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/GitLabDataSourceConfigDisplay.tsx @@ -0,0 +1,41 @@ +import { GenericFieldLabel } from "@app/components/v2"; +import { + GitLabDataSourceScope, + TGitLabDataSource +} from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +type Props = { + dataSource: TGitLabDataSource; +}; + +export const GitLabDataSourceConfigDisplay = ({ dataSource }: Props) => { + const { config } = dataSource; + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectName, projectId } = config; + return ( + <> + + {config.scope} + + {projectName || projectId} + + ); + } + + // group-scope + + const { includeProjects, groupId, groupName } = config; + + return ( + <> + + {config.scope} + + {groupName || groupId} + + {includeProjects.includes("*") ? "All" : includeProjects.join(", ")} + + + ); +}; diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceRow.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceRow.tsx index 4534056af..6933a45d8 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceRow.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceRow.tsx @@ -35,12 +35,14 @@ import { import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; import { useToggle } from "@app/hooks"; import { + SecretScanningDataSource, SecretScanningFindingStatus, SecretScanningScanStatus, TSecretScanningDataSource, TSecretScanningResourceWithDetails, useTriggerSecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; type Props = { resource: TSecretScanningResourceWithDetails; @@ -51,12 +53,30 @@ export const SecretScanningResourceRow = ({ resource, dataSource }: Props) => { const { id, name, lastScannedAt, lastScanStatus, unresolvedFindings, lastScanStatusMessage } = resource; - const { - config: { includeRepos } - } = dataSource; + let isActive: boolean; + + switch (dataSource.type) { + case SecretScanningDataSource.Bitbucket: + case SecretScanningDataSource.GitHub: + isActive = + dataSource.config.includeRepos.includes("*") || + dataSource.config.includeRepos.includes(name); + break; + case SecretScanningDataSource.GitLab: { + if (dataSource.config.scope === GitLabDataSourceScope.Project) { + isActive = true; // always active + } else { + isActive = + dataSource.config.includeProjects.includes("*") || + dataSource.config.includeProjects.includes(name); + } + break; + } + default: + throw new Error("Unhandled Data Source Type: Active Status"); + } // scott: will need to be differentiated by type once other data sources are available - const isActive = includeRepos.includes("*") || includeRepos.includes(name); const triggerDataSourceScan = useTriggerSecretScanningDataSource(); diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceTable.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceTable.tsx index ae671c49a..e44a72e0e 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceTable.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningResourceTable.tsx @@ -38,10 +38,12 @@ import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { + SecretScanningDataSource, SecretScanningScanStatus, TSecretScanningDataSource, useListSecretScanningResources } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; import { SecretScanningResourceRow } from "./SecretScanningResourceRow"; @@ -104,12 +106,29 @@ export const SecretScanningResourcesTable = ({ dataSource }: Props) => { resources .filter((resource) => { const { name } = resource; - const { - config: { includeRepos } - } = dataSource; - // scott: will need to be differentiated by type once other data sources are available - const isActive = includeRepos.includes("*") || includeRepos.includes(name); + let isActive: boolean; + + switch (dataSource.type) { + case SecretScanningDataSource.Bitbucket: + case SecretScanningDataSource.GitHub: + isActive = + dataSource.config.includeRepos.includes("*") || + dataSource.config.includeRepos.includes(name); + break; + case SecretScanningDataSource.GitLab: { + if (dataSource.config.scope === GitLabDataSourceScope.Project) { + isActive = true; // always active + } else { + isActive = + dataSource.config.includeProjects.includes("*") || + dataSource.config.includeProjects.includes(name); + } + break; + } + default: + throw new Error("Unhandled Data Source Type: Active Filter"); + } if (filters.status.length === 1) { if (filters.status.includes(ResourceStatus.Active) && !isActive) {