Files
infisical/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts
T

292 lines
9.5 KiB
TypeScript

import { join } from "path";
import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns";
import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
import {
SecretScanningFindingSeverity,
SecretScanningResource
} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums";
import {
cloneRepository,
convertPatchLineToFileLineNumber,
replaceNonChangesWithNewlines
} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns";
import {
TSecretScanningFactoryGetDiffScanFindingsPayload,
TSecretScanningFactoryGetDiffScanResourcePayload,
TSecretScanningFactoryGetFullScanPath,
TSecretScanningFactoryInitialize,
TSecretScanningFactoryListRawResources,
TSecretScanningFactoryPostInitialization
} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { titleCaseToCamelCase } from "@app/lib/fn";
import { GitHubRepositoryRegex } from "@app/lib/regex";
import {
getBitbucketUser,
listBitbucketRepositories,
TBitbucketConnection
} from "@app/services/app-connection/bitbucket";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TBitbucketDataSourceWithConnection, TQueueBitbucketResourceDiffScan } from "./bitbucket-secret-scanning-types";
export const BitbucketSecretScanningFactory = () => {
const initialize: TSecretScanningFactoryInitialize<TBitbucketConnection> = async (
{ connection, payload },
callback
) => {
const { email, apiToken } = connection.credentials;
const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
const { data } = await request.post<{ uuid: string }>(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${payload.config.workspaceSlug}/hooks`,
{
description: "Infisical webhook for push events",
url: `https://tunnel.util.lol/secret-scanning/webhooks/bitbucket`, // TODO(andrey): Swap to ${cfg.SITE_URL}
active: true,
events: ["repo:push"]
},
{
headers: {
Authorization: authHeader,
Accept: "application/json"
}
}
);
return callback({
credentials: { webhookId: data.uuid }
});
};
const postInitialization: TSecretScanningFactoryPostInitialization<TBitbucketConnection> = async ({
dataSourceId,
credentials,
connection,
payload
}) => {
const { email, apiToken } = connection.credentials;
const { webhookId } = credentials;
const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
const cfg = getConfig();
const newWebhookUrl = `${cfg.SITE_URL}/secret-scanning/webhooks/bitbucket?dataSourceId=${dataSourceId}`;
await request.put(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${payload.config.workspaceSlug}/hooks/${webhookId}`,
{
description: "Infisical webhook for push events",
url: newWebhookUrl,
active: true,
events: ["repo:push"]
},
{
headers: {
Authorization: authHeader,
Accept: "application/json"
}
}
);
};
// TODO(andrey): Hook this up
const postDeletion: any = async ({ credentials, connection, payload }) => {
const { email, apiToken } = connection.credentials;
const { webhookId } = credentials;
const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
await request.delete(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${payload.config.workspaceSlug}/hooks/${webhookId}`,
{
headers: {
Authorization: authHeader,
Accept: "application/json"
}
}
);
};
const listRawResources: TSecretScanningFactoryListRawResources<TBitbucketDataSourceWithConnection> = async (
dataSource
) => {
const {
connection,
config: { includeRepos, workspaceSlug }
} = dataSource;
const repos = await listBitbucketRepositories(connection, workspaceSlug);
const filteredRepos: typeof repos = [];
if (includeRepos.includes("*")) {
filteredRepos.push(...repos);
} else {
filteredRepos.push(...repos.filter((repo) => includeRepos.includes(repo.full_name)));
}
return filteredRepos.map(({ slug, full_name }) => ({
name: full_name,
externalId: slug.toString(),
type: SecretScanningResource.Repository
}));
};
const getFullScanPath: TSecretScanningFactoryGetFullScanPath<TBitbucketDataSourceWithConnection> = async ({
dataSource,
resourceName,
tempFolder
}) => {
const {
connection: {
credentials: { apiToken, email }
}
} = dataSource;
const repoPath = join(tempFolder, "repo.git");
if (!GitHubRepositoryRegex.test(resourceName)) {
throw new Error("Invalid Bitbucket repository name");
}
const { username } = await getBitbucketUser({ email, apiToken });
await cloneRepository({
cloneUrl: `https://${encodeURIComponent(username)}:${apiToken}@bitbucket.org/${resourceName}.git`,
repoPath
});
return repoPath;
};
const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload<
TQueueBitbucketResourceDiffScan["payload"]
> = ({ repository, dataSourceId }) => {
return {
name: repository.full_name,
externalId: dataSourceId,
type: SecretScanningResource.Repository
};
};
const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload<
TBitbucketDataSourceWithConnection,
TQueueBitbucketResourceDiffScan["payload"]
> = async ({ dataSource, payload, resourceName, configPath }) => {
const {
connection: {
credentials: { apiToken, email }
}
} = dataSource;
const { push, repository } = payload;
const allFindings: SecretMatch[] = [];
const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
for (const change of push.changes) {
for (const commit of change.commits) {
// eslint-disable-next-line no-await-in-loop
const { data: diffstat } = await request.get<{
values: {
status: "added" | "modified" | "removed" | "renamed";
new?: { path: string };
old?: { path: string };
}[];
}>(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${repository.full_name}/diffstat/${commit.hash}`, {
headers: {
Authorization: authHeader,
Accept: "application/json"
}
});
// eslint-disable-next-line no-continue
if (!diffstat.values) continue;
for (const file of diffstat.values) {
if ((file.status === "added" || file.status === "modified") && file.new?.path) {
const filePath = file.new.path;
// eslint-disable-next-line no-await-in-loop
const { data: patch } = await request.get<string>(
`https://api.bitbucket.org/2.0/repositories/${repository.full_name}/diff/${commit.hash}`,
{
params: {
path: filePath
},
headers: {
Authorization: authHeader
},
responseType: "text"
}
);
// eslint-disable-next-line no-continue
if (!patch) continue;
// eslint-disable-next-line no-await-in-loop
const findings = await scanContentAndGetFindings(replaceNonChangesWithNewlines(`\n${patch}`), configPath);
const adjustedFindings = findings.map((finding) => {
const startLine = convertPatchLineToFileLineNumber(patch, finding.StartLine);
const endLine =
finding.StartLine === finding.EndLine
? startLine
: convertPatchLineToFileLineNumber(patch, finding.EndLine);
const startColumn = finding.StartColumn - 1; // subtract 1 for +
const endColumn = finding.EndColumn - 1; // subtract 1 for +
const authorName = commit.author.user?.display_name || commit.author.raw.split(" <")[0];
const emailMatch = commit.author.raw.match(/<(.*)>/);
const authorEmail = emailMatch?.[1] ?? "";
return {
...finding,
StartLine: startLine,
EndLine: endLine,
StartColumn: startColumn,
EndColumn: endColumn,
File: filePath,
Commit: commit.hash,
Author: authorName,
Email: authorEmail,
Message: commit.message,
Fingerprint: `${commit.hash}:${filePath}:${finding.RuleID}:${startLine}:${startColumn}`,
Date: commit.date,
Link: `https://bitbucket.org/${resourceName}/src/${commit.hash}/${filePath}#lines-${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
};
};