Merge pull request #3925 from Infisical/ENG-3041

feat(secret-scanning): Bitbucket data source + App Connection
This commit is contained in:
x032205
2025-07-07 22:41:38 -04:00
committed by GitHub
113 changed files with 2185 additions and 130 deletions
@@ -28,6 +28,7 @@ export enum AppConnection {
Flyio = "flyio",
GitLab = "gitlab",
Cloudflare = "cloudflare",
Bitbucket = "bitbucket",
Zabbix = "zabbix"
}
@@ -50,6 +50,11 @@ import {
getAzureKeyVaultConnectionListItem,
validateAzureKeyVaultConnectionCredentials
} from "./azure-key-vault";
import {
BitbucketConnectionMethod,
getBitbucketConnectionListItem,
validateBitbucketConnectionCredentials
} from "./bitbucket";
import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda";
import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum";
import {
@@ -138,6 +143,7 @@ export const listAppConnectionOptions = () => {
getFlyioConnectionListItem(),
getGitLabConnectionListItem(),
getCloudflareConnectionListItem(),
getBitbucketConnectionListItem(),
getZabbixConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -219,6 +225,7 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator
};
@@ -256,6 +263,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case VercelConnectionMethod.ApiToken:
case OnePassConnectionMethod.ApiToken:
case CloudflareConnectionMethod.APIToken:
case BitbucketConnectionMethod.ApiToken:
case ZabbixConnectionMethod.ApiToken:
return "API Token";
case PostgresConnectionMethod.UsernameAndPassword:
@@ -337,6 +345,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Flyio]: platformManagedCredentialsNotSupported,
[AppConnection.GitLab]: platformManagedCredentialsNotSupported,
[AppConnection.Cloudflare]: platformManagedCredentialsNotSupported,
[AppConnection.Bitbucket]: platformManagedCredentialsNotSupported,
[AppConnection.Zabbix]: platformManagedCredentialsNotSupported
};
@@ -30,6 +30,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Flyio]: "Fly.io",
[AppConnection.GitLab]: "GitLab",
[AppConnection.Cloudflare]: "Cloudflare",
[AppConnection.Bitbucket]: "Bitbucket",
[AppConnection.Zabbix]: "Zabbix"
};
@@ -63,5 +64,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Flyio]: AppConnectionPlanType.Regular,
[AppConnection.GitLab]: AppConnectionPlanType.Regular,
[AppConnection.Cloudflare]: AppConnectionPlanType.Regular,
[AppConnection.Bitbucket]: AppConnectionPlanType.Regular,
[AppConnection.Zabbix]: AppConnectionPlanType.Regular
};
@@ -45,6 +45,8 @@ import { azureClientSecretsConnectionService } from "./azure-client-secrets/azur
import { ValidateAzureDevOpsConnectionCredentialsSchema } from "./azure-devops/azure-devops-schemas";
import { azureDevOpsConnectionService } from "./azure-devops/azure-devops-service";
import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault";
import { ValidateBitbucketConnectionCredentialsSchema } from "./bitbucket";
import { bitbucketConnectionService } from "./bitbucket/bitbucket-connection-service";
import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
import { camundaConnectionService } from "./camunda/camunda-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
@@ -122,6 +124,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema,
[AppConnection.GitLab]: ValidateGitLabConnectionCredentialsSchema,
[AppConnection.Cloudflare]: ValidateCloudflareConnectionCredentialsSchema,
[AppConnection.Bitbucket]: ValidateBitbucketConnectionCredentialsSchema,
[AppConnection.Zabbix]: ValidateZabbixConnectionCredentialsSchema
};
@@ -533,6 +536,7 @@ export const appConnectionServiceFactory = ({
flyio: flyioConnectionService(connectAppConnectionById),
gitlab: gitlabConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
cloudflare: cloudflareConnectionService(connectAppConnectionById),
bitbucket: bitbucketConnectionService(connectAppConnectionById),
zabbix: zabbixConnectionService(connectAppConnectionById)
};
};
@@ -56,6 +56,12 @@ import {
TAzureKeyVaultConnectionInput,
TValidateAzureKeyVaultConnectionCredentialsSchema
} from "./azure-key-vault";
import {
TBitbucketConnection,
TBitbucketConnectionConfig,
TBitbucketConnectionInput,
TValidateBitbucketConnectionCredentialsSchema
} from "./bitbucket";
import {
TCamundaConnection,
TCamundaConnectionConfig,
@@ -202,6 +208,7 @@ export type TAppConnection = { id: string } & (
| TFlyioConnection
| TGitLabConnection
| TCloudflareConnection
| TBitbucketConnection
| TZabbixConnection
);
@@ -239,6 +246,7 @@ export type TAppConnectionInput = { id: string } & (
| TFlyioConnectionInput
| TGitLabConnectionInput
| TCloudflareConnectionInput
| TBitbucketConnectionInput
| TZabbixConnectionInput
);
@@ -284,6 +292,7 @@ export type TAppConnectionConfig =
| TFlyioConnectionConfig
| TGitLabConnectionConfig
| TCloudflareConnectionConfig
| TBitbucketConnectionConfig
| TZabbixConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
@@ -316,6 +325,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateFlyioConnectionCredentialsSchema
| TValidateGitLabConnectionCredentialsSchema
| TValidateCloudflareConnectionCredentialsSchema
| TValidateBitbucketConnectionCredentialsSchema
| TValidateZabbixConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
@@ -0,0 +1,3 @@
export enum BitbucketConnectionMethod {
ApiToken = "api-token"
}
@@ -0,0 +1,117 @@
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { BitbucketConnectionMethod } from "./bitbucket-connection-enums";
import {
TBitbucketConnection,
TBitbucketConnectionConfig,
TBitbucketRepo,
TBitbucketWorkspace
} from "./bitbucket-connection-types";
export const getBitbucketConnectionListItem = () => {
return {
name: "Bitbucket" as const,
app: AppConnection.Bitbucket as const,
methods: Object.values(BitbucketConnectionMethod) as [BitbucketConnectionMethod.ApiToken]
};
};
export const getBitbucketUser = async ({ email, apiToken }: { email: string; apiToken: string }) => {
try {
const { data } = await request.get<{ username: string }>(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/user`, {
headers: {
Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`,
Accept: "application/json"
}
});
return data;
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
};
export const validateBitbucketConnectionCredentials = async (config: TBitbucketConnectionConfig) => {
await getBitbucketUser(config.credentials);
return config.credentials;
};
interface BitbucketWorkspacesResponse {
values: TBitbucketWorkspace[];
next?: string;
}
export const listBitbucketWorkspaces = async (appConnection: TBitbucketConnection) => {
const { email, apiToken } = appConnection.credentials;
const headers = {
Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`,
Accept: "application/json"
};
let allWorkspaces: TBitbucketWorkspace[] = [];
let nextUrl: string | undefined = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces?pagelen=100`;
let iterationCount = 0;
// Limit to 10 iterations, fetching at most 10 * 100 = 1000 workspaces
while (nextUrl && iterationCount < 10) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: BitbucketWorkspacesResponse } = await request.get<BitbucketWorkspacesResponse>(nextUrl, {
headers
});
allWorkspaces = allWorkspaces.concat(data.values.map((workspace) => ({ slug: workspace.slug })));
nextUrl = data.next;
iterationCount += 1;
}
return allWorkspaces;
};
interface BitbucketRepositoriesResponse {
values: TBitbucketRepo[];
next?: string;
}
export const listBitbucketRepositories = async (appConnection: TBitbucketConnection, workspaceSlug: string) => {
const { email, apiToken } = appConnection.credentials;
const headers = {
Authorization: `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`,
Accept: "application/json"
};
let allRepos: TBitbucketRepo[] = [];
let nextUrl: string | undefined =
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodeURIComponent(workspaceSlug)}?pagelen=100`;
let iterationCount = 0;
// Limit to 10 iterations, fetching at most 10 * 100 = 1000 repositories
while (nextUrl && iterationCount < 10) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: BitbucketRepositoriesResponse } = await request.get<BitbucketRepositoriesResponse>(
nextUrl,
{
headers
}
);
allRepos = allRepos.concat(data.values);
nextUrl = data.next;
iterationCount += 1;
}
return allRepos;
};
@@ -0,0 +1,72 @@
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 { BitbucketConnectionMethod } from "./bitbucket-connection-enums";
export const BitbucketConnectionAccessTokenCredentialsSchema = z.object({
apiToken: z
.string()
.trim()
.min(1, "API Token required")
.max(255)
.describe(AppConnections.CREDENTIALS.BITBUCKET.apiToken),
email: z
.string()
.email()
.trim()
.min(1, "Email required")
.max(255)
.describe(AppConnections.CREDENTIALS.BITBUCKET.email)
});
const BaseBitbucketConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Bitbucket) });
export const BitbucketConnectionSchema = BaseBitbucketConnectionSchema.extend({
method: z.literal(BitbucketConnectionMethod.ApiToken),
credentials: BitbucketConnectionAccessTokenCredentialsSchema
});
export const SanitizedBitbucketConnectionSchema = z.discriminatedUnion("method", [
BaseBitbucketConnectionSchema.extend({
method: z.literal(BitbucketConnectionMethod.ApiToken),
credentials: BitbucketConnectionAccessTokenCredentialsSchema.pick({
email: true
})
})
]);
export const ValidateBitbucketConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(BitbucketConnectionMethod.ApiToken)
.describe(AppConnections.CREATE(AppConnection.Bitbucket).method),
credentials: BitbucketConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Bitbucket).credentials
)
})
]);
export const CreateBitbucketConnectionSchema = ValidateBitbucketConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Bitbucket)
);
export const UpdateBitbucketConnectionSchema = z
.object({
credentials: BitbucketConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Bitbucket).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Bitbucket));
export const BitbucketConnectionListItemSchema = z.object({
name: z.literal("Bitbucket"),
app: z.literal(AppConnection.Bitbucket),
methods: z.nativeEnum(BitbucketConnectionMethod).array()
});
@@ -0,0 +1,33 @@
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listBitbucketRepositories, listBitbucketWorkspaces } from "./bitbucket-connection-fns";
import { TBitbucketConnection, TGetBitbucketRepositoriesDTO } from "./bitbucket-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TBitbucketConnection>;
export const bitbucketConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor);
const workspaces = await listBitbucketWorkspaces(appConnection);
return workspaces;
};
const listRepositories = async (
{ connectionId, workspaceSlug }: TGetBitbucketRepositoriesDTO,
actor: OrgServiceActor
) => {
const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor);
const repositories = await listBitbucketRepositories(appConnection, workspaceSlug);
return repositories;
};
return {
listWorkspaces,
listRepositories
};
};
@@ -0,0 +1,40 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
BitbucketConnectionSchema,
CreateBitbucketConnectionSchema,
ValidateBitbucketConnectionCredentialsSchema
} from "./bitbucket-connection-schemas";
export type TBitbucketConnection = z.infer<typeof BitbucketConnectionSchema>;
export type TBitbucketConnectionInput = z.infer<typeof CreateBitbucketConnectionSchema> & {
app: AppConnection.Bitbucket;
};
export type TValidateBitbucketConnectionCredentialsSchema = typeof ValidateBitbucketConnectionCredentialsSchema;
export type TBitbucketConnectionConfig = DiscriminativePick<
TBitbucketConnectionInput,
"method" | "app" | "credentials"
> & {
orgId: string;
};
export type TGetBitbucketRepositoriesDTO = {
connectionId: string;
workspaceSlug: string;
};
export type TBitbucketWorkspace = {
slug: string;
};
export type TBitbucketRepo = {
uuid: string;
full_name: string; // workspace-slug/repo-slug
slug: string;
};
@@ -0,0 +1,4 @@
export * from "./bitbucket-connection-enums";
export * from "./bitbucket-connection-fns";
export * from "./bitbucket-connection-schemas";
export * from "./bitbucket-connection-types";
@@ -814,9 +814,9 @@ const getAppsCloudflareWorkers = async ({ accessToken, accountId }: { accessToke
};
/**
* Return list of repositories for the BitBucket integration based on provided BitBucket workspace
* Return list of repositories for the Bitbucket integration based on provided Bitbucket workspace
*/
const getAppsBitBucket = async ({ accessToken, workspaceSlug }: { accessToken: string; workspaceSlug?: string }) => {
const getAppsBitbucket = async ({ accessToken, workspaceSlug }: { accessToken: string; workspaceSlug?: string }) => {
interface RepositoriesResponse {
size: number;
page: number;
@@ -1302,7 +1302,7 @@ export const getApps = async ({
});
case Integrations.BITBUCKET:
return getAppsBitBucket({
return getAppsBitbucket({
accessToken,
workspaceSlug
});
@@ -342,7 +342,7 @@ export const getIntegrationOptions = async () => {
{
name: "Bitbucket",
slug: "bitbucket",
image: "BitBucket.png",
image: "Bitbucket.png",
isAvailable: true,
type: "oauth",
clientId: appCfg.CLIENT_ID_BITBUCKET,
@@ -3921,9 +3921,9 @@ const syncSecretsCloudflareWorkers = async ({
};
/**
* Sync/push [secrets] to BitBucket repo with name [integration.app]
* Sync/push [secrets] to Bitbucket repo with name [integration.app]
*/
const syncSecretsBitBucket = async ({
const syncSecretsBitbucket = async ({
integration,
secrets,
accessToken
@@ -4832,7 +4832,7 @@ export const syncIntegrationSecrets = async ({
});
break;
case Integrations.BITBUCKET:
await syncSecretsBitBucket({
await syncSecretsBitbucket({
integration,
secrets,
accessToken
@@ -64,7 +64,7 @@ type ExchangeCodeGitlabResponse = {
created_at: number;
};
type ExchangeCodeBitBucketResponse = {
type ExchangeCodeBitbucketResponse = {
access_token: string;
token_type: string;
expires_in: number;
@@ -392,10 +392,10 @@ const exchangeCodeGitlab = async ({ code, url }: { code: string; url?: string })
};
/**
* Return [accessToken], [accessExpiresAt], and [refreshToken] for BitBucket
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Bitbucket
* code-token exchange
*/
const exchangeCodeBitBucket = async ({ code }: { code: string }) => {
const exchangeCodeBitbucket = async ({ code }: { code: string }) => {
const accessExpiresAt = new Date();
const appCfg = getConfig();
if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) {
@@ -403,7 +403,7 @@ const exchangeCodeBitBucket = async ({ code }: { code: string }) => {
}
const res = (
await request.post<ExchangeCodeBitBucketResponse>(
await request.post<ExchangeCodeBitbucketResponse>(
IntegrationUrls.BITBUCKET_TOKEN_URL,
new URLSearchParams({
grant_type: "authorization_code",
@@ -490,7 +490,7 @@ export const exchangeCode = async ({
url
});
case Integrations.BITBUCKET:
return exchangeCodeBitBucket({
return exchangeCodeBitbucket({
code
});
default:
@@ -524,7 +524,7 @@ type RefreshTokenGitLabResponse = {
created_at: number;
};
type RefreshTokenBitBucketResponse = {
type RefreshTokenBitbucketResponse = {
access_token: string;
token_type: string;
expires_in: number;
@@ -653,9 +653,9 @@ const exchangeRefreshGitLab = async ({ refreshToken, url }: { url?: string | nul
/**
* Return new access token by exchanging refresh token [refreshToken] for the
* BitBucket integration
* Bitbucket integration
*/
const exchangeRefreshBitBucket = async ({ refreshToken }: { refreshToken: string }) => {
const exchangeRefreshBitbucket = async ({ refreshToken }: { refreshToken: string }) => {
const accessExpiresAt = new Date();
const appCfg = getConfig();
if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) {
@@ -664,7 +664,7 @@ const exchangeRefreshBitBucket = async ({ refreshToken }: { refreshToken: string
const {
data
}: {
data: RefreshTokenBitBucketResponse;
data: RefreshTokenBitbucketResponse;
} = await request.post(
IntegrationUrls.BITBUCKET_TOKEN_URL,
new URLSearchParams({
@@ -794,7 +794,7 @@ export const exchangeRefresh = async (
url
});
case Integrations.BITBUCKET:
return exchangeRefreshBitBucket({
return exchangeRefreshBitbucket({
refreshToken
});
case Integrations.GCP_SECRET_MANAGER: