diff --git a/.env.example b/.env.example index 8ee3d2001..23f845b71 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,14 @@ INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= INF_APP_CONNECTION_GITHUB_APP_SLUG= INF_APP_CONNECTION_GITHUB_APP_ID= +#github radar app connection +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG= +INF_APP_CONNECTION_GITHUB_RADAR_APP_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= + #gcp app connection INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 3fcb06fbf..27f14fdb2 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -37,6 +37,7 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { TSecretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; @@ -271,6 +272,7 @@ declare module "fastify" { microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; + secretScanningV2: TSecretScanningV2ServiceFactory; internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; pkiTemplate: TPkiTemplatesServiceFactory; }; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 22999d916..44cd6bc79 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -336,9 +336,24 @@ import { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate, TSecrets, + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate, + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate, + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate, TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate, + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate, TSecretSharing, TSecretSharingInsert, TSecretSharingUpdate, @@ -1107,5 +1122,30 @@ declare module "knex/types/tables" { TGithubOrgSyncConfigsInsert, TGithubOrgSyncConfigsUpdate >; + [TableName.SecretScanningDataSource]: KnexOriginal.CompositeTableType< + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate + >; + [TableName.SecretScanningResource]: KnexOriginal.CompositeTableType< + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate + >; + [TableName.SecretScanningScan]: KnexOriginal.CompositeTableType< + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate + >; + [TableName.SecretScanningFinding]: KnexOriginal.CompositeTableType< + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate + >; + [TableName.SecretScanningConfig]: KnexOriginal.CompositeTableType< + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts new file mode 100644 index 000000000..86da451f3 --- /dev/null +++ b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts @@ -0,0 +1,107 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretScanningDataSource))) { + await knex.schema.createTable(TableName.SecretScanningDataSource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").index(); // if we need a unique way of identifying this data source from an external resource + t.string("name", 48).notNullable(); + t.string("description"); + t.string("type").notNullable(); + t.jsonb("config").notNullable(); + t.binary("encryptedCredentials"); // webhook credentials, etc. + t.uuid("connectionId"); + t.boolean("isAutoScanEnabled").defaultTo(true); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.boolean("isDisconnected").notNullable().defaultTo(false); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningResource))) { + await knex.schema.createTable(TableName.SecretScanningResource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").notNullable(); + t.string("name").notNullable(); + t.string("type").notNullable(); + t.uuid("dataSourceId").notNullable(); + t.foreign("dataSourceId").references("id").inTable(TableName.SecretScanningDataSource).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["dataSourceId", "externalId"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningResource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningScan))) { + await knex.schema.createTable(TableName.SecretScanningScan, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("status").notNullable().defaultTo(SecretScanningScanStatus.Queued); + t.string("statusMessage", 1024); + t.string("type").notNullable(); + t.uuid("resourceId").notNullable(); + t.foreign("resourceId").references("id").inTable(TableName.SecretScanningResource).onDelete("CASCADE"); + t.timestamp("createdAt").defaultTo(knex.fn.now()); + }); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningFinding))) { + await knex.schema.createTable(TableName.SecretScanningFinding, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("dataSourceName").notNullable(); + t.string("dataSourceType").notNullable(); + t.string("resourceName").notNullable(); + t.string("resourceType").notNullable(); + t.string("rule").notNullable(); + t.string("severity").notNullable(); + t.string("status").notNullable().defaultTo(SecretScanningFindingStatus.Unresolved); + t.string("remarks"); + t.string("fingerprint").notNullable(); + t.jsonb("details").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("scanId"); + t.foreign("scanId").references("id").inTable(TableName.SecretScanningScan).onDelete("SET NULL"); + t.timestamps(true, true, true); + t.unique(["projectId", "fingerprint"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningFinding); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningConfig))) { + await knex.schema.createTable(TableName.SecretScanningConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable().unique(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("content", 5000); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretScanningFinding); + + await dropOnUpdateTrigger(knex, TableName.SecretScanningFinding); + await knex.schema.dropTableIfExists(TableName.SecretScanningScan); + + await knex.schema.dropTableIfExists(TableName.SecretScanningResource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningResource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningDataSource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningConfig); + await dropOnUpdateTrigger(knex, TableName.SecretScanningConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 8e098c2ec..6743a23cc 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -111,7 +111,12 @@ export * from "./secret-rotation-outputs"; export * from "./secret-rotation-v2-secret-mappings"; export * from "./secret-rotations"; export * from "./secret-rotations-v2"; +export * from "./secret-scanning-configs"; +export * from "./secret-scanning-data-sources"; +export * from "./secret-scanning-findings"; export * from "./secret-scanning-git-risks"; +export * from "./secret-scanning-resources"; +export * from "./secret-scanning-scans"; export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 6887975fa..6722ce235 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -159,7 +159,12 @@ export enum TableName { MicrosoftTeamsIntegrations = "microsoft_teams_integrations", ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", SecretReminderRecipients = "secret_reminder_recipients", - GithubOrgSyncConfig = "github_org_sync_configs" + GithubOrgSyncConfig = "github_org_sync_configs", + SecretScanningDataSource = "secret_scanning_data_sources", + SecretScanningResource = "secret_scanning_resources", + SecretScanningScan = "secret_scanning_scans", + SecretScanningFinding = "secret_scanning_findings", + SecretScanningConfig = "secret_scanning_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -248,7 +253,8 @@ export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", KMS = "kms", - SSH = "ssh" + SSH = "ssh", + SecretScanning = "secret-scanning" } export enum ActionProjectType { @@ -256,6 +262,7 @@ export enum ActionProjectType { CertificateManager = ProjectType.CertificateManager, KMS = ProjectType.KMS, SSH = ProjectType.SSH, + SecretScanning = ProjectType.SecretScanning, // project operations that happen on all types Any = "any" } diff --git a/backend/src/db/schemas/secret-scanning-configs.ts b/backend/src/db/schemas/secret-scanning-configs.ts new file mode 100644 index 000000000..c3719d352 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-configs.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + content: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningConfigs = z.infer; +export type TSecretScanningConfigsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-scanning-data-sources.ts b/backend/src/db/schemas/secret-scanning-data-sources.ts new file mode 100644 index 000000000..d79b45e79 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-data-sources.ts @@ -0,0 +1,32 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningDataSourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + type: z.string(), + config: z.unknown(), + encryptedCredentials: zodBuffer.nullable().optional(), + connectionId: z.string().uuid().nullable().optional(), + isAutoScanEnabled: z.boolean().default(true).nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + isDisconnected: z.boolean().default(false) +}); + +export type TSecretScanningDataSources = z.infer; +export type TSecretScanningDataSourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningDataSourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-findings.ts b/backend/src/db/schemas/secret-scanning-findings.ts new file mode 100644 index 000000000..c36f229f8 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-findings.ts @@ -0,0 +1,32 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningFindingsSchema = z.object({ + id: z.string().uuid(), + dataSourceName: z.string(), + dataSourceType: z.string(), + resourceName: z.string(), + resourceType: z.string(), + rule: z.string(), + severity: z.string(), + status: z.string().default("unresolved"), + remarks: z.string().nullable().optional(), + fingerprint: z.string(), + details: z.unknown(), + projectId: z.string(), + scanId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningFindings = z.infer; +export type TSecretScanningFindingsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningFindingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-resources.ts b/backend/src/db/schemas/secret-scanning-resources.ts new file mode 100644 index 000000000..e791e1cd6 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-resources.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningResourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string(), + name: z.string(), + type: z.string(), + dataSourceId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningResources = z.infer; +export type TSecretScanningResourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningResourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-scans.ts b/backend/src/db/schemas/secret-scanning-scans.ts new file mode 100644 index 000000000..88e676b5b --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-scans.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningScansSchema = z.object({ + id: z.string().uuid(), + status: z.string().default("queued"), + statusMessage: z.string().nullable().optional(), + type: z.string(), + resourceId: z.string().uuid(), + createdAt: z.date().nullable().optional() +}); + +export type TSecretScanningScans = z.infer; +export type TSecretScanningScansInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningScansUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index 70e5005a4..e364f4949 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -2,6 +2,10 @@ import { registerSecretRotationV2Router, SECRET_ROTATION_REGISTER_ROUTER_MAP } from "@app/ee/routes/v2/secret-rotation-v2-routers"; +import { + registerSecretScanningV2Router, + SECRET_SCANNING_REGISTER_ROUTER_MAP +} from "@app/ee/routes/v2/secret-scanning-v2-routers"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -31,4 +35,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/secret-rotations" } ); + + await server.register( + async (secretScanningV2Router) => { + // register generic secret scanning endpoints + await secretScanningV2Router.register(registerSecretScanningV2Router); + + // register service-specific secret scanning endpoints (gitlab/github, etc.) + for await (const [type, router] of Object.entries(SECRET_SCANNING_REGISTER_ROUTER_MAP)) { + await secretScanningV2Router.register(router, { prefix: `data-sources/${type}` }); + } + }, + { prefix: "/secret-scanning" } + ); }; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts new file mode 100644 index 000000000..3961e7cbd --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceSchema, + UpdateGitHubDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/github"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitHubSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitHub, + server, + responseSchema: GitHubDataSourceSchema, + createSchema: CreateGitHubDataSourceSchema, + updateSchema: UpdateGitHubDataSourceSchema + }); 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 new file mode 100644 index 000000000..703529947 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -0,0 +1,12 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +import { registerGitHubSecretScanningRouter } from "./github-secret-scanning-router"; + +export * from "./secret-scanning-v2-router"; + +export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< + SecretScanningDataSource, + (server: FastifyZodProvider) => Promise +> = { + [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter +}; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts new file mode 100644 index 000000000..3a5c6b4d7 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts @@ -0,0 +1,593 @@ +import { z } from "zod"; + +import { SecretScanningResourcesSchema, SecretScanningScansSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_NAME_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TSecretScanningDataSource, + TSecretScanningDataSourceInput +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { ApiDocsTags, SecretScanningDataSources } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretScanningEndpoints = < + T extends TSecretScanningDataSource, + I extends TSecretScanningDataSourceInput +>({ + server, + type, + createSchema, + updateSchema, + responseSchema +}: { + type: SecretScanningDataSource; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + connectionId?: string; + config: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + updateSchema: z.ZodType<{ + name?: string; + config?: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `List the ${sourceType} Data Sources for the specified project.`, + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.LIST(type).projectId) + }), + response: { + 200: z.object({ dataSources: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const dataSources = (await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId, type }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + type, + count: dataSources.length, + dataSourceIds: dataSources.map((source) => source.id) + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.GET_BY_ID(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceById( + { dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: `/data-source-name/:dataSourceName`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by name and project ID.`, + params: z.object({ + sourceName: z + .string() + .trim() + .min(1, "Data Source name required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).sourceName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).projectId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sourceName } = req.params; + const { projectId } = req.query; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceByName( + { sourceName, projectId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId: dataSource.id, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Create ${ + startsWithVowel(sourceType) ? "an" : "a" + } ${sourceType} Data Source for the specified project.`, + body: createSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dataSource = (await server.services.secretScanningV2.createSecretScanningDataSource( + { ...req.body, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE, + metadata: { + dataSourceId: dataSource.id, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "PATCH", + url: "/:dataSourceId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Update the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.UPDATE(type).dataSourceId) + }), + body: updateSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.updateSecretScanningDataSource( + { ...req.body, dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE, + metadata: { + dataSourceId, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "DELETE", + url: `/:dataSourceId`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Delete the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.DELETE(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.deleteSecretScanningDataSource( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/resources/:resourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source resource.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId), + resourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).resourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId, resourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId, resourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId, + resourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/resources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the resources associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_RESOURCES(type).dataSourceId) + }), + response: { + 200: z.object({ resources: SecretScanningResourcesSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = await server.services.secretScanningV2.listSecretScanningResourcesByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the scans associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_SCANS(type).dataSourceId) + }), + response: { + 200: z.object({ scans: SecretScanningScansSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = await server.services.secretScanningV2.listSecretScanningScansByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/:dataSourceId/resources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resources: SecretScanningResourcesSchema.extend({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = + await server.services.secretScanningV2.listSecretScanningResourcesWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + scans: SecretScanningScansSchema.extend({ + unresolvedFindings: z.number(), + resolvedFindings: z.number(), + resourceName: z.string() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = + await server.services.secretScanningV2.listSecretScanningScansWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); +}; 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 new file mode 100644 index 000000000..70cfd08dc --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -0,0 +1,366 @@ +import { z } from "zod"; + +import { SecretScanningConfigsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + SecretScanningDataSourceSchema, + SecretScanningFindingSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas"; +import { + ApiDocsTags, + SecretScanningConfigs, + SecretScanningDataSources, + SecretScanningFindings +} from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [GitHubDataSourceListItemSchema]); + +export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/data-sources/options", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List the available Secret Scanning Data Source Options.", + response: { + 200: z.object({ + dataSourceOptions: SecretScanningDataSourceOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const dataSourceOptions = server.services.secretScanningV2.listSecretScanningDataSourceOptions(); + return { dataSourceOptions }; + } + }); + + server.route({ + method: "GET", + url: "/data-sources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Data Sources for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningDataSources.LIST().projectId) + }), + response: { + 200: z.object({ dataSources: SecretScanningDataSourceSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/findings", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Findings for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ findings: SecretScanningFindingSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const findings = await server.services.secretScanningV2.listSecretScanningFindingsByProjectId( + projectId, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_LIST, + metadata: { + findingIds: findings.map((finding) => finding.id), + count: findings.length + } + } + }); + + return { findings }; + } + }); + + server.route({ + method: "PATCH", + url: "/findings/:findingId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Finding.", + params: z.object({ + findingId: z.string().trim().min(1, "Finding ID required").describe(SecretScanningFindings.UPDATE.findingId) + }), + body: z.object({ + status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), + remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) + }), + response: { + 200: z.object({ finding: SecretScanningFindingSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { findingId }, + body, + permission + } = req; + + const { finding, projectId } = await server.services.secretScanningV2.updateSecretScanningFindingById( + { findingId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_UPDATE, + metadata: { + findingId, + ...body + } + } + }); + + return { finding }; + } + }); + + server.route({ + method: "GET", + url: "/configs", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Get the Secret Scanning Config for the specified project.", + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningConfigs.GET_BY_PROJECT_ID.projectId) + }), + response: { + 200: z.object({ + config: z.object({ content: z.string().nullish(), projectId: z.string(), updatedAt: z.date().nullish() }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const config = await server.services.secretScanningV2.findSecretScanningConfigByProjectId(projectId, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_GET + } + }); + + return { config }; + } + }); + + server.route({ + method: "PATCH", + url: "/configs", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Configuration.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningConfigs.UPDATE.projectId) + }), + body: z.object({ + content: z.string().nullable().describe(SecretScanningConfigs.UPDATE.content) + }), + response: { + 200: z.object({ config: SecretScanningConfigsSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + body, + permission + } = req; + + const config = await server.services.secretScanningV2.upsertSecretScanningConfig( + { projectId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE, + metadata: body + } + }); + + return { config }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/data-sources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: z.object({ + dataSources: z + .intersection( + SecretScanningDataSourceSchema, + z.object({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number().nullish() + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesWithDetailsByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/unresolved-findings-count", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ unresolvedFindings: z.number() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const unresolvedFindings = + await server.services.secretScanningV2.getSecretScanningUnresolvedFindingsCountByProjectId( + projectId, + permission + ); + + return { unresolvedFindings }; + } + }); +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 2d6bc7c06..808a13f68 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -10,6 +10,18 @@ import { TSecretRotationV2Raw, TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus, + SecretScanningScanType +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; @@ -382,6 +394,20 @@ export enum EventType { PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end", + SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list", + SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create", + SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update", + SECRET_SCANNING_DATA_SOURCE_DELETE = "secret-scanning-data-source-delete", + SECRET_SCANNING_DATA_SOURCE_GET = "secret-scanning-data-source-get", + SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN = "secret-scanning-data-source-trigger-scan", + SECRET_SCANNING_DATA_SOURCE_SCAN = "secret-scanning-data-source-scan", + SECRET_SCANNING_RESOURCE_LIST = "secret-scanning-resource-list", + SECRET_SCANNING_SCAN_LIST = "secret-scanning-scan-list", + SECRET_SCANNING_FINDING_LIST = "secret-scanning-finding-list", + SECRET_SCANNING_FINDING_UPDATE = "secret-scanning-finding-update", + SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get", + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update", + UPDATE_ORG = "update-org", CREATE_PROJECT = "create-project", @@ -2956,6 +2982,101 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent { }; } +interface SecretScanningDataSourceListEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; + metadata: { + type?: SecretScanningDataSource; + count: number; + dataSourceIds: string[]; + }; +} + +interface SecretScanningDataSourceGetEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + }; +} + +interface SecretScanningDataSourceCreateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE; + metadata: Omit & { dataSourceId: string }; +} + +interface SecretScanningDataSourceUpdateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE; + metadata: TUpdateSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceDeleteEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE; + metadata: TDeleteSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceTriggerScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN; + metadata: TTriggerSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN; + metadata: { + scanId: string; + resourceId: string; + resourceType: string; + dataSourceId: string; + dataSourceType: string; + scanStatus: SecretScanningScanStatus; + scanType: SecretScanningScanType; + numberOfSecretsDetected?: number; + }; +} + +interface SecretScanningResourceListEvent { + type: EventType.SECRET_SCANNING_RESOURCE_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + resourceIds: string[]; + count: number; + }; +} + +interface SecretScanningScanListEvent { + type: EventType.SECRET_SCANNING_SCAN_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + count: number; + }; +} + +interface SecretScanningFindingListEvent { + type: EventType.SECRET_SCANNING_FINDING_LIST; + metadata: { + findingIds: string[]; + count: number; + }; +} + +interface SecretScanningFindingUpdateEvent { + type: EventType.SECRET_SCANNING_FINDING_UPDATE; + metadata: TUpdateSecretScanningFindingDTO; +} + +interface SecretScanningConfigUpdateEvent { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE; + metadata: { + content: string | null; + }; +} + +interface SecretScanningConfigReadEvent { + type: EventType.SECRET_SCANNING_CONFIG_GET; + metadata?: Record; // not needed, based off projectId +} + interface OrgUpdateEvent { type: EventType.UPDATE_ORG; metadata: { @@ -3279,6 +3400,19 @@ export type Event = | MicrosoftTeamsWorkflowIntegrationGetEvent | MicrosoftTeamsWorkflowIntegrationListEvent | MicrosoftTeamsWorkflowIntegrationUpdateEvent + | SecretScanningDataSourceListEvent + | SecretScanningDataSourceGetEvent + | SecretScanningDataSourceCreateEvent + | SecretScanningDataSourceUpdateEvent + | SecretScanningDataSourceDeleteEvent + | SecretScanningDataSourceTriggerScanEvent + | SecretScanningDataSourceScanEvent + | SecretScanningResourceListEvent + | SecretScanningScanListEvent + | SecretScanningFindingListEvent + | SecretScanningFindingUpdateEvent + | SecretScanningConfigUpdateEvent + | SecretScanningConfigReadEvent | OrgUpdateEvent | ProjectCreateEvent | ProjectUpdateEvent diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 3384e3781..76ef7ef2a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -17,6 +17,7 @@ import { SapAseProvider } from "./sap-ase"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; +import { VerticaProvider } from "./vertica"; type TBuildDynamicSecretProviderDTO = { gatewayService: Pick; @@ -40,5 +41,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), - [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }) + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), + [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }) }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index ed59f4d67..91d26da32 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -16,7 +16,8 @@ export enum SqlProviders { MySQL = "mysql2", Oracle = "oracledb", MsSQL = "mssql", - SapAse = "sap-ase" + SapAse = "sap-ase", + Vertica = "vertica" } export enum ElasticSearchAuthTypes { @@ -293,6 +294,39 @@ export const DynamicSecretKubernetesSchema = z.object({ audiences: z.array(z.string().trim().min(1)) }); +export const DynamicSecretVerticaSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), + password: z.string().trim(), + database: z.string().trim(), + gatewayId: z.string().nullable().optional(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements") +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -337,7 +371,8 @@ export enum DynamicSecretProviders { Snowflake = "snowflake", Totp = "totp", SapAse = "sap-ase", - Kubernetes = "kubernetes" + Kubernetes = "kubernetes", + Vertica = "vertica" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -356,7 +391,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts new file mode 100644 index 000000000..9e283ab41 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -0,0 +1,367 @@ +import { randomInt } from "crypto"; +import handlebars from "handlebars"; +import knex, { Knex } from "knex"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +interface VersionResult { + version: string; +} + +interface SessionResult { + session_id?: string; +} + +interface DatabaseQueryResult { + rows?: Array>; +} + +// Extended Knex client interface to handle Vertica-specific overrides +interface VerticaKnexClient extends Knex { + client: { + parseVersion?: () => string; + }; +} + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + +type TVerticaProviderDTO = { + gatewayService: Pick; +}; + +export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretVerticaSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("Vertica revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } + return { ...providerInputs, hostIp }; + }; + + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const config = { + client: "pg", + connection: { + host: providerInputs.hostIp, + port: providerInputs.port, + database: providerInputs.database, + user: providerInputs.username, + password: providerInputs.password, + ssl: false + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { + min: 0, + max: 1, + acquireTimeoutMillis: 30000, + createTimeoutMillis: 30000, + destroyTimeoutMillis: 5000, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 100 + }, + // Disable version checking for Vertica compatibility + version: "9.6.0" // Fake a compatible PostgreSQL version + }; + + const client = knex(config) as VerticaKnexClient; + + // Override the version parsing to prevent errors with Vertica + if (client.client && typeof client.client.parseVersion !== "undefined") { + client.client.parseVersion = () => "9.6.0"; + } + + return client; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + + const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); + + const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined; + + if (!resultFromSelectedDatabase?.version) { + throw new BadRequestError({ + message: "Failed to validate Vertica connection, version query failed" + }); + } + + isConnected = true; + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return isConnected; + }; + + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); + + const username = generateUsername(usernameTemplate); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Execute queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Check for active sessions and close them + try { + const sessionResult: DatabaseQueryResult = await client.raw( + "SELECT session_id FROM sessions WHERE user_name = ?", + [username] + ); + + const activeSessions = (sessionResult.rows || []) as SessionResult[]; + + // Close all sessions in parallel since they're independent operations + if (activeSessions.length > 0) { + const sessionClosePromises = activeSessions.map(async (session) => { + try { + await client!.raw("SELECT close_session(?)", [session.session_id]); + } catch (error) { + // Continue if session is already closed + logger.error(error, `Failed to close session ${session.session_id}`); + } + }); + + await Promise.allSettled(sessionClosePromises); + } + } catch (error) { + // Continue if we can't query sessions (permissions, etc.) + logger.error(error, "Could not query/close active sessions"); + } + + // Execute revocation queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 231a35d0d..c2db3e6e7 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ kmip: false, gateway: false, sshHostGroups: false, + secretScanning: false, enterpriseSecretSyncs: false, enterpriseAppConnections: false }); diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index f509c7127..2937ac265 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -72,6 +72,7 @@ export type TFeatureSet = { kmip: false; gateway: false; sshHostGroups: false; + secretScanning: false; enterpriseSecretSyncs: false; enterpriseAppConnections: false; }; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 0eb8f3cf8..40c5310ae 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -13,6 +13,9 @@ import { ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, ProjectPermissionSecretSyncActions, ProjectPermissionSet, ProjectPermissionSshHostActions, @@ -148,6 +151,7 @@ const buildAdminPermissionRules = () => { can( [ ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, @@ -219,6 +223,29 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretRotation ); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can( + [ProjectPermissionSecretScanningConfigActions.Read, ProjectPermissionSecretScanningConfigActions.Update], + ProjectPermissionSub.SecretScanningConfigs + ); + return rules; }; @@ -228,6 +255,7 @@ const buildMemberPermissionRules = () => { can( [ ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, @@ -399,6 +427,23 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + return rules; }; @@ -435,6 +480,19 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can([ProjectPermissionSecretScanningFindingActions.Read], ProjectPermissionSub.SecretScanningFindings); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + return rules; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 48fae49cd..34cffcd98 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -132,6 +132,26 @@ export enum ProjectPermissionKmipActions { GenerateClientCertificates = "generate-client-certificates" } +export enum ProjectPermissionSecretScanningDataSourceActions { + Read = "read-data-sources", + Create = "create-data-sources", + Edit = "edit-data-sources", + Delete = "delete-data-sources", + TriggerScans = "trigger-data-source-scans", + ReadScans = "read-data-source-scans", + ReadResources = "read-data-source-resources" +} + +export enum ProjectPermissionSecretScanningFindingActions { + Read = "read-findings", + Update = "update-findings" +} + +export enum ProjectPermissionSecretScanningConfigActions { + Read = "read-configs", + Update = "update-configs" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -167,7 +187,10 @@ export enum ProjectPermissionSub { Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", - Kmip = "kmip" + Kmip = "kmip", + SecretScanningDataSources = "secret-scanning-data-sources", + SecretScanningFindings = "secret-scanning-findings", + SecretScanningConfigs = "secret-scanning-configs" } export type SecretSubjectFields = { @@ -301,7 +324,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] - | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]; + | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] + | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] + | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -631,6 +657,26 @@ const GeneralPermissionSchema = [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionKmipActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SecretScanningDataSources) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningDataSourceActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningFindings).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningFindingActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningConfigs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningConfigActions).describe( + "Describe what action an entity can take." + ) }) ]; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts new file mode 100644 index 000000000..a8dd2eb42 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-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 GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitHub", + type: SecretScanningDataSource.GitHub, + connection: AppConnection.GitHubRadar +}; 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 new file mode 100644 index 000000000..2dde97d7c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -0,0 +1,230 @@ +import { join } from "path"; +import { ProbotOctokit } from "probot"; + +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 { + SecretScanningDataSource, + 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, + TSecretScanningFactoryPostInitialization +} 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 { GitHubRepositoryRegex } from "@app/lib/regex"; +import { listGitHubRadarRepositories, TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { TGitHubDataSourceWithConnection, TQueueGitHubResourceDiffScan } from "./github-secret-scanning-types"; + +export const GitHubSecretScanningFactory = () => { + const initialize: TSecretScanningFactoryInitialize = async ( + { connection, secretScanningV2DAL }, + callback + ) => { + const externalId = connection.credentials.installationId; + + const existingDataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId, + type: SecretScanningDataSource.GitHub + }); + + if (existingDataSource) + throw new BadRequestError({ + message: `A Data Source already exists for this GitHub Radar Connection in the Project with ID "${existingDataSource.projectId}"` + }); + + return callback({ + externalId + }); + }; + + const postInitialization: TSecretScanningFactoryPostInitialization = async () => { + // no post-initialization required + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { + connection, + config: { includeRepos } + } = dataSource; + + const repos = await listGitHubRadarRepositories(connection); + + const filteredRepos: typeof repos = []; + if (includeRepos.includes("*")) { + filteredRepos.push(...repos); + } else { + filteredRepos.push(...repos.filter((repo) => includeRepos.includes(repo.full_name))); + } + + return filteredRepos.map(({ id, full_name }) => ({ + name: full_name, + externalId: id.toString(), + type: SecretScanningResource.Repository + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { + data: { token } + } = await octokit.apps.createInstallationAccessToken({ + installation_id: Number(installationId) + }); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitHubRepositoryRegex.test(resourceName)) { + throw new Error("Invalid GitHub repository name"); + } + + await cloneRepository({ + cloneUrl: `https://x-access-token:${token}@github.com/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitHubResourceDiffScan["payload"] + > = ({ repository }) => { + return { + name: repository.full_name, + externalId: repository.id.toString(), + type: SecretScanningResource.Repository + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitHubDataSourceWithConnection, + TQueueGitHubResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { commits, repository } = payload; + + const [owner, repo] = repository.full_name.split("/"); + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitData = await octokit.repos.getCommit({ + owner, + repo, + ref: commit.id + }); + + // eslint-disable-next-line no-continue + if (!commitData.data.files) continue; + + for (const file of commitData.data.files) { + if ((file.status === "added" || file.status === "modified") && file.patch) { + // eslint-disable-next-line + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${file.patch}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(file.patch!, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(file.patch!, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: file.filename, + Commit: commit.id, + Author: commit.author.name, + Email: commit.author.email ?? "", + Message: commit.message, + Fingerprint: `${commit.id}:${file.filename}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://github.com/${resourceName}/blob/${commit.id}/${file.filename}#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 + }) + ); + }; + + return { + initialize, + postInitialization, + listRawResources, + getFullScanPath, + getDiffScanResourcePayload, + getDiffScanFindingsPayload + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts new file mode 100644 index 000000000..f1eec125c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +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 { GitHubRepositoryRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitHubDataSourceConfigSchema = z.object({ + includeRepos: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitHubRepositoryRegex.test(value), "Invalid repository name format") + ) + .nonempty("One or more repositories required") + .max(100, "Cannot configure more than 100 repositories") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITHUB.includeRepos) +}); + +export const GitHubDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const CreateGitHubDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const UpdateGitHubDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitHub) + .extend({ + config: GitHubDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubDataSourceListItemSchema = z + .object({ + name: z.literal("GitHub"), + connection: z.literal(AppConnection.GitHubRadar), + type: z.literal(SecretScanningDataSource.GitHub) + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Repository), + dataSourceType: z.literal(SecretScanningDataSource.GitHub), + details: GitRepositoryScanFindingDetailsSchema +}); diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts new file mode 100644 index 000000000..8e38e04c1 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts @@ -0,0 +1,87 @@ +import { PushEvent } from "@octokit/webhooks-types"; + +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 { TGitHubDataSource } from "./github-secret-scanning-types"; + +export const githubSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick +) => { + const handleInstallationDeletedEvent = async (installationId: number) => { + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installationId), + type: SecretScanningDataSource.GitHub + }); + + if (!dataSource) { + logger.error( + `secretScanningV2RemoveEvent: GitHub - Could not find data source [installationId=${installationId}]` + ); + return; + } + + logger.info( + `secretScanningV2RemoveEvent: GitHub - installation deleted [installationId=${installationId}] [dataSourceId=${dataSource.id}]` + ); + + await secretScanningV2DAL.dataSources.updateById(dataSource.id, { + isDisconnected: true + }); + }; + + const handlePushEvent = async (payload: PushEvent) => { + const { commits, repository, installation } = payload; + + if (!commits || !repository || !installation) { + logger.warn( + `secretScanningV2PushEvent: GitHub - Insufficient data [commits=${commits?.length ?? 0}] [repository=${repository.name}] [installationId=${installation?.id}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installation.id), + type: SecretScanningDataSource.GitHub + })) as TGitHubDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitHub - Could not find data source [installationId=${installation.id}]` + ); + return; + } + + const { + isAutoScanEnabled, + config: { includeRepos } + } = dataSource; + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [installationId=${installation.id}]` + ); + return; + } + + if (includeRepos.includes("*") || includeRepos.includes(repository.full_name)) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitHub, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to repository not being present in config [installationId=${installation.id}] [dataSourceId=${dataSource.id}]` + ); + } + }; + + return { + handlePushEvent, + handleInstallationDeletedEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts new file mode 100644 index 000000000..90b910d44 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts @@ -0,0 +1,32 @@ +import { PushEvent } from "@octokit/webhooks-types"; +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceListItemSchema, + GitHubDataSourceSchema, + GitHubFindingSchema +} from "./github-secret-scanning-schemas"; + +export type TGitHubDataSource = z.infer; + +export type TGitHubDataSourceInput = z.infer; + +export type TGitHubDataSourceListItem = z.infer; + +export type TGitHubFinding = z.infer; + +export type TGitHubDataSourceWithConnection = TGitHubDataSource & { + connection: TGitHubRadarConnection; +}; + +export type TQueueGitHubResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitHub; + payload: PushEvent; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/index.ts b/backend/src/ee/services/secret-scanning-v2/github/index.ts new file mode 100644 index 000000000..b8bc755a6 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/index.ts @@ -0,0 +1,3 @@ +export * from "./github-secret-scanning-constants"; +export * from "./github-secret-scanning-schemas"; +export * from "./github-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts new file mode 100644 index 000000000..447ffc22a --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts @@ -0,0 +1,460 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + SecretScanningResourcesSchema, + SecretScanningScansSchema, + TableName, + TSecretScanningDataSources +} from "@app/db/schemas"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { DatabaseError } from "@app/lib/errors"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindOpt +} from "@app/lib/knex"; + +export type TSecretScanningV2DALFactory = ReturnType; + +type TSecretScanningDataSourceFindFilter = Parameters>[0]; +type TSecretScanningDataSourceFindOptions = TFindOpt; + +const baseSecretScanningDataSourceQuery = ({ + filter = {}, + db, + tx +}: { + db: TDbClient; + filter?: TSecretScanningDataSourceFindFilter; + options?: TSecretScanningDataSourceFindOptions; + tx?: Knex; +}) => { + const query = (tx || db.replicaNode())(TableName.SecretScanningDataSource) + .join( + TableName.AppConnection, + `${TableName.SecretScanningDataSource}.connectionId`, + `${TableName.AppConnection}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningDataSource)) + .select( + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningDataSource, filter))); + } + + return query; +}; + +const expandSecretScanningDataSource = < + T extends Awaited>[number] +>( + dataSource: T +) => { + const { + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + connectionIsPlatformManagedCredentials, + ...el + } = dataSource; + + return { + ...el, + connectionId, + connection: connectionId + ? { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + } + : undefined + }; +}; + +export const secretScanningV2DALFactory = (db: TDbClient) => { + const dataSourceOrm = ormify(db, TableName.SecretScanningDataSource); + const resourceOrm = ormify(db, TableName.SecretScanningResource); + const scanOrm = ormify(db, TableName.SecretScanningScan); + const findingOrm = ormify(db, TableName.SecretScanningFinding); + const configOrm = ormify(db, TableName.SecretScanningConfig); + + const findDataSource = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }); + + if (!dataSources.length) return []; + + return dataSources.map(expandSecretScanningDataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Scanning Data Source" }); + } + }; + + const findDataSourceById = async (id: string, tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter: { id }, db, tx }).first(); + + if (dataSource) return expandSecretScanningDataSource(dataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - Secret Scanning Data Source" }); + } + }; + + const createDataSource = async (data: Parameters<(typeof dataSourceOrm)["create"]>[0], tx?: Knex) => { + const source = await dataSourceOrm.create(data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const updateDataSourceById = async ( + dataSourceId: string, + data: Parameters<(typeof dataSourceOrm)["updateById"]>[1], + tx?: Knex + ) => { + const source = await dataSourceOrm.updateById(dataSourceId, data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const deleteDataSourceById = async (dataSourceId: string, tx?: Knex) => { + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: dataSourceId }, + db, + tx + }).first())!; + + await dataSourceOrm.deleteById(dataSourceId, tx); + + return expandSecretScanningDataSource(dataSource); + }; + + const findOneDataSource = async (filter: Parameters<(typeof dataSourceOrm)["findOne"]>[0], tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter, db, tx }).first(); + + if (dataSource) { + return expandSecretScanningDataSource(dataSource); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Scanning Data Source" }); + } + }; + + const findDataSourceWithDetails = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.dataSourceId`, + `${TableName.SecretScanningDataSource}.id` + ) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!dataSources.length) return []; + + const results = sqlNestRelationships({ + data: dataSources, + key: "id", + parentMapper: (dataSource) => expandSecretScanningDataSource(dataSource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage }) => ({ + id: scanId, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...dataSource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...dataSource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: scans.length ? findings.length : null + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Data Source" }); + } + }; + + const findResourcesWithDetails = async (filter: Parameters<(typeof resourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const resources = await (tx || db.replicaNode())(TableName.SecretScanningResource) + .where((qb) => { + if (filter) + void qb.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningResource, filter))); + }) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select(selectAllTableCols(TableName.SecretScanningResource)) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("type").withSchema(TableName.SecretScanningScan).as("scanType"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!resources.length) return []; + + const results = sqlNestRelationships({ + data: resources, + key: "id", + parentMapper: (resource) => SecretScanningResourcesSchema.parse(resource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage, scanType }) => ({ + id: scanId, + type: scanType, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...resource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...resource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: findings?.length ?? 0 + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Resource" }); + } + }; + + const findScansWithDetailsByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningScan)) + .select( + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId"), + db.ref("name").withSchema(TableName.SecretScanningResource).as("resourceName") + ); + + if (!scans.length) return []; + + const results = sqlNestRelationships({ + data: scans, + key: "id", + parentMapper: (scan) => SecretScanningScansSchema.parse(scan), + childrenMapper: [ + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId, findingStatus }) => ({ + id: findingId, + status: findingStatus + }) + }, + { + key: "resourceId", + label: "resources" as const, + mapper: ({ resourceName }) => ({ + name: resourceName + }) + } + ] + }); + + return results.map(({ findings, resources, ...scan }) => { + return { + ...scan, + unresolvedFindings: + findings?.filter((finding) => finding.status === SecretScanningFindingStatus.Unresolved).length ?? 0, + resolvedFindings: + findings?.filter((finding) => finding.status !== SecretScanningFindingStatus.Unresolved).length ?? 0, + resourceName: resources[0].name + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details By Data Source ID - Secret Scanning Scan" }); + } + }; + + const findScansByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + + .select(selectAllTableCols(TableName.SecretScanningScan)); + + return scans; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Data Source ID - Secret Scanning Scan" }); + } + }; + + return { + dataSources: { + ...dataSourceOrm, + find: findDataSource, + findById: findDataSourceById, + findOne: findOneDataSource, + create: createDataSource, + updateById: updateDataSourceById, + deleteById: deleteDataSourceById, + findWithDetails: findDataSourceWithDetails + }, + resources: { + ...resourceOrm, + findWithDetails: findResourcesWithDetails + }, + scans: { + ...scanOrm, + findWithDetailsByDataSourceId: findScansWithDetailsByDataSourceId, + findByDataSourceId: findScansByDataSourceId + }, + findings: findingOrm, + configs: configOrm + }; +}; 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 new file mode 100644 index 000000000..082f3d760 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -0,0 +1,33 @@ +export enum SecretScanningDataSource { + GitHub = "github" +} + +export enum SecretScanningScanStatus { + Completed = "completed", + Failed = "failed", + Queued = "queued", + Scanning = "scanning" +} + +export enum SecretScanningScanType { + FullScan = "full-scan", + DiffScan = "diff-scan" +} + +export enum SecretScanningFindingStatus { + Resolved = "resolved", + Unresolved = "unresolved", + FalsePositive = "false-positive", + Ignore = "ignore" +} + +export enum SecretScanningResource { + Repository = "repository", + Project = "project" +} + +export enum SecretScanningFindingSeverity { + High = "high", + Medium = "medium", + Low = "low" +} 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 new file mode 100644 index 000000000..109afe5f3 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -0,0 +1,19 @@ +import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; + +import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; +import { + TQueueSecretScanningResourceDiffScan, + TSecretScanningDataSourceCredentials, + TSecretScanningDataSourceWithConnection, + TSecretScanningFactory +} from "./secret-scanning-v2-types"; + +type TSecretScanningFactoryImplementation = TSecretScanningFactory< + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceCredentials, + TQueueSecretScanningResourceDiffScan["payload"] +>; + +export const SECRET_SCANNING_FACTORY_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory 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 new file mode 100644 index 000000000..64a0ba4ed --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -0,0 +1,140 @@ +import { AxiosError } from "axios"; +import { exec } from "child_process"; +import RE2 from "re2"; + +import { readFindingsFile } 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 { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { titleCaseToCamelCase } from "@app/lib/fn"; + +import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secret-scanning-v2-enums"; +import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListItem } from "./secret-scanning-v2-types"; + +const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { + [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION +}; + +export const listSecretScanningDataSourceOptions = () => { + return Object.values(SECRET_SCANNING_SOURCE_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const cloneRepository = async ({ cloneUrl, repoPath }: TCloneRepository): Promise => { + const command = `git clone ${cloneUrl} ${repoPath} --bare`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +}; + +export function scanDirectory(inputPath: string, outputPath: string, configPath?: string): Promise { + return new Promise((resolve, reject) => { + const command = `cd ${inputPath} && infisical scan --exit-code=77 -r "${outputPath}" ${configPath ? `-c ${configPath}` : ""}`; + exec(command, (error) => { + if (error && error.code !== 77) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +export const scanGitRepositoryAndGetFindings = async ( + scanPath: string, + findingsPath: string, + configPath?: string +): TGetFindingsPayload => { + await scanDirectory(scanPath, findingsPath, configPath); + + const findingsData = JSON.parse(await readFindingsFile(findingsPath)) as SecretMatch[]; + + return findingsData.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: `${finding.Fingerprint}:${finding.StartColumn}`, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); +}; + +export const replaceNonChangesWithNewlines = (patch: string) => { + return patch + .split("\n") + .map((line) => { + // Keep added lines (remove the + prefix) + if (line.startsWith("+") && !line.startsWith("+++")) { + return line.substring(1); + } + + // Replace everything else with newlines to maintain line positioning + + return ""; + }) + .join("\n"); +}; + +const HunkHeaderRegex = new RE2(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + +export const convertPatchLineToFileLineNumber = (patch: string, patchLineNumber: number) => { + const lines = patch.split("\n"); + let currentPatchLine = 0; + let currentNewLine = 0; + + for (const line of lines) { + currentPatchLine += 1; + + // Hunk header: @@ -a,b +c,d @@ + const hunkHeaderMatch = HunkHeaderRegex.match(line); + if (hunkHeaderMatch) { + const startLine = parseInt(hunkHeaderMatch[1], 10); + currentNewLine = startLine; + // eslint-disable-next-line no-continue + continue; + } + + if (currentPatchLine === patchLineNumber) { + return currentNewLine; + } + + if (line.startsWith("+++")) { + // eslint-disable-next-line no-continue + continue; // skip file metadata lines + } + + // Advance only if the line exists in the new file + if (line.startsWith("+") || line.startsWith(" ")) { + currentNewLine += 1; + } + } + + return currentNewLine; +}; + +const MAX_MESSAGE_LENGTH = 1024; + +export const parseScanErrorMessage = (err: unknown): string => { + let errorMessage: string; + + if (err instanceof AxiosError) { + errorMessage = err?.response?.data + ? JSON.stringify(err?.response?.data) + : (err?.message ?? "An unknown error occurred."); + } else { + errorMessage = (err as Error)?.message || "An unknown error occurred."; + } + + return errorMessage.length <= MAX_MESSAGE_LENGTH + ? errorMessage + : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; +}; 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 new file mode 100644 index 000000000..f41a2b5c2 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -0,0 +1,14 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { + [SecretScanningDataSource.GitHub]: "GitHub" +}; + +export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { + [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar +}; + +export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { + [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" } +}; 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 new file mode 100644 index 000000000..3747af81f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -0,0 +1,626 @@ +import { join } from "path"; + +import { ProjectMembershipRole, TSecretScanningFindings } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + createTempFolder, + deleteTempFolder, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { + parseScanErrorMessage, + scanGitRepositoryAndGetFindings +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +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 { 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"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { + SecretScanningDataSource, + SecretScanningResource, + SecretScanningScanStatus, + SecretScanningScanType +} from "./secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "./secret-scanning-v2-factory"; +import { + TFindingsPayload, + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification, + TSecretScanningDataSourceWithConnection +} from "./secret-scanning-v2-types"; + +type TSecretRotationV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + secretScanningV2DAL: TSecretScanningV2DALFactory; + smtpService: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + auditLogService: Pick; + keyStore: Pick; +}; + +export type TSecretScanningV2QueueServiceFactory = Awaited>; + +export const secretScanningV2QueueServiceFactory = async ({ + queueService, + secretScanningV2DAL, + projectMembershipDAL, + projectDAL, + smtpService, + kmsService, + auditLogService, + keyStore +}: TSecretRotationV2QueueServiceFactoryDep) => { + const queueDataSourceFullScan = async ( + dataSource: TSecretScanningDataSourceWithConnection, + resourceExternalId?: string + ) => { + try { + const { type } = dataSource; + + const factory = SECRET_SCANNING_FACTORY_MAP[type](); + + const rawResources = await factory.listRawResources(dataSource); + + let filteredRawResources = rawResources; + + // TODO: should add individual resource fetch to factory + if (resourceExternalId) { + filteredRawResources = rawResources.filter((resource) => resource.externalId === resourceExternalId); + } + + if (!filteredRawResources.length) { + throw new BadRequestError({ + message: `${resourceExternalId ? `Resource with "ID" ${resourceExternalId} could not be found.` : "Data source has no resources to scan"}. Ensure your data source config is correct and not filtering out scanning resources.` + }); + } + + for (const resource of filteredRawResources) { + // eslint-disable-next-line no-await-in-loop + if (await keyStore.getItem(KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId))) { + throw new BadRequestError({ message: `A scan is already in progress for resource "${resource.name}"` }); + } + } + + await secretScanningV2DAL.resources.transaction(async (tx) => { + const resources = await secretScanningV2DAL.resources.upsert( + filteredRawResources.map((rawResource) => ({ + ...rawResource, + dataSourceId: dataSource.id + })), + ["externalId", "dataSourceId"], + tx + ); + + const scans = await secretScanningV2DAL.scans.insertMany( + resources.map((resource) => ({ + resourceId: resource.id, + type: SecretScanningScanType.FullScan + })), + tx + ); + + for (const scan of scans) { + // eslint-disable-next-line no-await-in-loop + await queueService.queuePg(QueueJobs.SecretScanningV2FullScan, { + scanId: scan.id, + resourceId: scan.resourceId, + dataSourceId: dataSource.id + }); + } + }); + } catch (error) { + logger.error(error, `Failed to queue full-scan for data source with ID "${dataSource.id}"`); + + if (error instanceof BadRequestError) throw error; + + throw new InternalServerError({ message: `Failed to queue scan: ${(error as Error).message}` }); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2FullScan, + async ([job]) => { + const { scanId, resourceId, dataSourceId } = job.data as TQueueSecretScanningDataSourceFullScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[scanId=${scanId}] [resourceId=${resourceId}] [dataSourceId=${dataSourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const tempFolder = await createTempFolder(); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + let lock: Awaited> | undefined; + + try { + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId)], + 60 * 1000 * 5 + ); + } catch (e) { + throw new Error("Failed to acquire scanning lock."); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + 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 findingsPath = join(tempFolder, "findings.json"); + + const scanPath = await factory.getFullScanPath({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + tempFolder + }); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + let findingsPayload: TFindingsPayload; + switch (resource.type) { + case SecretScanningResource.Repository: + case SecretScanningResource.Project: + findingsPayload = await scanGitRepositoryAndGetFindings(scanPath, findingsPath, configPath); + break; + default: + throw new Error("Unhandled resource type"); + } + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed, + statusMessage: null + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: false, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.FullScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Full Scan Complete ${logDetails} findings=[${findingsPayload.length}]`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.FullScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Full Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + await lock?.release(); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + const queueResourceDiffScan = async ({ + payload, + dataSourceId, + dataSourceType + }: Pick) => { + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + + const resourcePayload = factory.getDiffScanResourcePayload(payload); + + try { + const { resourceId, scanId } = await secretScanningV2DAL.resources.transaction(async (tx) => { + const [resource] = await secretScanningV2DAL.resources.upsert( + [ + { + ...resourcePayload, + dataSourceId + } + ], + ["externalId", "dataSourceId"], + tx + ); + + const scan = await secretScanningV2DAL.scans.create( + { + resourceId: resource.id, + type: SecretScanningScanType.DiffScan + }, + tx + ); + + return { + resourceId: resource.id, + scanId: scan.id + }; + }); + + await queueService.queuePg(QueueJobs.SecretScanningV2DiffScan, { + payload, + dataSourceId, + dataSourceType, + scanId, + resourceId + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to queue diff scan [dataSourceId=${dataSourceId}] [resourceExternalId=${resourcePayload.externalId}]` + ); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2DiffScan, + async ([job]) => { + const { payload, dataSourceId, resourceId, scanId } = job.data as TQueueSecretScanningResourceDiffScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[dataSourceId=${dataSourceId}] [scanId=${scanId}] [resourceId=${resourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + + const tempFolder = await createTempFolder(); + + try { + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + const findingsPayload = await factory.getDiffScanFindingsPayload({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + payload, + configPath + }); + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: true, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.DiffScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Diff Scan Complete ${logDetails}`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.DiffScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Diff Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + await queueService.startPg( + QueueJobs.SecretScanningV2SendNotification, + async ([job]) => { + const { dataSource, resourceName, ...payload } = job.data as TQueueSecretScanningSendNotification; + + const appCfg = getConfig(); + + if (!appCfg.isSmtpConfigured) return; + + try { + const { projectId } = dataSource; + + logger.info( + `secretScanningV2Queue: Sending Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const timestamp = new Date().toISOString(); + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: + payload.status === SecretScanningScanStatus.Completed + ? SmtpTemplates.SecretScanningV2SecretsDetected + : SmtpTemplates.SecretScanningV2ScanFailed, + subjectLine: + payload.status === SecretScanningScanStatus.Completed + ? "Incident Alert: Secret(s) Leaked" + : `Secret Scanning Failed`, + substitutions: + payload.status === SecretScanningScanStatus.Completed + ? { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName, + numberOfSecrets: payload.numberOfSecrets, + isDiffScan: payload.isDiffScan, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` + ), + timestamp + } + : { + dataSourceName: dataSource.name, + resourceName, + projectName: project.name, + timestamp, + errorMessage: payload.errorMessage, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` + ) + } + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to Send Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + throw error; + } + }, + { + batchSize: 1, + workerCount: 5, + pollingIntervalSeconds: 1 + } + ); + + return { + queueDataSourceFullScan, + queueResourceDiffScan + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts new file mode 100644 index 000000000..832b73bda --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +import { SecretScanningDataSourcesSchema, SecretScanningFindingsSchema } from "@app/db/schemas"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +type SecretScanningDataSourceSchemaOpts = { + type: SecretScanningDataSource; + isConnectionRequired: boolean; +}; + +export const BaseSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + SecretScanningDataSourcesSchema.omit({ + // unique to provider + type: true, + connectionId: true, + config: true, + encryptedCredentials: true + }).extend({ + type: z.literal(type), + connectionId: isConnectionRequired ? z.string().uuid() : z.null(), + connection: isConnectionRequired + ? z.object({ + app: z.literal(SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]), + name: z.string(), + id: z.string().uuid() + }) + : z.null() + }); + +export const BaseCreateSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.CREATE(type).name), + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.CREATE(type).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.CREATE(type).description), + connectionId: isConnectionRequired + ? z.string().uuid().describe(SecretScanningDataSources.CREATE(type).connectionId) + : z.undefined(), + isAutoScanEnabled: z + .boolean() + .optional() + .default(true) + .describe(SecretScanningDataSources.CREATE(type).isAutoScanEnabled) + }); + +export const BaseUpdateSecretScanningDataSourceSchema = (type: SecretScanningDataSource) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.UPDATE(type).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.UPDATE(type).description), + isAutoScanEnabled: z.boolean().optional().describe(SecretScanningDataSources.UPDATE(type).isAutoScanEnabled) + }); + +export const GitRepositoryScanFindingDetailsSchema = z.object({ + description: z.string(), + startLine: z.number(), + endLine: z.number(), + startColumn: z.number(), + endColumn: z.number(), + file: z.string(), + link: z.string(), + symlinkFile: z.string(), + commit: z.string(), + entropy: z.number(), + author: z.string(), + email: z.string(), + date: z.string(), + message: z.string(), + tags: z.string().array(), + ruleID: z.string(), + fingerprint: z.string() +}); + +export const BaseSecretScanningFindingSchema = SecretScanningFindingsSchema.omit({ + dataSourceType: true, + resourceType: true, + details: true +}); 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 new file mode 100644 index 000000000..05449bd0d --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -0,0 +1,875 @@ +import { ForbiddenError } from "@casl/ability"; +import { join } from "path"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { + createTempFolder, + deleteTempFolder, + scanContentAndGetFindings, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { githubSecretScanningService } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-service"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-factory"; +import { listSecretScanningDataSourceOptions } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TFindSecretScanningDataSourceByIdDTO, + TFindSecretScanningDataSourceByNameDTO, + TListSecretScanningDataSourcesByProjectId, + TSecretScanningDataSource, + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceWithDetails, + TSecretScanningFinding, + TSecretScanningResourceWithDetails, + TSecretScanningScanWithDetails, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO, + TUpsertSecretScanningConfigDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +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"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; + +export type TSecretScanningV2ServiceFactoryDep = { + secretScanningV2DAL: TSecretScanningV2DALFactory; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + secretScanningV2Queue: Pick< + TSecretScanningV2QueueServiceFactory, + "queueDataSourceFullScan" | "queueResourceDiffScan" + >; + kmsService: Pick; +}; + +export type TSecretScanningV2ServiceFactory = ReturnType; + +export const secretScanningV2ServiceFactory = ({ + secretScanningV2DAL, + permissionService, + appConnectionService, + licenseService, + secretScanningV2Queue, + kmsService +}: TSecretScanningV2ServiceFactoryDep) => { + const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( + projectId: string, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Sources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + }; + + const listSecretScanningDataSourcesByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.find({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSource[]; + }; + + const listSecretScanningDataSourcesWithDetailsByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.findWithDetails({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSourceWithDetails[]; + }; + + const findSecretScanningDataSourceById = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const findSecretScanningDataSourceByName = async ( + { type, sourceName, projectId }: TFindSecretScanningDataSourceByNameDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + // we prevent conflicting names within a folder + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + name: sourceName, + projectId + }); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with name "${sourceName}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSource.id}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const createSecretScanningDataSource = async ( + payload: TCreateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to create Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: payload.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSub.SecretScanningDataSources + ); + + let connection: TAppConnection | null = null; + if (payload.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[payload.type], + payload.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + + try { + const createdDataSource = await factory.initialize( + { + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + secretScanningV2DAL + }, + async ({ credentials, externalId }) => { + let encryptedCredentials: Buffer | null = null; + + if (credentials) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: payload.projectId + }); + + const { cipherTextBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + encryptedCredentials = cipherTextBlob; + } + + return secretScanningV2DAL.dataSources.transaction(async (tx) => { + const dataSource = await secretScanningV2DAL.dataSources.create( + { + encryptedCredentials, + externalId, + ...payload + }, + tx + ); + + await factory.postInitialization({ + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + dataSourceId: dataSource.id, + credentials + }); + + return dataSource; + }); + } + ); + + if (payload.isAutoScanEnabled) { + try { + await secretScanningV2Queue.queueDataSourceFullScan({ + ...createdDataSource, + connection + } as TSecretScanningDataSourceWithConnection); + } catch { + // silently fail, don't want to block creation, they'll try scanning when they don't see anything and get the error + } + } + + return createdDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${payload.projectId}"` + }); + } + + throw err; + } + }; + + const updateSecretScanningDataSource = async ( + { type, dataSourceId, ...payload }: TUpdateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to update Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + try { + const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); + + return updatedDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${dataSource.projectId}"` + }); + } + + throw err; + } + }; + + const deleteSecretScanningDataSource = async ( + { type, dataSourceId }: TDeleteSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to delete Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + // TODO: clean up webhooks + + await secretScanningV2DAL.dataSources.deleteById(dataSourceId); + + return dataSource as TSecretScanningDataSource; + }; + + const triggerSecretScanningDataSourceScan = async ( + { type, dataSourceId, resourceId }: TTriggerSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to trigger scan for Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + 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.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + let resourceExternalId: string | undefined; + + if (resourceId) { + const resource = await secretScanningV2DAL.resources.findOne({ id: resourceId, dataSourceId }); + if (!resource) { + throw new NotFoundError({ + message: `Could not find Secret Scanning Resource with ID "${resourceId}" for Data Source with ID "${dataSourceId}"` + }); + } + resourceExternalId = resource.externalId; + } + + await secretScanningV2Queue.queueDataSourceFullScan( + { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceExternalId + ); + + return dataSource as TSecretScanningDataSource; + }; + + const listSecretScanningResourcesByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.find({ + dataSourceId + }); + + return { resources, projectId: dataSource.projectId }; + }; + + const listSecretScanningScansByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findByDataSourceId(dataSourceId); + + return { scans, projectId: dataSource.projectId }; + }; + + const listSecretScanningResourcesWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.findWithDetails({ dataSourceId }); + + return { resources: resources as TSecretScanningResourceWithDetails[], projectId: dataSource.projectId }; + }; + + const listSecretScanningScansWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Scans due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findWithDetailsByDataSourceId(dataSourceId); + + return { scans: scans as TSecretScanningScanWithDetails[], projectId: dataSource.projectId }; + }; + + const getSecretScanningUnresolvedFindingsCountByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const [finding] = await secretScanningV2DAL.findings.find( + { + projectId, + status: SecretScanningFindingStatus.Unresolved + }, + { count: true } + ); + + return Number(finding?.count ?? 0); + }; + + const listSecretScanningFindingsByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const findings = await secretScanningV2DAL.findings.find({ + projectId + }); + + return findings as TSecretScanningFinding[]; + }; + + const updateSecretScanningFindingById = async ( + { findingId, remarks, status }: TUpdateSecretScanningFindingDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const finding = await secretScanningV2DAL.findings.findById(findingId); + + if (!finding) + throw new NotFoundError({ + message: `Could not find Secret Scanning Finding with ID "${findingId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: finding.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Update, + ProjectPermissionSub.SecretScanningFindings + ); + + const updatedFinding = await secretScanningV2DAL.findings.updateById(findingId, { + remarks, + status + }); + + return { finding: updatedFinding as TSecretScanningFinding, projectId: finding.projectId }; + }; + + const findSecretScanningConfigByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Read, + ProjectPermissionSub.SecretScanningConfigs + ); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId + }); + + return ( + config ?? { content: null, projectId, updatedAt: null } // using default config + ); + }; + + const upsertSecretScanningConfig = async ( + { projectId, content }: TUpsertSecretScanningConfigDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Update, + ProjectPermissionSub.SecretScanningConfigs + ); + + if (content) { + const tempFolder = await createTempFolder(); + try { + const configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, content); + + // just checking if config parses + await scanContentAndGetFindings("", configPath); + } catch (e) { + throw new BadRequestError({ + message: "Unable to parse configuration: Check syntax and formatting." + }); + } finally { + await deleteTempFolder(tempFolder); + } + } + + const [config] = await secretScanningV2DAL.configs.upsert( + [ + { + projectId, + content + } + ], + "projectId" + ); + + return config; + }; + + return { + listSecretScanningDataSourceOptions, + listSecretScanningDataSourcesByProjectId, + listSecretScanningDataSourcesWithDetailsByProjectId, + findSecretScanningDataSourceById, + findSecretScanningDataSourceByName, + createSecretScanningDataSource, + updateSecretScanningDataSource, + deleteSecretScanningDataSource, + triggerSecretScanningDataSourceScan, + listSecretScanningResourcesByDataSourceId, + listSecretScanningScansByDataSourceId, + listSecretScanningResourcesWithDetailsByDataSourceId, + listSecretScanningScansWithDetailsByDataSourceId, + getSecretScanningUnresolvedFindingsCountByProjectId, + listSecretScanningFindingsByProjectId, + updateSecretScanningFindingById, + findSecretScanningConfigByProjectId, + upsertSecretScanningConfig, + github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue) + }; +}; 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 new file mode 100644 index 000000000..3ee5851d7 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -0,0 +1,189 @@ +import { + TSecretScanningDataSources, + TSecretScanningFindingsInsert, + TSecretScanningResources, + TSecretScanningScans +} from "@app/db/schemas"; +import { + TGitHubDataSource, + TGitHubDataSourceInput, + TGitHubDataSourceListItem, + TGitHubDataSourceWithConnection, + TGitHubFinding, + TQueueGitHubResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/github"; +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"; + +export type TSecretScanningDataSource = TGitHubDataSource; + +export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningResourceWithDetails = TSecretScanningResources & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningScanWithDetails = TSecretScanningScans & { + unresolvedFindings: number; + resolvedFindings: number; + resourceName: string; +}; + +export type TSecretScanningDataSourceWithConnection = TGitHubDataSourceWithConnection; + +export type TSecretScanningDataSourceInput = TGitHubDataSourceInput; + +export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem; + +export type TSecretScanningFinding = TGitHubFinding; + +export type TListSecretScanningDataSourcesByProjectId = { + projectId: string; + type?: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByIdDTO = { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByNameDTO = { + sourceName: string; + projectId: string; + type: SecretScanningDataSource; +}; + +export type TCreateSecretScanningDataSourceDTO = Pick< + TSecretScanningDataSource, + "description" | "name" | "projectId" +> & { + connectionId?: string; + type: SecretScanningDataSource; + isAutoScanEnabled?: boolean; + config: Partial; +}; + +export type TUpdateSecretScanningDataSourceDTO = Partial< + Omit +> & { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TDeleteSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; +}; + +export type TTriggerSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; + resourceId?: string; +}; + +export type TQueueSecretScanningDataSourceFullScan = { + dataSourceId: string; + resourceId: string; + scanId: string; +}; + +export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan; + +export type TQueueSecretScanningSendNotification = { + dataSource: TSecretScanningDataSources; + resourceName: string; +} & ( + | { status: SecretScanningScanStatus.Failed; errorMessage: string } + | { status: SecretScanningScanStatus.Completed; numberOfSecrets: number; scanId: string; isDiffScan: boolean } +); + +export type TCloneRepository = { + cloneUrl: string; + repoPath: string; +}; + +export type TSecretScanningFactoryListRawResources = ( + dataSource: T +) => Promise[]>; + +export type TSecretScanningFactoryGetDiffScanResourcePayload< + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (payload: P) => Pick; + +export type TSecretScanningFactoryGetFullScanPath = (parameters: { + dataSource: T; + resourceName: string; + tempFolder: string; +}) => Promise; + +export type TSecretScanningFactoryGetDiffScanFindingsPayload< + T extends TSecretScanningDataSourceWithConnection, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (parameters: { dataSource: T; resourceName: string; payload: P; configPath?: string }) => Promise; + +export type TSecretScanningDataSourceRaw = NonNullable< + Awaited> +>; + +export type TSecretScanningFactoryInitialize< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = ( + params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + secretScanningV2DAL: TSecretScanningV2DALFactory; + }, + callback: (parameters: { credentials?: C; externalId?: string }) => Promise +) => Promise; + +export type TSecretScanningFactoryPostInitialization< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = (params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + credentials: C; + dataSourceId: string; +}) => Promise; + +export type TSecretScanningFactory< + T extends TSecretScanningDataSourceWithConnection, + C extends TSecretScanningDataSourceCredentials, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = () => { + listRawResources: TSecretScanningFactoryListRawResources; + getFullScanPath: TSecretScanningFactoryGetFullScanPath; + initialize: TSecretScanningFactoryInitialize; + postInitialization: TSecretScanningFactoryPostInitialization; + getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; + getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; +}; + +export type TFindingsPayload = Pick[]; +export type TGetFindingsPayload = Promise; + +export type TUpdateSecretScanningFindingDTO = { + status?: SecretScanningFindingStatus; + remarks?: string | null; + findingId: string; +}; + +export type TUpsertSecretScanningConfigDTO = { + projectId: string; + content: string | null; +}; + +export type TSecretScanningDataSourceCredentials = undefined; 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 new file mode 100644 index 000000000..4f34791f8 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; + +export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [GitHubDataSourceSchema]); + +export const SecretScanningFindingSchema = z.discriminatedUnion("resourceType", [GitHubFindingSchema]); diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts index 2e74a1caf..ceb239adf 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts @@ -65,9 +65,9 @@ export function runInfisicalScanOnRepo(repoPath: string, outputPath: string): Pr }); } -export function runInfisicalScan(inputPath: string, outputPath: string): Promise { +export function runInfisicalScan(inputPath: string, outputPath: string, configPath?: string): Promise { return new Promise((resolve, reject) => { - const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}"`; + const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}" ${configPath ? `-c "${configPath}"` : ""}`; exec(command, (error) => { if (error && error.code !== 77) { reject(error); @@ -138,14 +138,14 @@ export async function scanFullRepoContentAndGetFindings( } } -export async function scanContentAndGetFindings(textContent: string): Promise { +export async function scanContentAndGetFindings(textContent: string, configPath?: string): Promise { const tempFolder = await createTempFolder(); const filePath = join(tempFolder, "content.txt"); const findingsPath = join(tempFolder, "findings.json"); try { await writeTextToFile(filePath, textContent); - await runInfisicalScan(filePath, findingsPath); + await runInfisicalScan(filePath, findingsPath, configPath); const findingsData = await readFindingsFile(findingsPath); return JSON.parse(findingsData) as SecretMatch[]; } finally { diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts index 3f14a41e3..6990febc3 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts @@ -9,6 +9,7 @@ export type SecretMatch = { Match: string; Secret: string; File: string; + Link: string; SymlinkFile: string; Commit: string; Entropy: number; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index ad58a2a7e..a7eec1078 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -36,6 +36,8 @@ export const KeyStorePrefixes = { `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, + SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => + `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, CaOrderCertificateForSubscriberLock: (subscriberId: string) => `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8467f7fe6..3734cbf21 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -3,6 +3,12 @@ import { SECRET_ROTATION_CONNECTION_MAP, SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + AUTO_SYNC_DESCRIPTION_HELPER, + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; @@ -57,7 +63,8 @@ export enum ApiDocsTags { SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", - KmsSigning = "KMS Signing" + KmsSigning = "KMS Signing", + SecretScanning = "Secret Scanning" } export const GROUPS = { @@ -2436,3 +2443,81 @@ export const SecretRotations = { } } }; + +export const SecretScanningDataSources = { + LIST: (type?: SecretScanningDataSource) => ({ + projectId: `The ID of the project to list ${type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning"} Data Sources from.` + }), + GET_BY_ID: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.` + }), + GET_BY_NAME: (type: SecretScanningDataSource) => ({ + sourceName: `The name of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.`, + projectId: `The ID of the project the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source is located in.` + }), + CREATE: (type: SecretScanningDataSource) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + return { + name: `The name of the ${sourceType} Data Source to create. Must be slug-friendly.`, + description: `An optional description for the ${sourceType} Data Source.`, + projectId: `The ID of the project to create the ${sourceType} Data Source in.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]] + } Connection to use for this Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The configuration parameters to use for this Data Source.` + }; + }, + UPDATE: (type: SecretScanningDataSource) => { + const typeName = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + + return { + dataSourceId: `The ID of the ${typeName} Data Source to be updated.`, + name: `The updated name of the ${typeName} Data Source. Must be slug-friendly.`, + description: `The updated description of the ${typeName} Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The updated configuration parameters to use for this Data Source.` + }; + }, + DELETE: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to be deleted.` + }), + SCAN: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to trigger a scan for.`, + resourceId: `The ID of the individual Data Source resource to trigger a scan for.` + }), + LIST_RESOURCES: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list resources from.` + }), + LIST_SCANS: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list scans for.` + }), + CONFIG: { + GITHUB: { + includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' + } + } +}; + +export const SecretScanningFindings = { + LIST: { + projectId: `The ID of the project to list Secret Scanning Findings from.` + }, + UPDATE: { + findingId: "The ID of the Secret Scanning Finding to update.", + status: "The updated status of the specified Secret Scanning Finding.", + remarks: "Remarks pertaining to the status of this finding." + } +}; + +export const SecretScanningConfigs = { + GET_BY_PROJECT_ID: { + projectId: `The ID of the project to retrieve the Secret Scanning Configuration for.` + }, + UPDATE: { + projectId: "The ID of the project to update the Secret Scanning Configuration for.", + content: "The contents of the Secret Scanning Configuration file." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9aed17f42..cb53a71a6 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { QueueWorkerProfile } from "@app/lib/types"; + import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -69,6 +71,7 @@ const envSchema = z ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), + QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All), HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options @@ -230,6 +233,14 @@ const envSchema = z INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()), + // github radar app + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET: zpStr(z.string().optional()), + // gcp app INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()), @@ -298,6 +309,13 @@ const envSchema = z Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + isSecretScanningV2Configured: + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET), isHsmConfigured: Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 87db80343..65d0b7859 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -32,3 +32,24 @@ export const shake = ( return acc; }, {} as T); }; + +export const titleCaseToCamelCase = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item: object) => titleCaseToCamelCase(item)); + } + + const result: Record = {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const camelKey = key.charAt(0).toLowerCase() + key.slice(1); + result[camelKey] = titleCaseToCamelCase((obj as Record)[key]); + } + } + + return result; +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index 2e17bff20..5949afe33 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -179,13 +179,18 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "batchInsert" }); } }, - upsert: async (data: readonly Tables[Tname]["insert"][], onConflictField: keyof Tables[Tname]["base"], tx?: Knex) => { + upsert: async ( + data: readonly Tables[Tname]["insert"][], + onConflictField: keyof Tables[Tname]["base"] | Array, + tx?: Knex, + mergeColumns?: (keyof Knex.ResolveTableType, "update">)[] | undefined + ) => { try { if (!data.length) return []; const res = await (tx || db)(tableName) .insert(data as never) .onConflict(onConflictField as never) - .merge() + .merge(mergeColumns) .returning("*"); return res; } catch (error) { diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index be9430669..c472f8d5d 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -9,3 +9,5 @@ export const DistinguishedNameRegex = export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/); export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); + +export const GitHubRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 9f063172f..49d8893be 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -78,3 +78,9 @@ export type OrgServiceActor = { authMethod: ActorAuthMethod; orgId: string; }; + +export enum QueueWorkerProfile { + All = "all", + Standard = "standard", + SecretScanning = "secret-scanning" +} diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index c4aae9aaf..e4d654998 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -11,9 +11,15 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; +import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TFailedIntegrationSyncEmailsPayload, @@ -54,7 +60,8 @@ export enum QueueName { ImportSecretsFromExternalSource = "import-secrets-from-external-source", AppConnectionSecretSync = "app-connection-secret-sync", SecretRotationV2 = "secret-rotation-v2", - InvalidateCache = "invalidate-cache" + InvalidateCache = "invalidate-cache", + SecretScanningV2 = "secret-scanning-v2" } export enum QueueJobs { @@ -88,6 +95,9 @@ export enum QueueJobs { SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", InvalidateCache = "invalidate-cache", + SecretScanningV2FullScan = "secret-scanning-v2-full-scan", + SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", + SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" } @@ -250,6 +260,19 @@ export type TQueueJobTypes = { }; }; }; + [QueueName.SecretScanningV2]: + | { + name: QueueJobs.SecretScanningV2FullScan; + payload: TQueueSecretScanningDataSourceFullScan; + } + | { + name: QueueJobs.SecretScanningV2DiffScan; + payload: TQueueSecretScanningResourceDiffScan; + } + | { + name: QueueJobs.SecretScanningV2SendNotification; + payload: TQueueSecretScanningSendNotification; + }; [QueueName.CaLifecycle]: { name: QueueJobs.CaOrderCertificateForSubscriber; payload: { @@ -263,6 +286,37 @@ export type TQueueJobTypes = { }; }; +const SECRET_SCANNING_JOBS = [ + QueueJobs.SecretScanningV2FullScan, + QueueJobs.SecretScanningV2DiffScan, + QueueJobs.SecretScanningV2SendNotification, + QueueJobs.SecretScan +]; + +const NON_STANDARD_JOBS = [...SECRET_SCANNING_JOBS]; + +const SECRET_SCANNING_QUEUES = [ + QueueName.SecretScanningV2, + QueueName.SecretFullRepoScan, + QueueName.SecretPushEventScan +]; + +const NON_STANDARD_QUEUES = [...SECRET_SCANNING_QUEUES]; + +const isQueueEnabled = (name: QueueName) => { + const appCfg = getConfig(); + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + return !NON_STANDARD_QUEUES.includes(name); + case QueueWorkerProfile.SecretScanning: + return SECRET_SCANNING_QUEUES.includes(name); + case QueueWorkerProfile.All: + default: + // allow all + return true; + } +}; + export type TQueueServiceFactory = ReturnType; export const queueServiceFactory = ( redisCfg: TRedisConfigKeys, @@ -319,7 +373,7 @@ export const queueServiceFactory = ( }); const appCfg = getConfig(); - if (appCfg.QUEUE_WORKERS_ENABLED) { + if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { workerContainer[name] = new Worker(name, jobFn, { ...queueSettings, connection @@ -338,6 +392,30 @@ export const queueServiceFactory = ( throw new Error(`${jobName} queue is already initialized`); } + const appCfg = getConfig(); + + if (!appCfg.QUEUE_WORKERS_ENABLED) return; + + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + if (NON_STANDARD_JOBS.includes(jobName)) { + // only process standard jobs + return; + } + + break; + case QueueWorkerProfile.SecretScanning: + if (!SECRET_SCANNING_JOBS.includes(jobName)) { + // only process secret scanning jobs + return; + } + + break; + case QueueWorkerProfile.All: + default: + // allow all + } + await pgBoss.createQueue(jobName); queueContainerPg[jobName] = true; @@ -357,7 +435,7 @@ export const queueServiceFactory = ( listener: WorkerListener[U] ) => { const appCfg = getConfig(); - if (!appCfg.QUEUE_WORKERS_ENABLED) { + if (!appCfg.QUEUE_WORKERS_ENABLED || !isQueueEnabled(name)) { return; } diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts new file mode 100644 index 000000000..466450180 --- /dev/null +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -0,0 +1,66 @@ +import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types"; +import { PushEvent } from "@octokit/webhooks-types"; +import { Probot } from "probot"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { writeLimit } from "@app/server/config/rateLimiter"; + +export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvider) => { + const probotApp = (app: Probot) => { + app.on("installation.deleted", async (context) => { + const { payload } = context; + const { installation } = payload; + + await server.services.secretScanningV2.github.handleInstallationDeletedEvent(installation.id); + }); + + app.on("installation", async (context) => { + const { payload } = context; + logger.info({ repositories: payload.repositories }, "Installed secret scanner to"); + }); + + app.on("push", async (context) => { + const { payload } = context; + await server.services.secretScanningV2.github.handlePushEvent(payload as PushEvent); + }); + }; + + const appCfg = getConfig(); + + if (!appCfg.isSecretScanningV2Configured) { + logger.info("Secret Scanning V2 is not configured. Skipping registration of secret scanning v2 webhooks."); + return; + } + + const probot = new Probot({ + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID as string, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY as string, + secret: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET as string + }); + + await probot.load(probotApp); + + // github push event webhook + server.route({ + method: "POST", + url: "/github", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const eventName = req.headers["x-github-event"] as EmitterWebhookEventName; + const signatureSHA256 = req.headers["x-hub-signature-256"] as string; + const id = req.headers["x-github-delivery"] as string; + + await probot.webhooks.verifyAndReceive({ + id, + name: eventName, + payload: JSON.stringify(req.body), + signature: signatureSHA256 + }); + + return res.send("ok"); + } + }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 422f5162b..509a2ef1f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -92,6 +92,9 @@ import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; import { secretScanningQueueFactory } from "@app/ee/services/secret-scanning/secret-scanning-queue"; import { secretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; @@ -118,6 +121,7 @@ import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; +import { registerSecretScanningV2Webhooks } from "@app/server/plugins/secret-scanner-v2"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -312,6 +316,9 @@ export const registerRoutes = async ( ) => { const appCfg = getConfig(); await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + await server.register(registerSecretScanningV2Webhooks, { + prefix: "/secret-scanning/webhooks" + }); // db layers const userDAL = userDALFactory(db); @@ -459,6 +466,7 @@ export const registerRoutes = async ( const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); + const secretScanningV2DAL = secretScanningV2DALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -1784,6 +1792,26 @@ export const registerRoutes = async ( smtpService }); + const secretScanningV2Queue = await secretScanningV2QueueServiceFactory({ + auditLogService, + secretScanningV2DAL, + queueService, + projectDAL, + projectMembershipDAL, + smtpService, + kmsService, + keyStore + }); + + const secretScanningV2Service = secretScanningV2ServiceFactory({ + permissionService, + appConnectionService, + licenseService, + secretScanningV2DAL, + secretScanningV2Queue, + kmsService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1898,7 +1926,8 @@ export const registerRoutes = async ( secretRotationV2: secretRotationV2Service, microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, - githubOrgSync: githubOrgSyncConfigService + githubOrgSync: githubOrgSyncConfigService, + secretScanningV2: secretScanningV2Service }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 4f9c96fec..f523bb218 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -33,6 +33,10 @@ import { } from "@app/services/app-connection/databricks"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { + GitHubRadarConnectionListItemSchema, + SanitizedGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema @@ -67,6 +71,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAppConnectionSchema = z.union([ ...SanitizedAwsConnectionSchema.options, ...SanitizedGitHubConnectionSchema.options, + ...SanitizedGitHubRadarConnectionSchema.options, ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, @@ -91,6 +96,7 @@ const SanitizedAppConnectionSchema = z.union([ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AwsConnectionListItemSchema, GitHubConnectionListItemSchema, + GitHubRadarConnectionListItemSchema, GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts new file mode 100644 index 000000000..086d986b7 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubRadarConnectionSchema, + SanitizedGitHubRadarConnectionSchema, + UpdateGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubRadarConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.GitHubRadar, + server, + sanitizedResponseSchema: SanitizedGitHubRadarConnectionSchema, + createSchema: CreateGitHubRadarConnectionSchema, + updateSchema: UpdateGitHubRadarConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + repositories: z.object({ id: z.number(), name: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const repositories = await server.services.appConnection.githubRadar.listRepositories( + connectionId, + req.permission + ); + + return { repositories }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index a71742e6f..7085b3364 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -11,6 +11,7 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; +import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; @@ -28,6 +29,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { .default("false") .transform((value) => value === "true"), type: z - .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .enum([ + ProjectType.SecretManager, + ProjectType.KMS, + ProjectType.CertificateManager, + ProjectType.SSH, + ProjectType.SecretScanning, + "all" + ]) .optional() }), response: { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 8d6b0630d..227818bf0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -1,5 +1,6 @@ export enum AppConnection { GitHub = "github", + GitHubRadar = "github-radar", AWS = "aws", Databricks = "databricks", GCP = "gcp", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index fe6661e59..4597d8f45 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -52,6 +52,11 @@ import { } from "./databricks"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; +import { + getGitHubRadarConnectionListItem, + GitHubRadarConnectionMethod, + validateGitHubRadarConnectionCredentials +} from "./github-radar"; import { getHCVaultConnectionListItem, HCVaultConnectionMethod, @@ -89,6 +94,7 @@ export const listAppConnectionOptions = () => { return [ getAwsConnectionListItem(), getGitHubConnectionListItem(), + getGitHubRadarConnectionListItem(), getGcpConnectionListItem(), getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), @@ -160,6 +166,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitHubRadar]: validateGitHubRadarConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: @@ -188,6 +195,7 @@ export const validateAppConnectionCredentials = async ( export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { switch (method) { case GitHubConnectionMethod.App: + case GitHubRadarConnectionMethod.App: return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: @@ -258,6 +266,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AWS]: platformManagedCredentialsNotSupported, [AppConnection.Databricks]: platformManagedCredentialsNotSupported, [AppConnection.GitHub]: platformManagedCredentialsNotSupported, + [AppConnection.GitHubRadar]: platformManagedCredentialsNotSupported, [AppConnection.GCP]: platformManagedCredentialsNotSupported, [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index be1a49d13..0042fdf42 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -3,6 +3,7 @@ import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", [AppConnection.GitHub]: "GitHub", + [AppConnection.GitHubRadar]: "GitHub Radar", [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", @@ -27,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { export const APP_CONNECTION_PLAN_MAP: Record = { [AppConnection.AWS]: AppConnectionPlanType.Regular, [AppConnection.GitHub]: AppConnectionPlanType.Regular, + [AppConnection.GitHubRadar]: AppConnectionPlanType.Regular, [AppConnection.GCP]: AppConnectionPlanType.Regular, [AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular, [AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular, diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 75656df6f..e91aefd4b 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -19,6 +19,7 @@ import { validateAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service"; +import { githubRadarConnectionService } from "@app/services/app-connection/github-radar/github-radar-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { ValidateOnePassConnectionCredentialsSchema } from "./1password"; @@ -49,6 +50,7 @@ import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; +import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar"; import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault"; import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; @@ -78,6 +80,7 @@ export type TAppConnectionServiceFactory = ReturnType = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, + [AppConnection.GitHubRadar]: ValidateGitHubRadarConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, [AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema, [AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema, @@ -486,6 +489,7 @@ export const appConnectionServiceFactory = ({ connectAppConnectionById, listAvailableAppConnectionsForUser, github: githubConnectionService(connectAppConnectionById), + githubRadar: githubRadarConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), aws: awsConnectionService(connectAppConnectionById), diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 7ea1ed994..9af833010 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -69,6 +69,12 @@ import { TGitHubConnectionInput, TValidateGitHubConnectionCredentialsSchema } from "./github"; +import { + TGitHubRadarConnection, + TGitHubRadarConnectionConfig, + TGitHubRadarConnectionInput, + TValidateGitHubRadarConnectionCredentialsSchema +} from "./github-radar"; import { THCVaultConnection, THCVaultConnectionConfig, @@ -122,6 +128,7 @@ import { export type TAppConnection = { id: string } & ( | TAwsConnection | TGitHubConnection + | TGitHubRadarConnection | TGcpConnection | TAzureKeyVaultConnection | TAzureAppConfigurationConnection @@ -150,6 +157,7 @@ export type TSqlConnection = TPostgresConnection | TMsSqlConnection | TMySqlConn export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput | TGitHubConnectionInput + | TGitHubRadarConnectionInput | TGcpConnectionInput | TAzureKeyVaultConnectionInput | TAzureAppConfigurationConnectionInput @@ -185,6 +193,7 @@ export type TUpdateAppConnectionDTO = Partial { + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG } = getConfig(); + + return { + name: "GitHub Radar" as const, + app: AppConnection.GitHubRadar as const, + methods: Object.values(GitHubRadarConnectionMethod) as [GitHubRadarConnectionMethod.App], + appClientSlug: INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG + }; +}; + +export const getGitHubRadarClient = (appConnection: TGitHubRadarConnection) => { + const appCfg = getConfig(); + + const { method, credentials } = appConnection; + + let client: Octokit; + + switch (method) { + case GitHubRadarConnectionMethod.App: + if (!appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + client = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId: credentials.installationId + } + }); + break; + default: + throw new InternalServerError({ + message: `Unhandled GitHub Radar connection method: ${method as GitHubRadarConnectionMethod}` + }); + } + + return client; +}; + +export const listGitHubRadarRepositories = async (appConnection: TGitHubRadarConnection) => { + const client = getGitHubRadarClient(appConnection); + + const repositories: TGitHubRadarRepository[] = await client.paginate("GET /installation/repositories"); + + return repositories; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; + error?: string; +}; + +export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRadarConnectionConfig) => { + const { credentials, method } = config; + + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, SITE_URL } = + getConfig(); + + if (!INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID || !INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, + client_secret: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, + code: credentials.code, + redirect_uri: `${SITE_URL}/organization/app-connections/github-radar/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubRadarConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + type: string; + id: number; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + + switch (method) { + case GitHubRadarConnectionMethod.App: + return { + installationId: credentials.installationId + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubRadarConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts new file mode 100644 index 000000000..ebdfa45e1 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; + +export const GitHubRadarConnectionInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "GitHub Radar App code required"), + installationId: z.string().min(1, "GitHub Radar App Installation ID required") +}); + +export const GitHubRadarConnectionOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const ValidateGitHubRadarConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(GitHubRadarConnectionMethod.App) + .describe(AppConnections.CREATE(AppConnection.GitHubRadar).method), + credentials: GitHubRadarConnectionInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHubRadar).credentials + ) + }) +]); + +export const CreateGitHubRadarConnectionSchema = ValidateGitHubRadarConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHubRadar) +); + +export const UpdateGitHubRadarConnectionSchema = z + .object({ + credentials: GitHubRadarConnectionInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.GitHubRadar).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHubRadar)); + +const BaseGitHubRadarConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHubRadar) }); + +export const GitHubRadarConnectionSchema = BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema +}); + +export const SanitizedGitHubRadarConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema.pick({}) + }) +]); + +export const GitHubRadarConnectionListItemSchema = z.object({ + name: z.literal("GitHub Radar"), + app: z.literal(AppConnection.GitHubRadar), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubRadarConnectionMethod).array(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts new file mode 100644 index 000000000..583c43952 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts @@ -0,0 +1,24 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { listGitHubRadarRepositories } from "@app/services/app-connection/github-radar/github-radar-connection-fns"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar/github-radar-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const githubRadarConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.GitHubRadar, connectionId, actor); + + const repositories = await listGitHubRadarRepositories(appConnection); + + return repositories.map((repo) => ({ id: repo.id, name: repo.full_name })); + }; + + return { + listRepositories + }; +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts new file mode 100644 index 000000000..c9e7e5aa8 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubRadarConnectionSchema, + GitHubRadarConnectionSchema, + ValidateGitHubRadarConnectionCredentialsSchema +} from "./github-radar-connection-schemas"; + +export type TGitHubRadarConnection = z.infer; + +export type TGitHubRadarConnectionInput = z.infer & { + app: AppConnection.GitHubRadar; +}; + +export type TValidateGitHubRadarConnectionCredentialsSchema = typeof ValidateGitHubRadarConnectionCredentialsSchema; + +export type TGitHubRadarConnectionConfig = DiscriminativePick< + TGitHubRadarConnectionInput, + "method" | "app" | "credentials" +>; + +export type TGitHubRadarRepository = { + id: number; + full_name: string; +}; diff --git a/backend/src/services/app-connection/github-radar/index.ts b/backend/src/services/app-connection/github-radar/index.ts new file mode 100644 index 000000000..3f2a5f663 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/index.ts @@ -0,0 +1,4 @@ +export * from "./github-radar-connection-enums"; +export * from "./github-radar-connection-fns"; +export * from "./github-radar-connection-schemas"; +export * from "./github-radar-connection-types"; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index c4c94b4fc..134b9667b 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -13,7 +13,12 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; export const LdapConnectionSimpleBindCredentialsSchema = z.object({ provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), - url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url), + url: z + .string() + .trim() + .min(1, "URL required") + .refine((value) => LdapUrlRegex.test(value), "Invalid LDAP URL") + .describe(AppConnections.CREDENTIALS.LDAP.url), dn: z .string() .trim() diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx index 3d02fc793..01bf779c5 100644 --- a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -13,7 +13,7 @@ export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmai - + {preview}

