diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index d1f33cbc7..f614877bb 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -324,6 +324,9 @@ import { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate, TSecrets, + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate, TSecretScanningDataSources, TSecretScanningDataSourcesInsert, TSecretScanningDataSourcesUpdate, @@ -1106,5 +1109,10 @@ declare module "knex/types/tables" { 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 index 71b5f9810..86da451f3 100644 --- a/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts +++ b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts @@ -78,7 +78,16 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.SecretScanningFinding); } - // TODO: Rules + 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 { @@ -92,4 +101,7 @@ export async function down(knex: Knex): Promise { 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 985b4bd72..c6f44fc82 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -107,6 +107,7 @@ 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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 5aacee351..c9040ecf6 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -159,7 +159,8 @@ export enum TableName { SecretScanningDataSource = "secret_scanning_data_sources", SecretScanningResource = "secret_scanning_resources", SecretScanningScan = "secret_scanning_scans", - SecretScanningFinding = "secret_scanning_findings" + SecretScanningFinding = "secret_scanning_findings", + SecretScanningConfig = "secret_scanning_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; 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/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index ed6b55006..27916b10d 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -1,5 +1,6 @@ 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 { @@ -10,7 +11,12 @@ import { SecretScanningDataSourceSchema, SecretScanningFindingSchema } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas"; -import { ApiDocsTags, SecretScanningDataSources, SecretScanningFindings } from "@app/lib/api-docs"; +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"; @@ -181,6 +187,96 @@ export const registerSecretScanningV2Router = async (server: FastifyZodProvider) } }); + server.route({ + method: "GET", + url: "/configs/:projectId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Get the Secret Scanning Config for the specified project.", + params: 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 { + params: { 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/:projectId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Configuration.", + params: z.object({ + projectId: z.string().trim().min(1, "Finding 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 { + params: { 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", 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 21b8a93db..cc9816de2 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -399,7 +399,9 @@ export enum EventType { 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_FINDING_UPDATE = "secret-scanning-finding-update", + SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get", + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update" } export const filterableSecretEvents: EventType[] = [ @@ -3020,6 +3022,18 @@ interface SecretScanningFindingUpdateEvent { metadata: TUpdateSecretScanningFinding; } +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 +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -3297,4 +3311,6 @@ export type Event = | SecretScanningResourceListEvent | SecretScanningScanListEvent | SecretScanningFindingListEvent - | SecretScanningFindingUpdateEvent; + | SecretScanningFindingUpdateEvent + | SecretScanningConfigUpdateEvent + | SecretScanningConfigReadEvent; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index a9ca8d4cc..b983220c7 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -26,8 +26,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customRateLimits: false, customAlerts: false, secretAccessInsights: false, - auditLogs: false, - auditLogsRetentionDays: 0, + auditLogs: true, + auditLogsRetentionDays: 30, auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, @@ -55,7 +55,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ kmip: false, gateway: false, sshHostGroups: false, - secretScanning: false + secretScanning: true }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 75e99e834..bbca4f664 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -12,6 +12,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, + ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSecretScanningFindingActions, ProjectPermissionSecretSyncActions, @@ -218,6 +219,11 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningFindings ); + can( + [ProjectPermissionSecretScanningConfigActions.Read, ProjectPermissionSecretScanningConfigActions.Update], + ProjectPermissionSub.SecretScanningConfigs + ); + return rules; }; @@ -413,6 +419,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretScanningFindings ); + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + return rules; }; @@ -459,6 +467,8 @@ const buildViewerPermissionRules = () => { 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 6c50cc53e..79e4bff6c 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -138,6 +138,11 @@ export enum ProjectPermissionSecretScanningFindingActions { Update = "update-findings" } +export enum ProjectPermissionSecretScanningConfigActions { + Read = "read-configs", + Update = "update-configs" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -175,7 +180,8 @@ export enum ProjectPermissionSub { SecretSyncs = "secret-syncs", Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", - SecretScanningFindings = "secret-scanning-findings" + SecretScanningFindings = "secret-scanning-findings", + SecretScanningConfigs = "secret-scanning-configs" } export type SecretSubjectFields = { @@ -300,7 +306,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] - | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]; + | [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([ @@ -635,6 +642,12 @@ const GeneralPermissionSchema = [ 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-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts index 63be91b53..00de7cc45 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -8,7 +8,11 @@ import { SecretScanningFindingSeverity, SecretScanningResource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; -import { cloneRepository } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; import { TSecretScanningFactoryGetDiffScanFindingsPayload, TSecretScanningFactoryGetDiffScanResourcePayload, @@ -121,7 +125,7 @@ export const GitHubSecretScanningFactory = () => { const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< TGitHubDataSourceWithConnection, TQueueGitHubResourceDiffScan["payload"] - > = async ({ dataSource, payload, resourceName }) => { + > = async ({ dataSource, payload, resourceName, configPath }) => { const appCfg = getConfig(); const { connection: { @@ -144,33 +148,52 @@ export const GitHubSecretScanningFactory = () => { const allFindings: SecretMatch[] = []; for (const commit of commits) { - for (const filepath of [...commit.added, ...commit.modified]) { - // eslint-disable-next-line - const fileContentsResponse = await octokit.repos.getContent({ - owner, - repo, - path: filepath - }); + // eslint-disable-next-line no-await-in-loop + const commitData = await octokit.repos.getCommit({ + owner, + repo, + ref: commit.id + }); - const { data } = fileContentsResponse; - const fileContent = Buffer.from((data as { content: string }).content, "base64").toString(); + // eslint-disable-next-line no-continue + if (!commitData.data.files) continue; - // eslint-disable-next-line - const findings = await scanContentAndGetFindings(`\n${fileContent}`); // extra line to count lines correctly + 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 + ); - allFindings.push( - ...findings.map((finding) => ({ - ...finding, - File: filepath, - Commit: commit.id, - Author: commit.author.name, - Email: commit.author.email ?? "", - Message: commit.message, - Fingerprint: `${commit.id}:${filepath}:${finding.RuleID}:${finding.StartLine}`, - Date: commit.timestamp, - Link: `https://github.com/${resourceName}/blob/${commit.id}/${filepath}#L${finding.StartLine}` - })) - ); + 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); + } } } 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 index e1bb88bc7..3cbd7f69f 100644 --- 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 @@ -16,8 +16,9 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const GitHubDataSourceConfigSchema = z.object({ includeRepos: z - .array(z.string()) + .array(z.string().min(1).max(256)) .nonempty("One or more repositories required") + .max(25) .default(["*"]) .describe(SecretScanningDataSources.CONFIG.GITHUB.includeRepos) }); 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 index b22eb414d..447ffc22a 100644 --- 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 @@ -111,6 +111,7 @@ export const secretScanningV2DALFactory = (db: TDbClient) => { 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 { @@ -406,7 +407,7 @@ export const secretScanningV2DALFactory = (db: TDbClient) => { unresolvedFindings: findings?.filter((finding) => finding.status === SecretScanningFindingStatus.Unresolved).length ?? 0, resolvedFindings: - findings?.filter((finding) => finding.status === SecretScanningFindingStatus.Resolved).length ?? 0, + findings?.filter((finding) => finding.status !== SecretScanningFindingStatus.Unresolved).length ?? 0, resourceName: resources[0].name }; }); @@ -453,6 +454,7 @@ export const secretScanningV2DALFactory = (db: TDbClient) => { findWithDetailsByDataSourceId: findScansWithDetailsByDataSourceId, findByDataSourceId: findScansByDataSourceId }, - findings: findingOrm + findings: findingOrm, + configs: configOrm }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index 22191f4f4..1128015a4 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -30,9 +30,9 @@ export const cloneRepository = async ({ cloneUrl, repoPath }: TCloneRepository): }); }; -export function scanDirectory(inputPath: string, outputPath: string): Promise { +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}"`; + const command = `cd ${inputPath} && infisical scan --exit-code=77 -r "${outputPath}" ${configPath ? `-c ${configPath}` : ""}`; exec(command, (error) => { if (error && error.code !== 77) { reject(error); @@ -43,8 +43,12 @@ export function scanDirectory(inputPath: string, outputPath: string): Promise { - await scanDirectory(scanPath, findingsPath); +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[]; @@ -56,13 +60,64 @@ export const scanGitRepositoryAndGetFindings = async (scanPath: string, findings ...finding }) => ({ details: titleCaseToCamelCase(finding), - fingerprint: finding.Fingerprint, + 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"); +}; + +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 = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + 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 => { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 0863797eb..f02eca27c 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -1,11 +1,12 @@ import { join } from "path"; -import { ProjectMembershipRole } from "@app/db/schemas"; +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 + deleteTempFolder, + writeTextToFile } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; import { parseScanErrorMessage, @@ -184,21 +185,33 @@ export const secretScanningV2QueueServiceFactory = async ({ 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); + findingsPayload = await scanGitRepositoryAndGetFindings(scanPath, findingsPath, configPath); break; default: throw new Error("Unhandled resource type"); } - if (findingsPayload.length) { - await secretScanningV2DAL.findings.transaction(async (tx) => { - await secretScanningV2DAL.findings.upsert( - findingsPayload.map((findings) => ({ - ...findings, + 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, @@ -211,22 +224,28 @@ export const secretScanningV2QueueServiceFactory = async ({ tx, ["resourceName", "dataSourceName", "status"] ); + } - await secretScanningV2DAL.scans.update( - { id: scanId }, - { - status: SecretScanningScanStatus.Completed, - statusMessage: null - } - ); - }); + 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: findingsPayload.length, + numberOfSecrets: newFindings.length, scanId }); } @@ -376,6 +395,8 @@ export const secretScanningV2QueueServiceFactory = async ({ const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const tempFolder = await createTempFolder(); + try { await secretScanningV2DAL.scans.update( { id: scanId }, @@ -387,20 +408,34 @@ export const secretScanningV2QueueServiceFactory = async ({ 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 + payload, + configPath }); - if (findingsPayload.length) { - await secretScanningV2DAL.findings.transaction(async (tx) => { - await secretScanningV2DAL.findings.upsert( - findingsPayload.map((findings) => ({ - ...findings, + 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, @@ -413,21 +448,27 @@ export const secretScanningV2QueueServiceFactory = async ({ tx, ["resourceName", "dataSourceName", "status"] ); + } - await secretScanningV2DAL.scans.update( - { id: scanId }, - { - status: SecretScanningScanStatus.Completed - } - ); - }); + 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: findingsPayload.length, + numberOfSecrets: newFindings.length, scanId }); } @@ -496,6 +537,8 @@ export const secretScanningV2QueueServiceFactory = async ({ logger.error(error, `secretScanningV2Queue: Diff Scan Failed ${logDetails}`); throw error; + } finally { + await deleteTempFolder(tempFolder); } }, { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index 650f46536..6626adc4b 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -4,6 +4,7 @@ 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 @@ -30,7 +31,8 @@ import { TSecretScanningScanWithDetails, TTriggerSecretScanningDataSourceDTO, TUpdateSecretScanningDataSourceDTO, - TUpdateSecretScanningFinding + 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"; @@ -714,7 +716,7 @@ export const secretScanningV2ServiceFactory = ({ }; const updateSecretScanningFindingById = async ( - { findingId, remarks, status }: TUpdateSecretScanningFinding, + { findingId, remarks, status }: TUpdateSecretScanningFindingDTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); @@ -754,6 +756,77 @@ export const secretScanningV2ServiceFactory = ({ 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.Read, + ProjectPermissionSub.SecretScanningConfigs + ); + + const [config] = await secretScanningV2DAL.configs.upsert( + [ + { + projectId, + content + } + ], + "projectId" + ); + + return config; + }; + return { listSecretScanningDataSourceOptions, listSecretScanningDataSourcesByProjectId, @@ -771,6 +844,8 @@ export const secretScanningV2ServiceFactory = ({ 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 index d501fe0e5..3ee5851d7 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -131,7 +131,7 @@ export type TSecretScanningFactoryGetFullScanPath = (parameters: { dataSource: T; resourceName: string; payload: P }) => Promise; +> = (parameters: { dataSource: T; resourceName: string; payload: P; configPath?: string }) => Promise; export type TSecretScanningDataSourceRaw = NonNullable< Awaited> @@ -175,10 +175,15 @@ export type TSecretScanningFactory< export type TFindingsPayload = Pick[]; export type TGetFindingsPayload = Promise; -export type TUpdateSecretScanningFinding = { +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/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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index a1d529b82..7fa6ddc8e 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2426,3 +2426,13 @@ export const SecretScanningFindings = { 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/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..ee0df5661 --- /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/{projectId}" +--- 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..d7025430d --- /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/{projectId}" +--- + + + 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/documentation/platform/secret-scanning/github.mdx b/docs/documentation/platform/secret-scanning/github.mdx index 8cc179292..0dfa1f146 100644 --- a/docs/documentation/platform/secret-scanning/github.mdx +++ b/docs/documentation/platform/secret-scanning/github.mdx @@ -1,4 +1,10 @@ -import {Tabs} from "../../../../frontend/src/components/v2";## Prerequisites +--- +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) diff --git a/docs/documentation/platform/secret-scanning/overview.mdx b/docs/documentation/platform/secret-scanning/overview.mdx index 936b5e4c4..e5f97da33 100644 --- a/docs/documentation/platform/secret-scanning/overview.mdx +++ b/docs/documentation/platform/secret-scanning/overview.mdx @@ -71,6 +71,134 @@ These status options help teams effectively track and manage the lifecycle of de ![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. 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/mint.json b/docs/mint.json index 46751cbab..e4de2100a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1046,6 +1046,13 @@ "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" + ] } ] }, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 4438113d2..fe5496a62 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -128,6 +128,11 @@ export enum ProjectPermissionSecretScanningFindingActions { Update = "update-findings" } +export enum ProjectPermissionSecretScanningConfigActions { + Read = "read-configs", + Update = "update-configs" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -216,7 +221,8 @@ export enum ProjectPermissionSub { SecretSyncs = "secret-syncs", Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", - SecretScanningFindings = "secret-scanning-findings" + SecretScanningFindings = "secret-scanning-findings", + SecretScanningConfigs = "secret-scanning-configs" } export type SecretSubjectFields = { @@ -345,6 +351,7 @@ export type ProjectPermissionSet = ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources ] - | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]; + | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 79a0224c8..b0b5f64b3 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -201,7 +201,9 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.SECRET_SCANNING_RESOURCE_LIST]: "List Secret Scanning Resources", [EventType.SECRET_SCANNING_SCAN_LIST]: "List Secret Scanning Scans", [EventType.SECRET_SCANNING_FINDING_LIST]: "List Secret Scanning Findings", - [EventType.SECRET_SCANNING_FINDING_UPDATE]: "Update Secret Scanning Finding Status" + [EventType.SECRET_SCANNING_FINDING_UPDATE]: "Update Secret Scanning Finding Status", + [EventType.SECRET_SCANNING_CONFIG_GET]: "Get Secret Scanning Configuration", + [EventType.SECRET_SCANNING_CONFIG_UPDATE]: "Update Secret Scanning Configuration" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 211ae0cb9..2139f8dd8 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -195,5 +195,7 @@ export enum EventType { 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_FINDING_UPDATE = "secret-scanning-finding-update", + SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get", + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update" } diff --git a/frontend/src/hooks/api/secretScanningV2/mutations.tsx b/frontend/src/hooks/api/secretScanningV2/mutations.tsx index 7020a9f29..36ea07c04 100644 --- a/frontend/src/hooks/api/secretScanningV2/mutations.tsx +++ b/frontend/src/hooks/api/secretScanningV2/mutations.tsx @@ -6,6 +6,8 @@ import { secretScanningV2Keys } from "./queries"; import { TCreateSecretScanningDataSourceDTO, TDeleteSecretScanningDataSourceDTO, + TGetSecretScanningConfigResponse, + TSecretScanningConfig, TSecretScanningDataSourceResponse, TSecretScanningFindingResponse, TTriggerSecretScanningDataSourceDTO, @@ -128,7 +130,31 @@ export const useUpdateSecretScanningFinding = () => { queryClient.invalidateQueries({ queryKey: secretScanningV2Keys.listFindings(projectId) }); - // TODO: data source queries + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.findingCount(projectId) + }); + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.dataSource() + }); + } + }); +}; + +export const useUpdateSecretScanningConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ projectId, ...params }: TSecretScanningConfig) => { + const { data } = await apiRequest.patch( + `/api/v2/secret-scanning/configs/${projectId}`, + params + ); + + return data.config; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.configByProjectId(projectId) + }); } }); }; diff --git a/frontend/src/hooks/api/secretScanningV2/queries.tsx b/frontend/src/hooks/api/secretScanningV2/queries.tsx index 3e7867f72..57714b043 100644 --- a/frontend/src/hooks/api/secretScanningV2/queries.tsx +++ b/frontend/src/hooks/api/secretScanningV2/queries.tsx @@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { + TGetSecretScanningConfigResponse, TGetSecretScanningDataSource, TGetSecretScanningUnresolvedFindingsResponse, TListSecretScanningDataSourceOptions, @@ -10,6 +11,7 @@ import { TListSecretScanningFindingsResponse, TListSecretScanningResourcesResponse, TListSecretScanningScansResponse, + TSecretScanningConfig, TSecretScanningDataSource, TSecretScanningDataSourceOption, TSecretScanningDataSourceResponse, @@ -42,7 +44,9 @@ export const secretScanningV2Keys = { finding: () => [...secretScanningV2Keys.all, "finding"] as const, findingCount: (projectId: string) => [...secretScanningV2Keys.finding(), "count", projectId] as const, - listFindings: (projectId: string) => [...secretScanningV2Keys.finding(), "list", projectId] + listFindings: (projectId: string) => [...secretScanningV2Keys.finding(), "list", projectId], + configByProjectId: (projectId: string) => + [...secretScanningV2Keys.all, "config", projectId] as const }; export const useSecretScanningDataSourceOptions = ( @@ -216,3 +220,28 @@ export const useListSecretScanningFindings = ( ...options }); }; + +export const useGetSecretScanningConfig = ( + projectId: string, + options?: Omit< + UseQueryOptions< + TSecretScanningConfig, + unknown, + TSecretScanningConfig, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretScanningV2Keys.configByProjectId(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v2/secret-scanning/configs/${projectId}` + ); + + return data.config; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/secretScanningV2/types/index.ts b/frontend/src/hooks/api/secretScanningV2/types/index.ts index a2b1ad837..07beaef5e 100644 --- a/frontend/src/hooks/api/secretScanningV2/types/index.ts +++ b/frontend/src/hooks/api/secretScanningV2/types/index.ts @@ -115,6 +115,15 @@ export type TGetSecretScanningUnresolvedFindingsResponse = { unresolvedFindings: number; }; +export type TSecretScanningConfig = { + projectId: string; + content: string | null; +}; + +export type TGetSecretScanningConfigResponse = { + config: TSecretScanningConfig; +}; + export type TSecretScanningFinding = { id: string; dataSourceName: string; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 893222141..1dadc2497 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -20,6 +20,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, + ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSecretScanningFindingActions, ProjectPermissionSecretSyncActions, @@ -107,6 +108,11 @@ const SecretScanningFindingPolicyActionSchema = z.object({ [ProjectPermissionSecretScanningFindingActions.Update]: z.boolean().optional() }); +const SecretScanningConfigPolicyActionSchema = z.object({ + [ProjectPermissionSecretScanningConfigActions.Read]: z.boolean().optional(), + [ProjectPermissionSecretScanningConfigActions.Update]: z.boolean().optional() +}); + const KmipPolicyActionSchema = z.object({ [ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(), [ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(), @@ -295,7 +301,9 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.SecretScanningDataSources]: SecretScanningDataSourcePolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningFindings]: - SecretScanningFindingPolicyActionSchema.array().default([]) + SecretScanningFindingPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretScanningConfigs]: + SecretScanningConfigPolicyActionSchema.array().default([]) }) .partial() .optional() @@ -777,6 +785,18 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { formVal[subject]![0][ProjectPermissionSecretScanningFindingActions.Update] = true; } + if (subject === ProjectPermissionSub.SecretScanningConfigs) { + const canRead = action.includes(ProjectPermissionSecretScanningConfigActions.Read); + const canUpdate = action.includes(ProjectPermissionSecretScanningConfigActions.Update); + + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject]![0][ProjectPermissionSecretScanningConfigActions.Read] = true; + if (canUpdate) + formVal[subject]![0][ProjectPermissionSecretScanningConfigActions.Update] = true; + } + if (subject === ProjectPermissionSub.SshHosts) { if (!formVal[subject]) formVal[subject] = []; @@ -1363,6 +1383,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { value: ProjectPermissionSecretScanningFindingActions.Update } ] + }, + [ProjectPermissionSub.SecretScanningConfigs]: { + title: "Secret Scanning Config", + actions: [ + { + label: "Read Config", + value: ProjectPermissionSecretScanningConfigActions.Read + }, + { + label: "Update Config", + value: ProjectPermissionSecretScanningConfigActions.Update + } + ] } }; @@ -1418,7 +1451,8 @@ const SshPermissionSubjects = (enabled = false) => ({ const SecretScanningSubject = (enabled = false) => ({ [ProjectPermissionSub.SecretScanningDataSources]: enabled, - [ProjectPermissionSub.SecretScanningFindings]: enabled + [ProjectPermissionSub.SecretScanningFindings]: enabled, + [ProjectPermissionSub.SecretScanningConfigs]: enabled }); // scott: this structure ensures we don't forget to add project permissions to their relevant project type @@ -1693,6 +1727,10 @@ export const RoleTemplates: Record = { { subject: ProjectPermissionSub.SecretScanningFindings, actions: [ProjectPermissionSecretScanningFindingActions.Read] + }, + { + subject: ProjectPermissionSub.SecretScanningConfigs, + actions: [ProjectPermissionSecretScanningConfigActions.Read] } ] }, @@ -1708,10 +1746,19 @@ export const RoleTemplates: Record = { { subject: ProjectPermissionSub.SecretScanningFindings, actions: Object.values(ProjectPermissionSecretScanningFindingActions) + }, + { + subject: ProjectPermissionSub.SecretScanningConfigs, + actions: [ProjectPermissionSecretScanningConfigActions.Read] } ] }, - projectManagerTemplate() + projectManagerTemplate([ + { + subject: ProjectPermissionSub.SecretScanningConfigs, + actions: Object.values(ProjectPermissionSecretScanningConfigActions) + } + ]) ], [ProjectType.SecretManager]: [ { diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningScanRow.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningScanRow.tsx index 21e310ecd..0e2dc0cd1 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningScanRow.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/SecretScanningScanRow.tsx @@ -114,7 +114,7 @@ export const SecretScanningScanRow = ({ scan }: Props) => { {totalFindings}{" "} {unresolvedFindings - ? `Secret ${totalFindings > 1 ? "s" : ""} Detected` + ? `Secret${totalFindings > 1 ? "s" : ""} Detected` : "Leak Resolved"} diff --git a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx index 7be3b1091..39ed618aa 100644 --- a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx @@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; +import { ProjectScanningConfigTab } from "./components/ProjectScanningConfigTab"; export const SettingsPage = () => { const { t } = useTranslation(); @@ -21,26 +23,29 @@ export const SettingsPage = () => { General {(isAllowed) => - isAllowed && Rule Settings + isAllowed && Scanning Configuration } - {/* + {(isAllowed) => isAllowed && ( - - + + ) } - */} + diff --git a/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx new file mode 100644 index 000000000..ca14f5dc2 --- /dev/null +++ b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx @@ -0,0 +1,40 @@ +import { faBan } from "@fortawesome/free-solid-svg-icons"; + +import { ContentLoader, EmptyState } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetSecretScanningConfig } from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningConfigForm } from "./SecretScanningConfigForm"; + +export const ProjectScanningConfigTab = () => { + const { currentWorkspace } = useWorkspace(); + const { data: config, isPending: isConfigPending } = useGetSecretScanningConfig( + currentWorkspace.id + ); + + if (isConfigPending) { + return ( +
+ +
+ ); + } + + if (!config) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/SecretScanningConfigForm.tsx b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/SecretScanningConfigForm.tsx new file mode 100644 index 000000000..1f8a47624 --- /dev/null +++ b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/SecretScanningConfigForm.tsx @@ -0,0 +1,132 @@ +import { Controller, useForm } from "react-hook-form"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, TextArea } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types"; +import { + TSecretScanningConfig, + useUpdateSecretScanningConfig +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + config: TSecretScanningConfig; +}; + +const FormSchema = z.object({ + content: z.string().max(5000, "Configuration cannot exceed 5000 characters").optional() +}); + +type FormType = z.infer; + +export const SecretScanningConfigForm = ({ config }: Props) => { + const updateConfig = useUpdateSecretScanningConfig(); + + const { + handleSubmit, + control, + formState: { isDirty } + } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + content: config.content ?? "" + } + }); + + const onSubmit = async ({ content }: FormType) => { + try { + await updateConfig.mutateAsync({ + projectId: config.projectId, + content: content || null + }); + + createNotification({ + type: "success", + text: "Configuration successfully updated" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to update Configuration" + }); + } + }; + + return ( +
+
+
+

Project Configuration

+ +
+ + Docs + +
+
+
+

+ Configure rules and exceptions to customize scanning +

+
+
+
+ + {(isAllowed) => ( + ( + +