diff --git a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx new file mode 100644 index 000000000..2e212cb82 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx @@ -0,0 +1,67 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningScanFailedTemplateProps extends Omit { + dataSourceName: string; + resourceName: string; + projectName: string; + timestamp: string; + url: string; + errorMessage: string; +} + +export const SecretScanningScanFailedTemplate = ({ + dataSourceName, + resourceName, + projectName, + siteUrl, + errorMessage, + url, + timestamp +}: SecretScanningScanFailedTemplateProps) => { + return ( + + + Infisical encountered an error while attempting to scan the resource {resourceName} + +
+ Resource + {resourceName} + Data Source + {dataSourceName} + Project + {projectName} + Timestamp + {timestamp} + Error + {errorMessage} +
+
+ +
+
+ ); +}; + +export default SecretScanningScanFailedTemplate; + +SecretScanningScanFailedTemplate.PreviewProps = { + dataSourceName: "my-data-source", + resourceName: "my-resource", + projectName: "my-project", + timestamp: "May 3rd 2025, 5:42 pm", + url: "https://infisical.com", + errorMessage: "401 Unauthorized", + siteUrl: "https://infisical.com" +} as SecretScanningScanFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx new file mode 100644 index 000000000..b7c0d8a14 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx @@ -0,0 +1,101 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningSecretsDetectedTemplateProps + extends Omit { + numberOfSecrets: number; + isDiffScan: boolean; + authorName?: string; + authorEmail?: string; + resourceName: string; + url: string; +} + +export const SecretScanningSecretsDetectedTemplate = ({ + numberOfSecrets, + siteUrl, + authorName, + authorEmail, + isDiffScan, + resourceName, + url +}: SecretScanningSecretsDetectedTemplateProps) => { + return ( + + + Infisical has uncovered {numberOfSecrets} secret(s) + {isDiffScan ? " from a recent commit to" : " in"} {resourceName} + +
+ + You are receiving this notification because one or more leaked secrets have been detected + {isDiffScan && " in a recent commit"} + {isDiffScan ? ( + (authorName || authorEmail) && ( + <> + {" "} + pushed by {authorName ?? "Unknown Pusher"}{" "} + {authorEmail && ( + <> + ( + + {authorEmail} + + ) + + )} + + ) + ) : ( + <> + {" "} + in your resource {resourceName} + + )} + . + + + If these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as + a comment in the given programming language. This will prevent future notifications from being sent out for + these secrets. + + + If these are production secrets, please rotate them immediately. + + + Once you have taken action, be sure to update the finding status in the{" "} + + Infisical Dashboard + + . + +
+
+ +
+
+ ); +}; + +export default SecretScanningSecretsDetectedTemplate; + +SecretScanningSecretsDetectedTemplate.PreviewProps = { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName: "my-resource", + numberOfSecrets: 3, + url: "https://infisical.com", + isDiffScan: true, + siteUrl: "https://infisical.com" +} as SecretScanningSecretsDetectedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 29738dbb2..840a98cad 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -21,6 +21,8 @@ export * from "./SecretLeakIncidentTemplate"; export * from "./SecretReminderTemplate"; export * from "./SecretRequestCompletedTemplate"; export * from "./SecretRotationFailedTemplate"; +export * from "./SecretScanningScanFailedTemplate"; +export * from "./SecretScanningSecretsDetectedTemplate"; export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 12b38ebb2..ac56f0ee4 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -30,6 +30,8 @@ import { SecretReminderTemplate, SecretRequestCompletedTemplate, SecretRotationFailedTemplate, + SecretScanningScanFailedTemplate, + SecretScanningSecretsDetectedTemplate, SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, @@ -73,7 +75,9 @@ export enum SmtpTemplates { ProjectAccessRequest = "projectAccess", OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess", OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", - ServiceTokenExpired = "serviceTokenExpired" + ServiceTokenExpired = "serviceTokenExpired", + SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", + SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected" } export enum SmtpHost { @@ -113,7 +117,9 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, [SmtpTemplates.ResetPassword]: PasswordResetTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, - [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate + [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, + [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, + [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/docs/api-reference/endpoints/app-connections/github-radar/available.mdx b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx new file mode 100644 index 000000000..6cfc0758c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/github-radar/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/create.mdx b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx new file mode 100644 index 000000000..0cd66a49b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github-radar" +--- + + + GitHub Radar Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx new file mode 100644 index 000000000..64b252538 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx new file mode 100644 index 000000000..1ffc291c9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx new file mode 100644 index 000000000..a5ca5e3f5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github-radar/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/list.mdx b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx new file mode 100644 index 000000000..2bd832941 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github-radar" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/update.mdx b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx new file mode 100644 index 000000000..4ffb88dc5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github-radar/{connectionId}" +--- + + + GitHub Radar Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx new file mode 100644 index 000000000..8204a3098 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Project ID" +openapi: "GET /api/v2/secret-scanning/configs" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/config/update.mdx b/docs/api-reference/endpoints/secret-scanning/config/update.mdx new file mode 100644 index 000000000..bf068a20f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/configs" +--- + + + Check out the [Configuration Docs](/documentation/platform/secret-scanning/overview#configuration) for an in-depth guide on custom configurations. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx new file mode 100644 index 000000000..248c8cf53 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx new file mode 100644 index 000000000..eb791e6db --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx new file mode 100644 index 000000000..0103ad447 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx new file mode 100644 index 000000000..c86dcc948 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/github/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx new file mode 100644 index 000000000..f2627827f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx new file mode 100644 index 000000000..839b66355 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx new file mode 100644 index 000000000..951f827a8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx new file mode 100644 index 000000000..5025126fd --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx new file mode 100644 index 000000000..f6b21b485 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx new file mode 100644 index 000000000..2d800d9d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx new file mode 100644 index 000000000..0958dc382 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx new file mode 100644 index 000000000..3affb5270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v2/secret-scanning/data-sources/options" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/list.mdx b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx new file mode 100644 index 000000000..6a97a3a20 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/findings" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/update.mdx b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx new file mode 100644 index 000000000..1c9614f8b --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/findings/{findingId}" +--- \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/vertica.mdx b/docs/documentation/platform/dynamic-secrets/vertica.mdx new file mode 100644 index 000000000..3d5c7b1a9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/vertica.mdx @@ -0,0 +1,134 @@ +--- +title: "Vertica" +description: "Learn how to dynamically generate Vertica database users." +--- + +The Infisical Vertica dynamic secret allows you to generate Vertica database credentials on demand based on configured role. + +## Prerequisite + +Create a user with the required permission in your Vertica instance. This user will be used to create new accounts on-demand. + +## Set up Dynamic Secrets with Vertica + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + + Vertica database host + + + + Vertica database port (default: 5433) + + + + Name of the Vertica database for which you want to create dynamic secrets + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + Customize the SQL statement used to create new users. Default creates a user with basic schema permissions. + + + Customize the SQL statement used to revoke users. Default revokes a user. + + + + + Length of generated passwords (1-250 characters) + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum symbols (default: 0) + + + Symbols allowed in generated passwords + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete the lease before its set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/identities/oidc-auth/spire.mdx b/docs/documentation/platform/identities/oidc-auth/spire.mdx new file mode 100644 index 000000000..b402a1d10 --- /dev/null +++ b/docs/documentation/platform/identities/oidc-auth/spire.mdx @@ -0,0 +1,177 @@ +--- +title: SPIFFE/SPIRE +description: "Learn how to authenticate SPIRE workloads with Infisical using OpenID Connect (OIDC)." +--- + +**OIDC Auth** is a platform-agnostic JWT-based authentication method that can be used to authenticate from any platform or environment using an identity provider with OpenID Connect. + +## Diagram + +The following sequence diagram illustrates the OIDC Auth workflow for authenticating SPIRE workloads with Infisical. + +```mermaid +sequenceDiagram + participant Client as SPIRE Workload + participant Agent as SPIRE Agent + participant Server as SPIRE Server + participant Infis as Infisical + + Client->>Agent: Step 1: Request JWT-SVID + Agent->>Server: Validate workload and fetch signing key + Server-->>Agent: Return signing material + Agent-->>Client: Return JWT-SVID with verifiable claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send JWT-SVID to /api/v1/auth/oidc-auth/login + + Note over Infis,Server: Step 3: Query verification + Infis->>Server: Request JWT public key using OIDC Discovery + Server-->>Infis: Return public key + + Note over Infis: Step 4: JWT validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a SPIRE workload by verifying the JWT-SVID and checking that it meets specific requirements (e.g. it is issued by a trusted SPIRE server) at the `/api/v1/auth/oidc-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The SPIRE workload requests a JWT-SVID from the local SPIRE Agent. +2. The SPIRE Agent validates the workload's identity and requests signing material from the SPIRE Server. +3. The SPIRE Agent returns a JWT-SVID containing the workload's SPIFFE ID and other claims. +4. The JWT-SVID is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint. +5. Infisical fetches the public key that was used to sign the JWT-SVID from the SPIRE Server using OIDC Discovery. +6. Infisical validates the JWT-SVID using the public key provided by the SPIRE Server and checks that the subject, audience, and claims of the token matches with the set criteria. +7. If all is well, Infisical returns a short-lived access token that the workload can use to make authenticated requests to the Infisical API. + +Infisical needs network-level access to the SPIRE Server's OIDC Discovery endpoint. + +## Prerequisites + +Before following this guide, ensure you have: + +- A running SPIRE deployment with both SPIRE Server and SPIRE Agent configured +- OIDC Discovery Provider deployed alongside your SPIRE Server +- Workload registration entries created in SPIRE for the workloads that need to access Infisical +- Network connectivity between Infisical and your OIDC Discovery Provider endpoint + +For detailed SPIRE setup instructions, refer to the [SPIRE documentation](https://spiffe.io/docs/latest/spire-about/). + +## OIDC Discovery Provider Setup + +To enable JWT-SVID verification with Infisical, you need to deploy the OIDC Discovery Provider alongside your SPIRE Server. The OIDC Discovery Provider runs as a separate service that exposes the necessary OIDC endpoints. + +In Kubernetes deployments, this is typically done by adding an `oidc-discovery-provider` container to your SPIRE Server StatefulSet: + +```yaml +- name: spire-oidc + image: ghcr.io/spiffe/oidc-discovery-provider:1.12.2 + args: + - -config + - /run/spire/oidc/config/oidc-discovery-provider.conf + ports: + - containerPort: 443 + name: spire-oidc-port +``` + +The OIDC Discovery Provider will expose the OIDC Discovery endpoint at `https:///.well-known/openid_configuration`, which Infisical will use to fetch the public keys for JWT-SVID verification. + +For detailed setup instructions, refer to the [SPIRE OIDC Discovery Provider documentation](https://github.com/spiffe/spire/tree/main/support/oidc-discovery-provider). + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method with SPIFFE/SPIRE. + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with Universal Auth by default, you should re-configure it to use OIDC Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new OIDC Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create oidc auth method](/images/platform/identities/identities-org-create-oidc-auth-method.png) + + Restrict access by configuring the Subject, Audiences, and Claims fields + + Here's some more guidance on each field: + - OIDC Discovery URL: The URL used to retrieve the OpenID Connect configuration from the SPIRE Server. This will be used to fetch the public key needed for verifying the provided JWT-SVID. This should be set to your SPIRE Server's OIDC Discovery endpoint, typically `https://:/.well-known/openid_configuration` + - Issuer: The unique identifier of the SPIRE Server issuing the JWT-SVID. This value is used to verify the iss (issuer) claim in the JWT-SVID to ensure the token is issued by a trusted SPIRE Server. This should match your SPIRE Server's configured issuer, typically `https://:` + - CA Certificate: The PEM-encoded CA certificate for establishing secure communication with the SPIRE Server endpoints. This should contain the CA certificate that signed your SPIRE Server's TLS certificate. + - Subject: The expected SPIFFE ID that is the subject of the JWT-SVID. The format of the sub field for SPIRE JWT-SVIDs follows the SPIFFE ID format: `spiffe:///`. For example: `spiffe://example.org/workload/api-server` + - Audiences: A list of intended recipients for the JWT-SVID. This value is checked against the aud (audience) claim in the token. When workloads request JWT-SVIDs from SPIRE, they specify an audience (e.g., `infisical` or your service name). Configure this to match what your workloads use. + - Claims: Additional information or attributes that should be present in the JWT-SVID for it to be valid. Standard SPIRE JWT-SVID claims include `sub` (SPIFFE ID), `aud` (audience), `exp` (expiration), and `iat` (issued at). You can also configure custom claims if your SPIRE Server includes additional metadata. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + SPIRE JWT-SVIDs contain standard claims like `sub` (SPIFFE ID), `aud` (audience), `exp`, and `iat`. The audience is typically specified when requesting the JWT-SVID (e.g., `spire-agent api fetch jwt -audience infisical`). + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded SPIFFE IDs whenever possible for better security. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + Here's an example of how a workload can use its JWT-SVID to authenticate with Infisical and retrieve secrets: + + ```bash + #!/bin/bash + + # Obtain JWT-SVID from SPIRE Agent + JWT_SVID=$(spire-agent api fetch jwt -audience infisical -socketPath /run/spire/sockets/agent.sock | grep -A1 "token(" | tail -1) + + # Authenticate with Infisical using the JWT-SVID + ACCESS_TOKEN=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "{\"identityId\":\"\",\"jwt\":\"$JWT_SVID\"}" \ + https://app.infisical.com/api/v1/auth/oidc-auth/login | jq -r '.accessToken') + + # Use the access token to retrieve secrets + curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://app.infisical.com/api/v3/secrets/raw?workspaceSlug=&environment=&secretPath=/" + ``` + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + JWT-SVIDs from SPIRE have their own expiration time (typically short-lived). Ensure your application handles both JWT-SVID renewal from SPIRE and access token renewal from Infisical appropriately. + + + + \ No newline at end of file diff --git a/docs/documentation/platform/secret-scanning.mdx b/docs/documentation/platform/secret-scanning.mdx deleted file mode 100644 index da28bfa55..000000000 --- a/docs/documentation/platform/secret-scanning.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: 'Secret Scanning' -description: "Scan and prevent secret leaks in your code repositories" ---- - -The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories. - -To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes. - - - - - To setup secret scanning on your own instance of Infisical, you can follow the steps below. - - - - Create a new GitHub app in your GitHub organization or personal [Developer Settings](https://github.com/settings/apps). - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app.png) - - ### Configure the GitHub App - To configure the GitHub app to work with Infisical, you'll need to modify the following settings: - - **Homepage URL**: Required to be set. Set it to the URL of your Infisical instance. (e.g. `https://app.infisical.com`) - - **Setup URL**: Set this to `https:///organization/secret-scanning` - - **Webhook URL**: Set this to `https:///api/v1/secret-scanning/webhook` - - **Webhook Secret**: Set this to a random string. This is used to verify the webhook request from Infisical. Use `openssl rand -base64 32` in your terminal to generate a random secret. - - - Remember to save the webhook secret as you will need it in the next step. - - - ![GitHub App Settings](/images/platform/secret-scanning/github-configure-app.png) - - ### Configure the GitHub App Permissions - The GitHub app needs the following permissions: - - Repository permissions: - - `Checks`: Read and Write - - `Contents`: Read-only - - `Issues`: Read and Write - - `Pull Requests`: Read and Write - - `Metadata`: Read-only (enabled by default) - - ![Github App Repository Permissions](/images/platform/secret-scanning/github-repo-permissions.png) - - Subscribed events: - - `Check run` - - `Pull request` - - `Push` - - ![Github App Subscribed Events](/images/platform/secret-scanning/github-subscribed-events.png) - - - ### Create the GitHub App - Now you can create the GitHub app by clicking on the "Create GitHub App" button. - - - If you want other Github users to be able to install the app, you need to tick the "Any account" option under "Where can this GitHub App be installed?" - - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app-button.png) - - - - After clicking the "Create GitHub App" button, you will be redirected to the GitHub settings page. Here you can copy the "App ID" and save it for later when you need to configure your environment variables for your Infisical instance. - - ![Github App ID](/images/platform/secret-scanning/github-app-copy-app-id.png) - - - - The GitHub App slug is the name of the app you created in a slug friendly format. You can find the slug in the URL of the app you created. - - ![Github App Slug](/images/platform/secret-scanning/github-app-copy-slug.png) - - - - Create a new app private key by clicking on the "Generate a private key" button under the "Private keys" section. - - Once you click the "Generate a private key" button, the private key will be downloaded to your computer. Save this file for later as you will need the private key when configuring Infisical. - - ![Github App Private Key](/images/platform/secret-scanning/github-app-create-private-key.png) - - - Remember to save the private key as you will need it in the next step. - - - - - - - Now you can configure your Infisical instance by setting the following environment variables: - - - `SECRET_SCANNING_GIT_APP_ID`: The App ID of your GitHub App. - - `SECRET_SCANNING_GIT_APP_SLUG`: The slug of your GitHub App. - - `SECRET_SCANNING_PRIVATE_KEY`: The private key of your GitHub App that you created in a previous step. - - `SECRET_SCANNING_WEBHOOK_SECRET`: The webhook secret of your GitHub App that you created in a previous step. - - - - After restarting your Infisical instance, you should be able to use the secret scanning feature within your organization. Follow the steps below to add the GitHub App to your Infisical organization. - - -## Install the Infisical Radar GitHub App - -To install the GitHub App, press the "Integrate With GitHub" button in the top right corner of your Infisical Secret Scanning dashboard. - -![Integrate With GitHub](/images/platform/secret-scanning/infisical-connect-secret-scanner.png) - -Next, you'll be prompted to select which organization you'd like to install the app into. Select the organization you'd like to install the app into by clicking the organization in the menu. - -![Select Organization](/images/platform/secret-scanning/github-select-org-2.png) - -Select the repositories you'd like to scan for secrets and press the "Install" button. - -![Select Repositories](/images/platform/secret-scanning/github-select-repos.png) - -## Code Scanning - -![Scanning Overview](/images/platform/secret-scanning/overview.png) - -Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets. - -If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it: -- **Date**: When the risk was first detected. -- **Secret Type**: Which type of secret was detected. -- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change. - -Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret. - - - Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed. - - - - -![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png) - - -## Responding to Exposed Secrets - -After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible. - -You can mark the risk as `Resolved` by changing the status to one of the following states: -- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner. -- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed. -- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk. - -![Needs Attention](/images/platform/secret-scanning/needs-attention.png) - - - - -## Ignoring Known Secrets -If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. - -### infisical-scan:ignore - -To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. - -```js example.js -function helloWorld() { - console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore -} -``` - -### .infisicalignore -An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. -You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. -By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. - -```.ignore .infisicalignore -bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 -bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 -1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 -``` diff --git a/docs/documentation/platform/secret-scanning/github.mdx b/docs/documentation/platform/secret-scanning/github.mdx new file mode 100644 index 000000000..9fa766ed6 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/github.mdx @@ -0,0 +1,96 @@ +--- +title: "GitHub Secret Scanning" +sidebarTitle: "GitHub" +description: "Learn how to configure secret scanning for GitHub." +--- + +## Prerequisites + +- Create a [GitHub Radar Connection](/integrations/app-connections/github-radar) + +## Create a GitHub 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 **GitHub** option. + ![Select GitHub Option](/images/platform/secret-scanning/github/github-data-source-step-2.png) + + 3. Select the **GitHub Radar Connection** to use and configure which repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/github/github-data-source-step-3.png) + + - **GitHub Radar Connection** - the connection that has access to the repositories you want to scan. + - **Scan Repositories** - 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/github/github-data-source-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/github/github-data-source-step-5.png) + + 6. Your **GitHub Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/github/github-data-source-step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/github/github-data-source-step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/github/github-data-source-step-8.png) + + + To create a GitHub Data Source, make an API request to the [Create GitHub Data Source](/api-reference/endpoints/secret-scanning/data-sources/github/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/github \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-github-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my github data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "includeRepos": ["*"] + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-github-source", + "description": "my github data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "github", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "github-radar", + "name": "my-radar-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "includeRepos": ["*"] + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-scanning/overview.mdx b/docs/documentation/platform/secret-scanning/overview.mdx new file mode 100644 index 000000000..69bf5a783 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/overview.mdx @@ -0,0 +1,230 @@ +--- +title: "Secret Scanning" +sidebarTitle: "Overview" +description: "Scan and prevent secret leaks in your code repositories" +--- + +## Introduction + +Monitor and detect exposed secrets across your data sources, including code repositories, with Infisical Secret Scanning. + +For additional security, we recommend using our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to check for exposed secrets before pushing your code changes. + + + Secret Scanning is a paid feature. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## How Secret Scanning Works + +Secret Scanning consists of several components that enable you to quickly respond to secret leaks: + +- **Scanner Engine**: The core component that analyzes your code and detects potential secrets using pattern matching and entropy analysis +- **Real-time Monitoring**: Provides continuous surveillance of your repositories for immediate detection of exposed secrets +- **Alert System**: Notifies organization admins via email when secrets are detected +- **Risk Management**: Allows tracking and managing detected secrets with different status options +- **Data Sources**: Integrates with various data sources and version control systems +- **Customizable Rules**: Supports ignore patterns and custom configurations to reduce false positives + +These components work together to provide comprehensive secret detection and incident response capabilities. + +### Data Sources + +Data sources are configured integrations with external platforms, such as a GitHub organization or a GitLab group, that establish secure connections for scanning purposes using [App Connections](/integrations/app-connections/overview). + +A data source acts as a secure intermediary between the external system and the scanner engine. It manages a collection of scannable resources (such as repositories) and handles the authentication and communication required for scanning operations. + +![data sources](/images/platform/secret-scanning/secret-scanning-data-sources.png) + +### Resources + +Resources are the atomic, scannable units, such as a repository, that can be monitored for secret exposure. Resources are added automatically when a data source is scanned and updated when scanning events are triggered, such as when a user pushes changes to GitHub. + +Each resource maintains its own scanning history and status, allowing for granular monitoring and management of secret scanning across your organization. + +![resources](/images/platform/secret-scanning/secret-scanning-resources.png) + +### Scans + +Scans can be initiated in two ways: + +1. **Full Scan** - Manually triggered scan that comprehensively checks either all resources associated with a data source or a single selected resource. + +2. **Diff Scan** - Automatically executed when **Auto-Scan** is enabled on a data source. This scan type specifically focuses on updates to existing resources. + +All scan activities can be monitored in real-time through the Infisical UI, which displays: +- Current scan status +- Timestamp of the scan +- Resource(s) being scanned +- Detection results (whether any secrets were found) + +![scans](/images/platform/secret-scanning/secret-scanning-scans.png) + +### Findings + +Findings are automatically generated when secret leaks are detected during scanning operations. Each finding contains comprehensive information including: +- The specific scanning rule that identified the leak +- File location and line number where the secret was found +- Resource-specific details (e.g., commit hash and author for Git repositories) + +Findings are initially marked as **Unresolved** and can be updated to one of the following statuses with additional remarks: +- **Resolved** - The issue has been addressed +- **False Positive** - The detection was incorrect +- **Ignore** - The finding can be safely disregarded + +These status options help teams effectively track and manage the lifecycle of detected secret leaks. + +![findings](/images/platform/secret-scanning/secret-scanning-findings.png) + +### Configuration + +You can configure custom scanning rules and exceptions by updating your project's scanning configuration via the UI or API. + +The configuration options allow you to: +- Define custom scanning patterns and rules +- Set up ignore patterns to reduce false positives +- Specify file path exclusions +- Configure entropy thresholds for secret detection +- Add allowlists for known safe patterns + +For detailed configuration options, expand the example configuration below. + + + ```toml + # Title for the configuration file + title = "Some title" + + + # This configuration is the foundation that can be expanded. If there are any overlapping rules + # between this base and the expanded configuration, the rules in this base will take priority. + # Another aspect of extending configurations is the ability to link multiple files, up to a depth of 2. + # "Allowlist" arrays get appended and may have repeated elements. + # "useDefault" and "path" cannot be used simultaneously. Please choose one. + [extend] + # useDefault will extend the base configuration with the default config: + # https://raw.githubusercontent.com/Infisical/infisical/main/cli/config/infisical-scan.toml + useDefault = true + # or you can supply a path to a configuration. Path is relative to where infisical cli + # was invoked, not the location of the base config. + path = "common_config.toml" + + # An array of tables that contain information that define instructions + # on how to detect secrets + [[rules]] + + # Unique identifier for this rule + id = "some-identifier-for-rule" + + # Short human readable description of the rule. + description = "awesome rule 1" + + # Golang regular expression used to detect secrets. Note Golang's regex engine + # does not support lookaheads. + regex = '''one-go-style-regex-for-this-rule''' + + # Golang regular expression used to match paths. This can be used as a standalone rule or it can be used + # in conjunction with a valid `regex` entry. + path = '''a-file-path-regex''' + + # Array of strings used for metadata and reporting purposes. + tags = ["tag","another tag"] + + # A regex match may have many groups, this allows you to specify the group that should be used as (which group the secret is contained in) + # its entropy checked if `entropy` is set. + secretGroup = 3 + + # Float representing the minimum shannon entropy a regex group must have to be considered a secret. + # Shannon entropy measures how random a data is. Since secrets are usually composed of many random characters, they typically have high entropy + entropy = 3.5 + + # Keywords are used for pre-regex check filtering. + # If rule has keywords but the text fragment being scanned doesn't have at least one of it's keywords, it will be skipped for processing further. + # Ideally these values should either be part of the identifier or unique strings specific to the rule's regex + # (introduced in v8.6.0) + keywords = [ + "auth", + "password", + "token", + ] + + # You can include an allowlist table for a single rule to reduce false positives or ignore commits + # with known/rotated secrets + [rules.allowlist] + description = "ignore commit A" + commits = [ "commit-A", "commit-B"] + paths = [ + '''go\.mod''', + '''go\.sum''' + ] + # note: (rule) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + regexes = [ + '''process''', + '''getenv''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + + + # This is a global allowlist which has a higher order of precedence than rule-specific allowlists. + # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no + # secrets will be detected for said commit. The same logic applies for regexes and paths. + [allowlist] + description = "global allow list" + commits = [ "commit-A", "commit-B", "commit-C"] + paths = [ + '''gitleaks\.toml''', + '''(.*?)(jpg|gif|doc)''' + ] + + # note: (global) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + + regexes = [ + '''219-09-9999''', + '''078-05-1120''', + '''(9[0-9]{2}|666)-\d{2}-\d{4}''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + ``` + + +![config](/images/platform/secret-scanning/secret-scanning-config.png) + +## Ignoring Known Secrets +If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. + +### infisical-scan:ignore + +To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. + +```js example.js +function helloWorld() { + console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore +} +``` + +### .infisicalignore +An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. +You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. +By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. + +```.ignore .infisicalignore +bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 +bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 +1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 +``` diff --git a/docs/images/app-connections/github-radar/create-github-radar-app-method.png b/docs/images/app-connections/github-radar/create-github-radar-app-method.png new file mode 100644 index 000000000..2aa403dbc Binary files /dev/null and b/docs/images/app-connections/github-radar/create-github-radar-app-method.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-app-created.png b/docs/images/app-connections/github-radar/github-radar-app-created.png new file mode 100644 index 000000000..d4b9a9374 Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-app-created.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-authorize.png b/docs/images/app-connections/github-radar/github-radar-authorize.png new file mode 100644 index 000000000..a113b761c Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-authorize.png differ diff --git a/docs/images/app-connections/github-radar/select-github-radar-connection.png b/docs/images/app-connections/github-radar/select-github-radar-connection.png new file mode 100644 index 000000000..35a9e574d Binary files /dev/null and b/docs/images/app-connections/github-radar/select-github-radar-connection.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png new file mode 100644 index 000000000..4c55482e2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png new file mode 100644 index 000000000..50959a5c2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png new file mode 100644 index 000000000..5b9aa6ebc Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png new file mode 100644 index 000000000..b9fae23ed Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png new file mode 100644 index 000000000..52e27cec1 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png new file mode 100644 index 000000000..aed2940ca Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png new file mode 100644 index 000000000..1ed7a8173 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png new file mode 100644 index 000000000..f7646855d Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png new file mode 100644 index 000000000..012e09796 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png new file mode 100644 index 000000000..d53849e6b Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png differ diff --git a/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png new file mode 100644 index 000000000..379d6e0e1 Binary files /dev/null and b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png new file mode 100644 index 000000000..85da8a929 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-add-access-token.png b/docs/images/app-connections/gitlab/gitlab-add-access-token.png new file mode 100644 index 000000000..b1307f774 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-add-access-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-copy-token.png b/docs/images/app-connections/gitlab/gitlab-copy-token.png new file mode 100644 index 000000000..e425ac3ef Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-copy-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png new file mode 100644 index 000000000..69575afe7 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png differ diff --git a/docs/images/app-connections/gitlab/select-gitlab-connection.png b/docs/images/app-connections/gitlab/select-gitlab-connection.png new file mode 100644 index 000000000..0f559477d Binary files /dev/null and b/docs/images/app-connections/gitlab/select-gitlab-connection.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png new file mode 100644 index 000000000..0424286d9 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png new file mode 100644 index 000000000..a270cb214 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png new file mode 100644 index 000000000..6effe4574 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png new file mode 100644 index 000000000..dd97ccc4e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png differ diff --git a/docs/images/platform/secret-scanning/exposed-secret.png b/docs/images/platform/secret-scanning/exposed-secret.png deleted file mode 100644 index 727765292..000000000 Binary files a/docs/images/platform/secret-scanning/exposed-secret.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-app-id.png b/docs/images/platform/secret-scanning/github-app-copy-app-id.png deleted file mode 100644 index a94cb5ece..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-app-id.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-slug.png b/docs/images/platform/secret-scanning/github-app-copy-slug.png deleted file mode 100644 index c555dcd41..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-slug.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-create-private-key.png b/docs/images/platform/secret-scanning/github-app-create-private-key.png deleted file mode 100644 index 50f602a36..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-create-private-key.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-configure-app.png b/docs/images/platform/secret-scanning/github-configure-app.png deleted file mode 100644 index df64eeb18..000000000 Binary files a/docs/images/platform/secret-scanning/github-configure-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app-button.png b/docs/images/platform/secret-scanning/github-create-app-button.png deleted file mode 100644 index 3ea4b2d38..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app-button.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app.png b/docs/images/platform/secret-scanning/github-create-app.png deleted file mode 100644 index f4d1cdb8c..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-register-app.png b/docs/images/platform/secret-scanning/github-register-app.png deleted file mode 100644 index 904c07bf2..000000000 Binary files a/docs/images/platform/secret-scanning/github-register-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-repo-permissions.png b/docs/images/platform/secret-scanning/github-repo-permissions.png deleted file mode 100644 index 53eae9a41..000000000 Binary files a/docs/images/platform/secret-scanning/github-repo-permissions.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org-2.png b/docs/images/platform/secret-scanning/github-select-org-2.png deleted file mode 100644 index 55b945c18..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org-2.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org.png b/docs/images/platform/secret-scanning/github-select-org.png deleted file mode 100644 index 7d6e5abc5..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-repos.png b/docs/images/platform/secret-scanning/github-select-repos.png deleted file mode 100644 index 51a6648d2..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-repos.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-subscribed-events.png b/docs/images/platform/secret-scanning/github-subscribed-events.png deleted file mode 100644 index 7aa6b431f..000000000 Binary files a/docs/images/platform/secret-scanning/github-subscribed-events.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-1.png b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png new file mode 100644 index 000000000..62fc84459 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-2.png b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png new file mode 100644 index 000000000..5962406e4 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-3.png b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png new file mode 100644 index 000000000..d1a0e81a0 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-4.png b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png new file mode 100644 index 000000000..f46045250 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-5.png b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png new file mode 100644 index 000000000..9f0891888 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-6.png b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png new file mode 100644 index 000000000..886c0ca45 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-7.png b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png new file mode 100644 index 000000000..bc34c5fdb Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-8.png b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png new file mode 100644 index 000000000..75b9f8175 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png differ diff --git a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png b/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png deleted file mode 100644 index 11f24fd74..000000000 Binary files a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/needs-attention.png b/docs/images/platform/secret-scanning/needs-attention.png deleted file mode 100644 index 6ac664ead..000000000 Binary files a/docs/images/platform/secret-scanning/needs-attention.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/overview.png b/docs/images/platform/secret-scanning/overview.png deleted file mode 100644 index 19981fa11..000000000 Binary files a/docs/images/platform/secret-scanning/overview.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-config.png b/docs/images/platform/secret-scanning/secret-scanning-config.png new file mode 100644 index 000000000..b844f711f Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-config.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-data-sources.png b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png new file mode 100644 index 000000000..344178f54 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-findings.png b/docs/images/platform/secret-scanning/secret-scanning-findings.png new file mode 100644 index 000000000..2f0dde504 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-findings.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-resources.png b/docs/images/platform/secret-scanning/secret-scanning-resources.png new file mode 100644 index 000000000..c47b252fc Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-resources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-scans.png b/docs/images/platform/secret-scanning/secret-scanning-scans.png new file mode 100644 index 000000000..f710dbc20 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-scans.png differ diff --git a/docs/integrations/app-connections/github-radar.mdx b/docs/integrations/app-connections/github-radar.mdx new file mode 100644 index 000000000..376973efd --- /dev/null +++ b/docs/integrations/app-connections/github-radar.mdx @@ -0,0 +1,121 @@ +--- +title: "GitHub Radar Connection" +description: "Learn how to configure a GitHub Radar Connection for Infisical." +--- + +Infisical supports GitHub App installation for creating a GitHub Radar Connection. + + + GitHub Radar Connections are specifically configured for [Secret Scanning](/documentation/platform/secret-scanning/overview) and require specific permissions and webhook configuration. + + Check out our [GitHub Connection](/integrations/app-connections/github) for secret management features such as [Secret Syncs](/integrations/secret-syncs/overview). + + + + Using a GitHub Radar Connection with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub App Settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + ![create github radar app](/images/app-connections/github-radar/self-hosted-github-radar-step-1.png) + + Configure the following fields: + + 1. **Name** - give your app a name + 2. **Homepage URL** - your self-hosted domain (i.e. `https://your-domain.com`) + 3. **Callback URL** - the callback URL for your domain (i.e. `https://your-domain.com/organization/app-connections/github-radar/oauth/callback`) + 4. **User Authorization** - enable request user authorization on app installation + + ![github radar app details](/images/app-connections/github-radar/self-hosted-github-radar-step-2.png) + + Enable and configure the Webhook fields: + + - **Webhook URL** - the webhook URL for your domain (i.e. `https://your-domain.com/secret-scanning/webhooks/github`) + - **Webhook Secret** - a strong, generated secret to verify webhook payloads + - **SSL Verification** - enable SSL verification + + ![github radar app webhook](/images/app-connections/github-radar/self-hosted-github-radar-step-3.png) + + Set the following repository permissions: + - **Contents**: `Read-only` + - **Metadata**: `Read-only` + + ![github radar app permissions 1](/images/app-connections/github-radar/self-hosted-github-radar-step-4.png) + ![github radar app permissions 2](/images/app-connections/github-radar/self-hosted-github-radar-step-5.png) + + Subscribe to the following events: + - **Push** + + ![github radar app events](/images/app-connections/github-radar/self-hosted-github-radar-step-6.png) + + Create the Github application. + ![github radar app complete](/images/app-connections/github-radar/self-hosted-github-radar-step-7.png) + + + Generate a new **Client Secret** for your GitHub application. + ![github radar app client secret](/images/app-connections/github-radar/self-hosted-github-radar-step-8.png) + + Generate a new **Private Key** for your Github application. + + You will need to copy the contents of the .pem file downloaded + + ![github radar app private key](/images/app-connections/github-radar/self-hosted-github-radar-step-9.png) + + Obtain the following credentials: + + 1. **Slug** - the slug of your application found in the URL + 2. **App ID** - the ID of your application + 3. **Client ID** - the client ID of your application + 4. **Client Secret** - the client secret generated above + 5. **Private Key** - the contents of the private key .pem file generated above + 6. **Webhook Secret** - the secret generated in the previous step when configuring the webhook + + ![github radar app credentials](/images/app-connections/github-radar/self-hosted-github-radar-step-10.png) + + Back in your Infisical instance, add the six new environment variables for the credentials of your GitHub Radar application: + + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY`: The **Private Key** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET`: The **Webhook Secret** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + +## Setup GitHub Radar Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Radar Connection** option from the connection options modal. + ![Select GitHub Radar Connection](/images/app-connections/github-radar/select-github-radar-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github-radar/create-github-radar-app-method.png) + + + You will then be redirected to the GitHub App installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github-radar/github-radar-authorize.png) + + + Your **GitHub Radar Connection** is now available for use. + ![GitHub Radar Connection](/images/app-connections/github-radar/github-radar-app-created.png) + + \ No newline at end of file diff --git a/docs/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx index 1ffafe637..889355954 100644 --- a/docs/integrations/app-connections/teamcity.mdx +++ b/docs/integrations/app-connections/teamcity.mdx @@ -3,7 +3,7 @@ title: "TeamCity Connection" description: "Learn how to configure a TeamCity Connection for Infisical." --- -Infisical supports connecting to TeamCity using an Access Token to securely sync your secrets to TeamCity. +Infisical supports connecting to TeamCity using Access Tokens. ## Setup TeamCity Connection in Infisical diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx index 8ef4a5647..7ab7bea1b 100644 --- a/docs/integrations/app-connections/vercel.mdx +++ b/docs/integrations/app-connections/vercel.mdx @@ -3,7 +3,7 @@ title: "Vercel Connection" description: "Learn how to configure a Vercel Connection for Infisical." --- -Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. +Infisical supports connecting to Vercel using API Tokens. ## Setup Vercel Connection in Infisical diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx index ca4aa7da4..5cab9fa38 100644 --- a/docs/integrations/app-connections/windmill.mdx +++ b/docs/integrations/app-connections/windmill.mdx @@ -3,7 +3,7 @@ title: "Windmill Connection" description: "Learn how to configure a Windmill Connection for Infisical." --- -Infisical supports connecting to Windmill using an **Access Token** to securely sync your secrets to Windmill. +Infisical supports connecting to Windmill using Access Tokens. ## Get a Windmill Access Token diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx deleted file mode 100644 index fddd41683..000000000 --- a/docs/internals/bug-bounty.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: "Bug bounty program" -description: " Learn about our bug bounty program and how to report vulnerabilities." ---- - -The Infisical Bug Bounty Program is our way of recognizing and rewarding the work of security researchers who help keep our platform secure. By reporting vulnerabilities or potential risks, you help us protect secrets, infrastructure, and the organizations who rely on us. - -We value reports that help identify vulnerabilities that affect the integrity of secrets, prevent unauthorized access to environments, or expose flaws in our authentication or authorization flows. - -### How to Report - -- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. -- You will receive follow ups from our team if we deam your report to be a legitimate vulnerability or need further clarification. We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope. - - -### What's in Scope? - -- Vulnerabilities in our cloud-hosted platform (e.g., `app.infisical.com`, `eu.infisical.com`) -- Security issues in the open source Infisical codebase, as maintained in our official GitHub repository -- Authentication bypass, privilege escalation, or access to secrets/data without authorization - -### Reward Guidelines - -Bounties are based on severity, impact, and exploitability, as well as whether the report introduces a new vulnerability class or helps improve an existing fix. - -| Severity | Examples | Typical Reward (USD currency) | -| --- | --- | --- | -| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 | -| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 | -| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $100 - $1,000 | -| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | - - -We may award lower amounts for: -- Duplicate class vulnerabilities already under review -- Patch bypasses of previously rewarded issues -- Vulnerabilities requiring unrealistic attacker conditions - -All final reward amounts are determined at Infisical's discretion based on impact, report quality, and how actionable the issue is. - - -### Out of Scope - -- Social engineering or phishing (including email hyperlink injection without code execution) -- Rate limiting issues on non-sensitive endpoints -- Denial-of-service attacks that require authentication and don't impact core service availability -- Findings based on outdated or forked code not maintained by the Infisical team -- Vulnerabilities in third-party dependencies unless they result in a direct risk to Infisical users - - -### Responsible Disclosure - -We ask that researchers: - -- Avoid accessing data that isn't yours -- Do not publicly disclose without coordination -- Use testing accounts where possible -- Give us a reasonable window to investigate and patch before going public - -Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. - -### Program Conduct and Enforcement - -We value professional and collaborative interaction with security researchers. To maintain the integrity of our bug bounty program, we expect all participants to adhere to the following guidelines: - -- Maintain professional communication in all interactions -- Do not threaten public disclosure of vulnerabilities before we've had reasonable time to investigate and address the issue -- Do not attempt to extort or coerce compensation through threats -- Follow the responsible disclosure process outlined in this document -- Do not use automated scanning tools without prior permission - -Violations of these guidelines may result in: - -1. **Warning**: For minor violations, we may issue a warning explaining the violation and requesting compliance with program guidelines. -2. **Temporary Ban**: Repeated minor violations or more serious violations may result in a temporary suspension from the program. -3. **Permanent Ban**: Severe violations such as threats, extortion attempts, or unauthorized public disclosure will result in permanent removal from the Infisical Bug Bounty Program. - -We reserve the right to reject reports, withhold bounties, and remove participants from the program at our discretion for conduct that undermines the collaborative spirit of security research. - -Infisical is committed to working respectfully with security researchers who follow these guidelines, and we strive to recognize and reward valuable contributions that help protect our platform and users. diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 8a12532a4..98f3bfeb2 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -316,3 +316,32 @@ Supports conditions and permission inversion | `create` | Create new SSH certificate templates | | `edit` | Modify SSH template configurations | | `delete` | Remove SSH certificate templates | + +### Secret Scanning + +#### Subject: `secret-scanning-data-sources` + +| Action | Description | +| -------- | ---------------------------------------------------- | +| `read-data-sources` | View Data Sources | +| `create-data-sources` | Create new Data Sources | +| `edit-data-sources` | Modify Data Sources | +| `delete-data-sources` | Remove Data Sources | +| `read-data-source-resources` | View Data Source Resources | +| `read-data-source-scans` | View Data Source Scans | +| `trigger-data-source-scans` | Trigger Data Source Secret Scans | + +#### Subject: `secret-scanning-findings` + +| Action | Description | +| -------- | --------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | + + +#### Subject: `secret-scanning-configs` + +| Action | Description | +| ---------------- | ------------------------------------------------ | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 219c32287..85138be9c 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -117,7 +117,3 @@ Whether or not Infisical or your employees can access data in the Infisical inst It should be noted that, even on Infisical Cloud, it is physically impossible for employees of Infisical to view the values of secrets if users have not explicitly granted Infisical access to their project (i.e. opted out of zero-knowledge). Please email security@infisical.com if you have any specific inquiries about employee data and security policies. - -## Bug Bounty Program -We run a [Bug Bounty Program](/internals/bug-bounty) to recognize and reward security researchers who help make Infisical more secure. -If you've found a vulnerability, please review the program details for scope, disclosure guidelines, and reward tiers. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 9a7ce2a2e..67a771085 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -225,7 +225,8 @@ "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", "documentation/platform/dynamic-secrets/totp", - "documentation/platform/dynamic-secrets/kubernetes" + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" ] }, { @@ -252,7 +253,13 @@ ] }, "documentation/platform/secret-sharing", - "documentation/platform/secret-scanning" + { + "group": "Secret Scanning", + "pages": [ + "documentation/platform/secret-scanning/overview", + "documentation/platform/secret-scanning/github" + ] + } ] }, { @@ -336,7 +343,8 @@ "documentation/platform/identities/oidc-auth/github", "documentation/platform/identities/oidc-auth/circleci", "documentation/platform/identities/oidc-auth/gitlab", - "documentation/platform/identities/oidc-auth/terraform-cloud" + "documentation/platform/identities/oidc-auth/terraform-cloud", + "documentation/platform/identities/oidc-auth/spire" ] }, @@ -490,6 +498,7 @@ "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", + "integrations/app-connections/github-radar", "integrations/app-connections/hashicorp-vault", "integrations/app-connections/humanitec", "integrations/app-connections/ldap", @@ -1035,6 +1044,47 @@ } ] }, + { + "group": "Secret Scanning", + "pages": [ + { + "group": "Data Sources", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/list", + "api-reference/endpoints/secret-scanning/data-sources/options", + { + "group": "GitHub", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/github/list", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/github/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/github/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/github/create", + "api-reference/endpoints/secret-scanning/data-sources/github/update", + "api-reference/endpoints/secret-scanning/data-sources/github/delete", + "api-reference/endpoints/secret-scanning/data-sources/github/scan", + "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" + ] + } + ] + }, + { + "group": "Findings", + "pages": [ + "api-reference/endpoints/secret-scanning/findings/list", + "api-reference/endpoints/secret-scanning/findings/update" + ] + }, + { + "group": "Configuration", + "pages": [ + "api-reference/endpoints/secret-scanning/config/get-by-project-id", + "api-reference/endpoints/secret-scanning/config/update" + ] + } + ] + }, { "group": "Identity Specific Privilege", "pages": [ @@ -1187,6 +1237,18 @@ "api-reference/endpoints/app-connections/github/delete" ] }, + { + "group": "GitHub Radar", + "pages": [ + "api-reference/endpoints/app-connections/github-radar/list", + "api-reference/endpoints/app-connections/github-radar/available", + "api-reference/endpoints/app-connections/github-radar/get-by-id", + "api-reference/endpoints/app-connections/github-radar/get-by-name", + "api-reference/endpoints/app-connections/github-radar/create", + "api-reference/endpoints/app-connections/github-radar/update", + "api-reference/endpoints/app-connections/github-radar/delete" + ] + }, { "group": "Hashicorp Vault", "pages": [ @@ -1782,7 +1844,6 @@ }, "internals/components", "internals/security", - "internals/bug-bounty", "internals/service-tokens" ] }, diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index cb374071f..efce4d912 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -553,6 +553,32 @@ You can configure third-party app connections for re-use across Infisical Projec + + + The ID of the GitHub Radar App + + + + The slug of the GitHub Radar App + + + + The client ID for the GitHub Radar App + + + + The client secret for the GitHub Radar App + + + + The private key for the GitHub Radar App + + + + The webhook secret configured for payload verification in the GitHub Radar App + + + The OAuth2 client ID for GitHub OAuth Connection diff --git a/frontend/public/lotties/blocks.json b/frontend/public/lotties/blocks.json new file mode 100644 index 000000000..93a6ad0cb --- /dev/null +++ b/frontend/public/lotties/blocks.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-40-add-card","ddd":0,"assets":[{"id":"comp_1","nm":"hover-add-card","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-90,"ix":10},"p":{"a":0,"k":[354.165,145.831,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.6,"y":0},"t":1,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":18,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":18,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":35,"s":[90]}],"ix":10},"p":{"a":0,"k":[354.171,354.168,0],"ix":2,"l":2},"a":{"a":0,"k":[354.171,354.168,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.168,0],[-43.168,0]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-43.168],[0,43.168]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[354.171,354.168],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[145.831,145.831,0],"to":[34.722,0,0],"ti":[-34.722,0,0]},{"t":42,"s":[354.165,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.506,0],[0,0],[0,-11.506]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[145.831,354.17,0],"to":[0,-34.723,0],"ti":[0,34.723,0]},{"t":49,"s":[145.831,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[145.831,354.17,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":57,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":23,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.914,0.91,0.91],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-add-card","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-add-card","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/search.json b/frontend/public/lotties/search.json new file mode 100644 index 000000000..a650cc5a0 --- /dev/null +++ b/frontend/public/lotties/search.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-42-search","ddd":0,"assets":[{"id":"comp_1","nm":"hover-search","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.995,147.901,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-41.707,-41.707],[41.707,41.707]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":41.73,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.218],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.35],"y":[1]},"o":{"x":[0.522],"y":[0]},"t":29,"s":[29]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[-4]},{"t":59,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.218,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[223.962,223.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.35,"y":1},"o":{"x":0.522,"y":0},"t":29,"s":[310.962,188.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[203.962,233.958,0],"to":[0,0,0],"ti":[0,0,0]},{"t":59,"s":[223.962,223.958,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.218,0.218,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":1,"s":[100,100,100]},{"i":{"x":[0.35,0.35,0.667],"y":[1,1,1]},"o":{"x":[0.522,0.522,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":48,"s":[100,100,100]},{"t":59,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-80.542],[80.542,0],[0,80.542],[-80.542,0]],"o":[[0,80.542],[-80.542,0],[0,-80.542],[80.542,0]],"v":[[145.834,0],[0,145.834],[-145.834,0],[0,-145.834]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":384,"st":60,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.91,0.91,0.914],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-search","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-search","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx index 09537ce57..8ccdb0191 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx @@ -41,7 +41,7 @@ export const DeleteProjectTemplateModal = ({ isOpen, onOpenChange, template }: P diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx index 9b7164f80..666edf87b 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx @@ -112,7 +112,7 @@ export const EditProjectTemplate = ({ isInfisicalTemplate, projectTemplate, onBa /> handlePopUpToggle("removeTemplate", isOpen)} onDeleteApproved={handleRemoveTemplate} diff --git a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx index 16f9a67d4..20b08eb49 100644 --- a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx +++ b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx @@ -66,7 +66,7 @@ export const DeleteSecretRotationV2Modal = ({ diff --git a/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..a3943cf35 --- /dev/null +++ b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { + SecretScanningDataSource, + TSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; +import { SecretScanningDataSourceSelect } from "./SecretScanningDataSourceSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (dataSource: TSecretScanningDataSource) => void; + selectedDataSource: SecretScanningDataSource | null; + setSelectedDataSource: (selectedDataSource: SecretScanningDataSource | null) => void; +}; + +const Content = ({ setSelectedDataSource, selectedDataSource, ...props }: ContentProps) => { + if (selectedDataSource) { + return ( + setSelectedDataSource(null)} + type={selectedDataSource} + {...props} + /> + ); + } + + return ; +}; + +export const CreateSecretScanningDataSourceModal = ({ onOpenChange, isOpen, ...props }: Props) => { + const [selectedDataSource, setSelectedDataSource] = useState( + null + ); + + return ( + { + if (!open) setSelectedDataSource(null); + onOpenChange(open); + }} + > + + ) : ( +
+ Add Data Source + +
+ + Docs + +
+
+
+ ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className={selectedDataSource ? "max-w-2xl" : "max-w-3xl"} + subTitle={ + selectedDataSource ? undefined : "Select a data source to configure secret scanning for." + } + bodyClassName="overflow-visible" + > + { + setSelectedDataSource(null); + onOpenChange(false); + }} + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + {...props} + /> +
+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..9cf918dfb --- /dev/null +++ b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx @@ -0,0 +1,66 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + TSecretScanningDataSource, + useDeleteSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + dataSource?: TSecretScanningDataSource; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretScanningDataSourceModal = ({ + isOpen, + onOpenChange, + dataSource, + onComplete +}: Props) => { + const deleteDataSource = useDeleteSecretScanningDataSource(); + + if (!dataSource) return null; + + const { id: dataSourceId, name, type, projectId } = dataSource; + + const handleDeleteDataSource = async () => { + const dataSourceType = SECRET_SCANNING_DATA_SOURCE_MAP[type].name; + + try { + await deleteDataSource.mutateAsync({ + dataSourceId, + type, + projectId + }); + + createNotification({ + text: `Successfully deleted ${dataSourceType} Data Source`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch { + createNotification({ + text: `Failed to delete ${dataSourceType} Data Source`, + type: "error" + }); + } + }; + + return ( + +

+ Findings associated with this data source will be preserved. +

+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..318a96bed --- /dev/null +++ b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx @@ -0,0 +1,36 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + dataSource?: TSecretScanningDataSource; +}; + +export const EditSecretScanningDataSourceModal = ({ + dataSource, + onOpenChange, + ...props +}: Props) => { + if (!dataSource) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + dataSource={dataSource} + type={dataSource.type} + /> + + + ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx new file mode 100644 index 000000000..fb1f4236b --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx @@ -0,0 +1,47 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +type Props = { + type: SecretScanningDataSource; + isConfigured: boolean; +}; + +export const SecretScanningDataSourceModalHeader = ({ type, isConfigured }: Props) => { + const dataSourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( +
+ {`${dataSourceDetails.name} +
+
+ {dataSourceDetails.name} Data Source + +
+ + Docs + +
+
+
+

+ {isConfigured ? "Edit" : "Connect a"} {dataSourceDetails.name} Data Source +

+
+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx new file mode 100644 index 000000000..c507a6ef3 --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx @@ -0,0 +1,91 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + SecretScanningDataSource, + useSecretScanningDataSourceOptions +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + onSelect: (type: SecretScanningDataSource) => void; +}; + +export const SecretScanningDataSourceSelect = ({ onSelect }: Props) => { + const { isPending, data: dataSourceOptions } = useSecretScanningDataSourceOptions(); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {dataSourceOptions?.map(({ type }) => { + const { image, name, size } = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( + + ); + })} + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ +
+ Coming Soon +
+
+
+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx new file mode 100644 index 000000000..c74ee477f --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx @@ -0,0 +1,90 @@ +import { faArrowRotateForward, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { SecretScanningScanStatus } from "@app/hooks/api/secretScanningV2"; + +type Props = { + status: SecretScanningScanStatus; + statusMessage?: string | null; + className?: string; + scannedAt?: string | null; +}; + +export const SecretScanningScanStatusBadge = ({ + status, + statusMessage, + className, + scannedAt +}: Props) => { + if (status === SecretScanningScanStatus.Failed) { + let errorMessage = statusMessage; + if (statusMessage) { + try { + errorMessage = JSON.stringify(JSON.parse(statusMessage), null, 2); + } catch { + errorMessage = statusMessage; + } + } + + return ( + +
+
+ +
Failure Reason
+
+
{errorMessage}
+ {scannedAt && ( +
+ Attempted {formatDistance(new Date(scannedAt), new Date(), { addSuffix: true })} +
+ )} +
+ + } + > +
+ + + Scan Error + +
+
+ ); + } + + if (status === SecretScanningScanStatus.Queued || status === SecretScanningScanStatus.Scanning) { + return ( + + + Scanning + + ); + } + + return ( + + + Complete + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx new file mode 100644 index 000000000..be369bd07 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx @@ -0,0 +1,126 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue } 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 { + TGitHubRadarConnectionRepository, + useGitHubRadarConnectionListRepositories +} from "@app/hooks/api/appConnections/github-radar"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllRepositories = "all-repositories", + SelectRepositories = "select-repositories" +} + +export const GitHubDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitHub; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const { data: repositories, isPending: areRepositoriesLoading } = + useGitHubRadarConnectionListRepositories(connectionId, { enabled: Boolean(connectionId) }); + + const includeRepos = watch("config.includeRepos"); + + const scanMethod = + !includeRepos || includeRepos[0] === "*" + ? ScanMethod.AllRepositories + : ScanMethod.SelectRepositories; + + useEffect(() => { + if (!includeRepos) { + setValue("config.includeRepos", ["*"]); + } + }, [includeRepos, setValue]); + + return ( + <> + { + if (scanMethod === ScanMethod.SelectRepositories) { + setValue("config.includeRepos", []); + } + }} + /> + + + + {scanMethod === ScanMethod.SelectRepositories && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the repository you're looking for?{" "} + +
+ + } + > + value.includes(repository.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map( + (p) => p.name + ) + : null + ); + }} + options={repositories} + placeholder="Select repositories..." + 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 new file mode 100644 index 000000000..bebcf28fc --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -0,0 +1,55 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Switch } from "@app/components/v2"; +import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; + +const COMPONENT_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields +}; + +export const SecretScanningDataSourceConfigFields = () => { + const { watch, control } = useFormContext(); + + const type = watch("type"); + + const Component = COMPONENT_MAP[type]; + const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type]; + + return ( + <> +

Connect and configure your Data Source.

+ + { + return ( + + +

Auto-Scan {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts new file mode 100644 index 000000000..31d24284f --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts @@ -0,0 +1 @@ +export * from "./SecretScanningDataSourceConfigFields"; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx new file mode 100644 index 000000000..3f995e1d0 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx @@ -0,0 +1,103 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/helpers/secretScanningV2"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretScanningDataSourceForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; + isUpdate?: boolean; +}; + +export const SecretScanningDataSourceConnectionField = ({ + onChange: callback, + isUpdate +}: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const dataSourceType = watch("type"); + const app = SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSourceType]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + return ( + <> + ( + + Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret scanning. +

+ ) + } + > + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + isDisabled={isUpdate} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + control={control} + name="connection" + /> + {!isUpdate && availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {connectionName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${connectionName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx new file mode 100644 index 000000000..7c5565421 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TSecretScanningDataSourceForm } from "./schemas"; + +export const SecretScanningDataSourceDetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Data Source. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +