feature: secret scanning architecture and github data source (wip)

This commit is contained in:
Scott Wilson
2025-05-28 22:21:03 -07:00
parent 931ee1e8da
commit e6c97510ca
231 changed files with 11443 additions and 1048 deletions
@@ -67,6 +67,15 @@ export const OrgAdminProjects = withPermission(
});
return;
}
if (type === ProjectType.SecretScanning) {
await navigate({
to: "/secret-scanning/$projectId/data-sources" as const,
params: {
projectId
}
});
return;
}
await navigate({
to: `/${type}/$projectId/overview` as const,
params: {
@@ -18,6 +18,7 @@ import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { GitHubRadarConnectionForm } from "./GitHubRadarConnectionForm";
import { HCVaultConnectionForm } from "./HCVaultConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
@@ -70,6 +71,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <AwsConnectionForm onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm />;
case AppConnection.GitHubRadar:
return <GitHubRadarConnectionForm />;
case AppConnection.GCP:
return <GcpConnectionForm onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault:
@@ -144,6 +147,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <AwsConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm appConnection={appConnection} />;
case AppConnection.GitHubRadar:
return <GitHubRadarConnectionForm appConnection={appConnection} />;
case AppConnection.GCP:
return <GcpConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault:
@@ -178,7 +183,6 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,157 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import {
GitHubRadarConnectionMethod,
TGitHubRadarConnection,
useGetAppConnectionOption
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TGitHubRadarConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.GitHubRadar),
method: z.nativeEnum(GitHubRadarConnectionMethod)
});
type FormData = z.infer<typeof formSchema>;
export const GitHubRadarConnectionForm = ({ appConnection }: Props) => {
const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false);
const {
option: { appClientSlug },
isLoading
} = useGetAppConnectionOption(AppConnection.GitHubRadar);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.GitHubRadar,
method: GitHubRadarConnectionMethod.App
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"githubRadarConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) {
case GitHubRadarConnectionMethod.App:
window.location.assign(
`https://github.com/apps/${appClientSlug}/installations/new?state=${state}`
);
break;
default:
throw new Error(
`Unhandled GitHub Radar Connection method: ${(formData as FormData).method}`
);
}
};
let isMissingConfig: boolean;
switch (selectedMethod) {
case GitHubRadarConnectionMethod.App:
isMissingConfig = !appClientSlug;
break;
default:
throw new Error(`Unhandled GitHub Radar Connection method: ${selectedMethod}`);
}
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.GitHubRadar].name
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See Docs to configure GitHub Radar ${methodDetails.name} Connections.`
}`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubRadarConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -15,11 +15,19 @@ export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => {
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${appDetails.name} logo`}
src={`/images/integrations/${appDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div className="relative">
<img
alt={`${appDetails.name} logo`}
src={`/images/integrations/${appDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
{appDetails.icon && (
<FontAwesomeIcon
icon={appDetails.icon}
className="absolute bottom-1 right-1 text-primary-700"
/>
)}
</div>
<div>
<div className="flex items-center text-mineshaft-300">
{appDetails.name}
@@ -1,4 +1,4 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
@@ -23,69 +23,81 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
}
return (
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => {
const { image, name, size = 50 } = APP_CONNECTION_MAP[option.app];
<>
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => {
const { image, name, size = 50, icon } = APP_CONNECTION_MAP[option.app];
return (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4">
<FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
return (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
{icon && (
<FontAwesomeIcon
className="-right- absolute -bottom-1.5 -right-1.5 text-primary-700"
size="xl"
icon={icon}
/>
)}
</div>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-1">
<div className="max-w-xs text-center text-sm font-medium text-mineshaft-400 duration-200">
Don&#39;t see the connection you&#39;re looking for?
</div>
</Tooltip>
</div>
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<FontAwesomeIcon className="text-mineshaft-400" size="xs" icon={faInfoCircle} />
</Tooltip>
</div>
</>
);
};
@@ -65,6 +65,8 @@ export const AppConnectionRow = ({
const methodDetails = getAppConnectionMethodDetails(method);
const connectionDetails = APP_CONNECTION_MAP[app];
return (
<Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
@@ -72,12 +74,21 @@ export const AppConnectionRow = ({
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="mr-0.5 w-5"
/>
<span className="hidden lg:inline">{APP_CONNECTION_MAP[app].name}</span>
<div className="relative">
<img
alt={`${connectionDetails.name} integration`}
src={`/images/integrations/${connectionDetails.image}`}
className="mr-0.5 w-5"
/>
{connectionDetails.icon && (
<FontAwesomeIcon
icon={connectionDetails.icon}
size="xs"
className="absolute -bottom-0.5 -right-0.5 text-primary-700"
/>
)}
</div>
<span className="hidden lg:inline">{connectionDetails.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
@@ -14,6 +14,7 @@ import {
TAzureClientSecretsConnection,
TAzureKeyVaultConnection,
TGitHubConnection,
TGitHubRadarConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
@@ -26,6 +27,9 @@ type BaseFormData = {
type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" | "description">;
type GithubRadarFormData = BaseFormData &
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
type AzureKeyVaultFormData = BaseFormData &
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
Pick<TAzureKeyVaultConnection["credentials"], "tenantId">;
@@ -40,6 +44,7 @@ type AzureClientSecretsFormData = BaseFormData &
type FormDataMap = {
[AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub };
[AppConnection.GitHubRadar]: GithubRadarFormData & { app: AppConnection.GitHubRadar };
[AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault };
[AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & {
app: AppConnection.AzureAppConfiguration;
@@ -51,6 +56,7 @@ type FormDataMap = {
const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = {
[AppConnection.GitHub]: "githubConnectionFormData",
[AppConnection.GitHubRadar]: "githubRadarConnectionFormData",
[AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData",
[AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData",
[AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData"
@@ -318,6 +324,54 @@ export const OAuthCallbackPage = () => {
};
}, []);
const handleGithubRadar = useCallback(async () => {
const formData = getFormData(AppConnection.GitHubRadar);
if (formData === null) return null;
clearState(AppConnection.GitHubRadar);
const { connectionId, name, description, returnUrl } = formData;
try {
if (connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.GitHubRadar,
connectionId,
credentials: {
code: code as string,
installationId: installationId as string
}
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.GitHubRadar,
name,
description,
method: GitHubConnectionMethod.App,
credentials: {
code: code as string,
installationId: installationId as string
}
});
}
} catch (e: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} GitHub Radar Connection`,
text: e.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/app-connections"
});
}
return {
connectionId,
returnUrl,
appConnectionName: formData.app
};
}, []);
// Ensure that the localstorage is ready for use, to avoid the form data being malformed
useEffect(() => {
if (!isReady) {
@@ -334,6 +388,8 @@ export const OAuthCallbackPage = () => {
if (appConnection === AppConnection.GitHub) {
data = await handleGithub();
} else if (appConnection === AppConnection.GitHubRadar) {
data = await handleGithubRadar();
} else if (appConnection === AppConnection.AzureKeyVault) {
data = await handleAzureKeyVault();
} else if (appConnection === AppConnection.AzureAppConfiguration) {
@@ -24,6 +24,8 @@ const formatDescription = (type: ProjectType) => {
return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices.";
if (type === ProjectType.KMS)
return "Centralize the management of keys for cryptographic operations, such as encryption and decryption.";
if (type === ProjectType.SecretScanning)
return "Connect and monitor data sources to prevent secret leaks.";
return "Infisical SSH lets you issue SSH credentials to users for short-lived, secure SSH access to infrastructure.";
};
@@ -0,0 +1,7 @@
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProductOverviewPage } from "../SecretManagerOverviewPage/SecretManagerOverviewPage";
export const SecretScanningOverviewPage = () => (
<ProductOverviewPage type={ProjectType.SecretScanning} />
);
@@ -0,0 +1,23 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { SecretScanningOverviewPage } from "./SecretScanningOverviewPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning/overview"
)({
component: SecretScanningOverviewPage,
context: () => ({
breadcrumbs: [
{
label: "Products",
icon: () => <FontAwesomeIcon icon={faHome} />
},
{
label: "Secret Scanning",
link: linkOptions({ to: "/organization/secret-scanning/overview" })
}
]
})
});
@@ -1,221 +0,0 @@
import { useEffect } from "react";
import { Helmet } from "react-helmet";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, NoticeBanner, PageHeader, Pagination } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useServerConfig
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePagination, usePopUp } from "@app/hooks";
import {
useCreateNewInstallationSession,
useGetSecretScanningInstallationStatus,
useGetSecretScanningRisks,
useLinkGitAppInstallationWithOrg
} from "@app/hooks/api/secretScanning";
import { SecretScanningOrderBy } from "@app/hooks/api/secretScanning/types";
import { ExportSecretScansModal } from "./components/ExportSecretScansModal";
import { SecretScanningFilter } from "./components/SecretScanningFilters";
import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types";
import { SecretScanningLogsTable } from "./components";
const PER_PAGE_INIT = 25;
export const SecretScanningPage = withPermission(
() => {
const queryParams = useSearch({
from: ROUTE_PATHS.Organization.SecretScanning.id
});
const navigate = useNavigate();
const { control, watch } = useForm<SecretScanningFilterFormData>({
resolver: zodResolver(secretScanningFilterFormSchema),
defaultValues: {}
});
const { config } = useServerConfig();
const { currentOrg } = useOrganization();
const organizationId = currentOrg.id;
const { offset, limit, orderBy, setPage, perPage, page, setPerPage } = usePagination(
SecretScanningOrderBy.CreatedAt,
{ initPerPage: PER_PAGE_INIT }
);
const repositoryNames = watch("repositoryNames");
const resolvedStatus = watch("resolved");
const { isPending, data: risksData } = useGetSecretScanningRisks(
organizationId,
{
offset,
limit,
orderBy
},
{
repositoryNames:
repositoryNames?.length > 0 ? repositoryNames.map((repo) => repo.name) : undefined,
resolvedStatus
}
);
const { mutateAsync: linkGitAppInstallationWithOrganization } =
useLinkGitAppInstallationWithOrg();
const { mutateAsync: createNewIntegrationSession } = useCreateNewInstallationSession();
const {
data: installationStatus,
isPending: isSecretScanningInstatllationStatusLoading,
refetch: refetchSecretScanningInstallationStatus
} = useGetSecretScanningInstallationStatus(organizationId);
const integrationEnabled =
!isSecretScanningInstatllationStatusLoading && installationStatus?.appInstallationCompleted;
const { handlePopUpToggle, popUp } = usePopUp(["exportSecretScans"]);
useEffect(() => {
const linkInstallation = async () => {
if (queryParams.state && queryParams.installation_id) {
try {
const isLinked = await linkGitAppInstallationWithOrganization({
installationId: String(queryParams.installation_id),
sessionId: String(queryParams.state)
});
if (isLinked) {
await navigate({
to: "/organization/secret-scanning"
});
refetchSecretScanningInstallationStatus();
}
console.log("installation verification complete");
} catch (e) {
console.log("app installation is stale, start new session", e);
}
}
};
linkInstallation();
}, [queryParams.state, queryParams.installation_id]);
const generateNewIntegrationSession = async () => {
const session = await createNewIntegrationSession({ organizationId });
window.location.href = `https://github.com/apps/${session.gitAppSlug}/installations/new?state=${session.sessionId}`;
};
return (
<div className="py-2">
<Helmet>
<title>Secret scanning</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader
title="Secret Scanning"
description="Automatically monitor your GitHub activity and prevent secret leaks"
/>
{config.isSecretScanningDisabled && (
<NoticeBanner title="Secret scanning is in maintenance" className="mb-4">
We are working on improving the performance of secret scanning due to increased
usage.
</NoticeBanner>
)}
<div className="relative mb-6 flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6">
<div className="flex flex-col items-start">
<div className="mb-1 flex flex-row">
Secret Scanning Status:{" "}
{integrationEnabled ? (
<p className="ml-1.5 font-semibold text-green">Enabled</p>
) : (
<p className="ml-1.5 font-semibold text-red">Not enabled</p>
)}
</div>
<div>
{integrationEnabled ? (
<p className="text-mineshaft-300">
Your GitHub organization is connected to Infisical, and is being continuously
monitored for secret leaks.
</p>
) : (
<p className="text-mineshaft-300">
Connect your GitHub organization to Infisical.
</p>
)}
</div>
</div>
{integrationEnabled ? (
<div>
<div className="absolute right-[2.5rem] top-[2.5rem] flex h-6 w-6 animate-ping items-center justify-center rounded-full bg-green" />
<div className="absolute right-[2.63rem] top-[2.63rem] flex h-5 w-5 animate-ping items-center justify-center rounded-full bg-green" />
<div className="absolute right-[2.82rem] top-[2.82rem] flex h-3.5 w-3.5 animate-ping items-center justify-center rounded-full bg-green" />
</div>
) : (
<div className="flex h-[3.25rem] items-center">
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.SecretScanning}
>
{(isAllowed) => (
<Button
variant="solid"
colorSchema="primary"
onClick={generateNewIntegrationSession}
className="h-min py-2"
isDisabled={!isAllowed}
>
Integrate with GitHub
</Button>
)}
</OrgPermissionCan>
</div>
)}
</div>
<div className="mt-8 space-y-3">
{integrationEnabled && (
<div className="flex w-full items-center justify-end">
<SecretScanningFilter
repositories={risksData?.repos || []}
handlePopUpToggle={handlePopUpToggle}
control={control}
/>
</div>
)}
<SecretScanningLogsTable gitRisks={risksData?.risks} isPending={isPending} />
{!isPending &&
risksData?.totalCount !== undefined &&
risksData.totalCount >= PER_PAGE_INIT && (
<Pagination
className="rounded-md"
count={risksData.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div>
</div>
</div>
<ExportSecretScansModal
repositories={risksData?.repos || []}
handlePopUpToggle={handlePopUpToggle}
popUp={popUp}
/>
</div>
);
},
{
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.SecretScanning
}
);
@@ -1,200 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import FileSaver from "file-saver";
import { z } from "zod";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useExportSecretScanningRisks } from "@app/hooks/api/secretScanning";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { convertJsonToCsv } from "@app/lib/fn/csv";
type Props = {
popUp: UsePopUpState<["exportSecretScans"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["exportSecretScans"]>,
state?: boolean
) => void;
repositories: string[];
};
enum ExportFormat {
Json = "json",
Csv = "csv"
}
const formSchema = z.object({
githubRepository: z.string().trim(),
status: z.nativeEnum(SecretScanningResolvedStatus),
exportFormat: z.nativeEnum(ExportFormat)
});
type TFormSchema = z.infer<typeof formSchema>;
export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, repositories }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync } = useExportSecretScanningRisks();
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
exportFormat: ExportFormat.Json,
status: SecretScanningResolvedStatus.All,
githubRepository: "all"
}
});
const onFormSubmit = async (data: TFormSchema) => {
const gitRisks = await mutateAsync({
orgId: currentOrg.id,
filter: {
repositoryNames: data.githubRepository !== "all" ? [data.githubRepository] : undefined,
resolvedStatus: data.status
}
});
const filteredRisks = gitRisks
.filter((risk) =>
// eslint-disable-next-line no-nested-ternary
data.status === SecretScanningResolvedStatus.All
? true
: data.status === SecretScanningResolvedStatus.Resolved
? risk.isResolved
: !risk.isResolved
)
.filter((risk) => {
if (data.githubRepository === "all") return true;
return risk.repositoryFullName === data.githubRepository;
});
const formattedRisks = filteredRisks.map((risk) => {
return {
repository: risk.repositoryFullName,
fileName: risk.file,
isResolved: risk.isResolved ? "Resolved" : "Needs Attention",
status: risk.status || "UNRESOLVED",
entropy: risk.entropy,
secretType: risk.ruleID,
exposedSecretLink: `https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`,
commitAuthor: risk.author,
commitAuthorEmail: risk.email,
commitDate: risk.createdAt,
commitMessage: risk.message,
commitFingerprint: risk.fingerprint
};
});
const fileName = `infisical-secret-scans-${new Date().toISOString()}`;
if (data.exportFormat === ExportFormat.Csv) {
const csvBlob = convertJsonToCsv(formattedRisks);
FileSaver.saveAs(csvBlob, `${fileName}.csv`);
} else if (data.exportFormat === ExportFormat.Json) {
const json = JSON.stringify(formattedRisks, null, 2);
const blob = new Blob([json], { type: "application/json" });
FileSaver.saveAs(blob, `${fileName}.json`);
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Modal
isOpen={popUp?.exportSecretScans?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("exportSecretScans", isOpen);
}}
>
<ModalContent
title="Export Secret Scans"
footerContent={[
<Button
onClick={() => handleSubmit(onFormSubmit)()}
isLoading={isSubmitting}
type="submit"
colorSchema="primary"
>
Export secret scans
</Button>,
<Button
key="keep-old-btn"
className="ml-4"
onClick={() => handlePopUpToggle("exportSecretScans", false)}
variant="outline_bg"
isDisabled={isSubmitting}
>
Cancel
</Button>
]}
>
<Controller
control={control}
name="exportFormat"
render={({ field: { value, onChange } }) => (
<FormControl label="Export Format">
<Select
defaultValue={ExportFormat.Json}
value={value}
onValueChange={onChange}
className="w-full"
>
{Object.values(ExportFormat).map((format) => (
<SelectItem key={format} value={format}>
{format.toUpperCase()}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
render={({ field: { value, onChange } }) => (
<FormControl label="Risk Status">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
{Object.values(SecretScanningResolvedStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="githubRepository"
render={({ field: { value, onChange } }) => (
<FormControl label="GitHub Repository">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
<SelectItem value="all">All Repositories</SelectItem>
{repositories.map((repoName) => (
<SelectItem key={repoName} value={repoName}>
{repoName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</ModalContent>
</Modal>
</form>
);
};
@@ -1,46 +0,0 @@
import { useState } from "react";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateRiskStatus } from "@app/hooks/api/secretScanning";
import { RiskStatus } from "@app/hooks/api/secretScanning/types";
export const RiskStatusSelection = ({
riskId,
currentSelection
}: {
riskId: any;
currentSelection: any;
}) => {
const [selectedRiskStatus, setSelectedRiskStatus] = useState(currentSelection);
const { mutate: updateRiskStatus } = useUpdateRiskStatus();
const { currentOrg } = useOrganization();
const organizationId = currentOrg.id;
return (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.SecretScanning}>
{(isAllowed) => (
<select
disabled={!isAllowed}
value={selectedRiskStatus}
onChange={(e) => {
setSelectedRiskStatus(e.target.value);
updateRiskStatus({
organizationId,
status: e.target.value as RiskStatus,
riskId
});
}}
className="block w-full rounded-md px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
>
<option>Unresolved</option>
<option value={RiskStatus.RESOLVED_FALSE_POSITIVE}>
This is a false positive, resolved
</option>
<option value={RiskStatus.RESOLVED_REVOKED}>I have rotated the secret, resolved</option>
<option value={RiskStatus.RESOLVED_NOT_REVOKED}>No rotate needed, resolved</option>
</select>
)}
</OrgPermissionCan>
);
};
@@ -1,89 +0,0 @@
import { Control, Controller } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button, FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { SecretScanningFilterFormData } from "./types";
type Props = {
control: Control<SecretScanningFilterFormData>;
repositories: string[];
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["exportSecretScans"]>,
state?: boolean
) => void;
};
export const SecretScanningFilter = ({ repositories, control, handlePopUpToggle }: Props) => {
return (
<div className={twMerge("flex w-full flex-wrap items-center justify-between bg-bunker-800")}>
<div className="flex items-center -space-x-8">
<Controller
control={control}
name="repositoryNames"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Repository"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-96"
>
<FilterableSelect
value={value}
isClearable
isMulti
onChange={onChange}
placeholder="Select a repository..."
options={repositories.map((repository) => ({
name: repository
}))}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="resolved"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Status"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-44"
>
<Select
defaultValue={SecretScanningResolvedStatus.All}
placeholder={SecretScanningResolvedStatus.All}
value={value}
onValueChange={onChange}
className="w-full"
>
{Object.values(SecretScanningResolvedStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)} risks
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div>
<Button
className="mt-[0.45rem]"
onClick={() => handlePopUpToggle("exportSecretScans", true)}
variant="solid"
colorSchema="secondary"
>
Export
</Button>
</div>
</div>
);
};
@@ -1,91 +0,0 @@
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { formatDistance } from "date-fns";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { TSecretScanningGitRisks } from "@app/hooks/api/secretScanning/types";
import { RiskStatusSelection } from "./RiskStatusSelection";
type Props = {
gitRisks?: TSecretScanningGitRisks[];
isPending: boolean;
};
export const SecretScanningLogsTable = ({ gitRisks, isPending }: Props) => {
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="flex-1">Date</Th>
<Th className="flex-1">Secret Type</Th>
<Th className="flex-1">View Risk</Th>
<Th className="flex-1">Info</Th>
<Th className="flex-1">Status</Th>
<Th className="flex-1">Action</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isPending &&
gitRisks &&
gitRisks?.map((risk) => {
return (
<Tr key={risk.ruleID} className="h-10">
<Td>{formatDistance(new Date(risk.createdAt), new Date())}</Td>
<Td>{risk.ruleID}</Td>
<Td>
<a
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`}
target="_blank"
className="text-red-500"
rel="noreferrer"
>
View Exposed Secret
</a>
</Td>
<Td>
<div className="font-bold">
<a href={`https://github.com/${risk.repositoryFullName}`}>
{risk.repositoryFullName}
</a>
</div>
<div className="text-xs">
<span>{risk.file}</span>
<br />
<br />
<span className="font-bold">{risk.author}</span>
<br />
<span>{risk.email}</span>
</div>
</Td>
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
<Td>
<RiskStatusSelection riskId={risk.id} currentSelection={risk.status} />
</Td>
</Tr>
);
})}
{isPending && <TableSkeleton columns={7} innerKey="gitRisks" />}
{!isPending && gitRisks && gitRisks?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState title="No risks detected." icon={faCheck} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -1 +0,0 @@
export { SecretScanningLogsTable } from "./SecretScanningLogsTable";
@@ -1,22 +0,0 @@
import { z } from "zod";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
export const secretScanningFilterFormSchema = z.object({
repositoryNames: z.array(z.object({ name: z.string() })),
resolved: z
.nativeEnum(SecretScanningResolvedStatus)
.default(SecretScanningResolvedStatus.All)
.optional()
});
export type SecretScanningFilterFormData = z.infer<typeof secretScanningFilterFormSchema>;
export type SetValueType = (
name: keyof SecretScanningFilterFormData,
value: any,
options?: {
shouldValidate?: boolean;
shouldDirty?: boolean;
}
) => void;
@@ -1,39 +0,0 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SecretScanningPage } from "./SecretScanningPage";
const SecretScanningQueryParams = z.object({
state: z.string().catch(""),
installation_id: z.coerce.string().optional()
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning"
)({
validateSearch: zodValidator(SecretScanningQueryParams),
component: SecretScanningPage,
search: {
middlewares: [
stripSearchParams({
installation_id: "",
state: ""
})
]
},
context: () => ({
breadcrumbs: [
{
label: "Home",
icon: () => <FontAwesomeIcon icon={faHome} />,
link: linkOptions({ to: "/" })
},
{
label: "Secret Scanning"
}
]
})
});
@@ -0,0 +1,20 @@
import { Helmet } from "react-helmet";
import { ProjectSettings } from "@app/components/projects/ProjectSettings";
import { PageHeader } from "@app/components/v2";
export const SecretScanningSettingsPage = () => {
return (
<>
<Helmet>
<title>Secret Scanning Settings</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title="Secret Scanning Settings" />
<ProjectSettings />
</div>
</div>
</>
);
};
@@ -0,0 +1,26 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { SecretScanningSettingsPage } from "./SecretScanningSettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning/settings"
)({
component: SecretScanningSettingsPage,
context: () => ({
breadcrumbs: [
{
label: "Products",
icon: () => <FontAwesomeIcon icon={faHome} />
},
{
label: "Secret Scanning",
link: linkOptions({ to: "/organization/secret-scanning/settings" })
},
{
label: "Settings"
}
]
})
});
@@ -0,0 +1,38 @@
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Access Control",
link: linkOptions({
to: "/secret-scanning/$projectId/access-management",
params: {
projectId: params.projectId
}
})
}
]
};
}
});
@@ -0,0 +1,28 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Access Control",
link: linkOptions({
to: "/secret-scanning/$projectId/access-management",
params: {
projectId: params.projectId
}
})
},
{
label: "Identities"
}
]
};
}
});
@@ -0,0 +1,28 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Access Control",
link: linkOptions({
to: "/secret-scanning/$projectId/access-management",
params: {
projectId: params.projectId
}
})
},
{
label: "Users"
}
]
};
}
});
@@ -20,6 +20,8 @@ import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningDataSourceActions,
ProjectPermissionSecretScanningFindingActions,
ProjectPermissionSecretSyncActions,
ProjectPermissionSshHostActions,
TPermissionCondition,
@@ -90,6 +92,21 @@ const SecretRotationPolicyActionSchema = z.object({
[ProjectPermissionSecretRotationActions.RotateSecrets]: z.boolean().optional()
});
const SecretScanningDataSourcePolicyActionSchema = z.object({
[ProjectPermissionSecretScanningDataSourceActions.Read]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.Create]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.Edit]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.Delete]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.ReadScans]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.ReadResources]: z.boolean().optional(),
[ProjectPermissionSecretScanningDataSourceActions.TriggerScans]: z.boolean().optional()
});
const SecretScanningFindingPolicyActionSchema = z.object({
[ProjectPermissionSecretScanningFindingActions.Read]: z.boolean().optional(),
[ProjectPermissionSecretScanningFindingActions.Resolve]: z.boolean().optional()
});
const KmipPolicyActionSchema = z.object({
[ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(),
[ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(),
@@ -274,7 +291,11 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([])
[ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretScanningDataSources]:
SecretScanningDataSourcePolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretScanningFindings]:
SecretScanningFindingPolicyActionSchema.array().default([])
})
.partial()
.optional()
@@ -1250,6 +1271,52 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
value: ProjectPermissionKmipActions.GenerateClientCertificates
}
]
},
[ProjectPermissionSub.SecretScanningDataSources]: {
title: "Secret Scanning Data Sources",
actions: [
{
label: "Read Data Sources",
value: ProjectPermissionSecretScanningDataSourceActions.Read
},
{
label: "Create Data Sources",
value: ProjectPermissionSecretScanningDataSourceActions.Create
},
{
label: "Modify Data Sources",
value: ProjectPermissionSecretScanningDataSourceActions.Edit
},
{
label: "Delete Data Sources",
value: ProjectPermissionSecretScanningDataSourceActions.Delete
},
{
label: "Read Resources",
value: ProjectPermissionSecretScanningDataSourceActions.ReadResources
},
{
label: "Read Scans",
value: ProjectPermissionSecretScanningDataSourceActions.ReadScans
},
{
label: "Trigger Scans",
value: ProjectPermissionSecretScanningDataSourceActions.TriggerScans
}
]
},
[ProjectPermissionSub.SecretScanningFindings]: {
title: "Secret Scanning Findings",
actions: [
{
label: "Read Findings",
value: ProjectPermissionSecretScanningFindingActions.Read
},
{
label: "Resolve Finding Status",
value: ProjectPermissionSecretScanningFindingActions.Resolve
}
]
}
};
@@ -1303,6 +1370,11 @@ const SshPermissionSubjects = (enabled = false) => ({
[ProjectPermissionSub.SshHostGroups]: enabled
});
const SecretScanningSubject = (enabled = false) => ({
[ProjectPermissionSub.SecretScanningDataSources]: enabled,
[ProjectPermissionSub.SecretScanningFindings]: enabled
});
// scott: this structure ensures we don't forget to add project permissions to their relevant project type
export const ProjectTypePermissionSubjects: Record<
ProjectType,
@@ -1313,27 +1385,39 @@ export const ProjectTypePermissionSubjects: Record<
...SecretsManagerPermissionSubjects(true),
...KmsPermissionSubjects(),
...CertificateManagerPermissionSubjects(),
...SshPermissionSubjects()
...SshPermissionSubjects(),
...SecretScanningSubject()
},
[ProjectType.KMS]: {
...SharedPermissionSubjects,
...KmsPermissionSubjects(true),
...SecretsManagerPermissionSubjects(),
...CertificateManagerPermissionSubjects(),
...SshPermissionSubjects()
...SshPermissionSubjects(),
...SecretScanningSubject()
},
[ProjectType.CertificateManager]: {
...SharedPermissionSubjects,
...CertificateManagerPermissionSubjects(true),
...KmsPermissionSubjects(),
...SecretsManagerPermissionSubjects(),
...SshPermissionSubjects()
...SshPermissionSubjects(),
...SecretScanningSubject()
},
[ProjectType.SSH]: {
...SharedPermissionSubjects,
...SshPermissionSubjects(true),
...CertificateManagerPermissionSubjects(),
...KmsPermissionSubjects(),
...SecretsManagerPermissionSubjects(),
...SecretScanningSubject()
},
[ProjectType.SecretScanning]: {
...SharedPermissionSubjects,
...SecretScanningSubject(true),
...SshPermissionSubjects(),
...CertificateManagerPermissionSubjects(),
...KmsPermissionSubjects(),
...SecretsManagerPermissionSubjects()
}
};
@@ -1546,6 +1630,43 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
},
projectManagerTemplate()
],
[ProjectType.SecretScanning]: [
{
id: "scanning-viewer",
name: "Secret Scanning Viewing Policies",
description: "Grants read access to data sources and findings",
permissions: [
{
subject: ProjectPermissionSub.SecretScanningDataSources,
actions: [
ProjectPermissionSecretScanningDataSourceActions.Read,
ProjectPermissionSecretScanningDataSourceActions.ReadResources,
ProjectPermissionSecretScanningDataSourceActions.ReadScans
]
},
{
subject: ProjectPermissionSub.SecretScanningFindings,
actions: [ProjectPermissionSecretScanningFindingActions.Read]
}
]
},
{
id: "scanning-editor",
name: "Secret Scanning Editing Policies",
description: "Grants read and edit access to data sources and findings",
permissions: [
{
subject: ProjectPermissionSub.SecretScanningDataSources,
actions: Object.values(ProjectPermissionSecretScanningDataSourceActions)
},
{
subject: ProjectPermissionSub.SecretScanningFindings,
actions: Object.values(ProjectPermissionSecretScanningFindingActions)
}
]
},
projectManagerTemplate()
],
[ProjectType.SecretManager]: [
{
id: "secret-viewer",
@@ -0,0 +1,28 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Access Control",
link: linkOptions({
to: "/secret-scanning/$projectId/access-management",
params: {
projectId: params.projectId
}
})
},
{
label: "Roles"
}
]
};
}
});
@@ -252,7 +252,7 @@ export const SecretSyncRow = ({
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
@@ -0,0 +1,118 @@
import { Helmet } from "react-helmet";
import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import {
SecretScanningDataSource,
useGetSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import {
SecretScanningDataSourceSection,
SecretScanningResourceSection,
SecretScanningScanSection
} from "./components";
const PageContent = () => {
const navigate = useNavigate();
const { type, dataSourceId, projectId } = useParams({
from: ROUTE_PATHS.SecretScanning.DataSourceByIdPage.id,
select: (params) => ({
...params,
type: params.type as SecretScanningDataSource
})
});
const { data: dataSource, isPending } = useGetSecretScanningDataSource({ type, dataSourceId });
if (isPending) {
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader />
</div>
);
}
const details = SECRET_SCANNING_DATA_SOURCE_MAP[type];
if (!dataSource) {
return (
<div className="flex h-full w-full items-center justify-center px-20">
<EmptyState
className="max-w-2xl rounded-md text-center"
icon={faBan}
title={`Could not find ${details.name ?? "Secret Scanning"} Data Source with ID ${dataSourceId}`}
/>
</div>
);
}
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: "/secret-scanning/$projectId/data-sources",
params: {
projectId
}
});
}}
>
Data Sources
</Button>
<div className="mb-6 flex w-full items-center gap-3">
<img
alt={`${details.name} data source`}
src={`/images/integrations/${details.image}`}
className="ml-1 mt-3 w-14"
/>
<div>
<p className="text-3xl font-semibold text-white">{dataSource.name}</p>
<p className="leading-3 text-bunker-300">{details.name} Data Source</p>
</div>
{/* <SecretSyncActionTriggers dataSource={dataSource} /> */}
</div>
<div className="flex justify-center">
<div className="mr-4 flex w-72 flex-col gap-4">
<SecretScanningDataSourceSection dataSource={dataSource} />
</div>
<div className="flex flex-1 flex-col gap-4">
<SecretScanningResourceSection dataSource={dataSource} />
<SecretScanningScanSection dataSource={dataSource} />
</div>
</div>
</div>
</div>
);
};
export const SecretScanningDataSourceByIdPage = () => {
return (
<>
<Helmet>
<title>Secret Scanning Data Source | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionSecretScanningDataSourceActions.Read}
a={ProjectPermissionSub.SecretScanningDataSources}
>
<PageContent />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,21 @@
import {
SecretScanningDataSource,
TSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import { GitHubDataSourceConfigDisplay } from "./GitHubDataSourceConfigDisplay";
type Props = {
dataSource: TSecretScanningDataSource;
};
export const DataSourceConfigDisplay = ({ dataSource }: Props) => {
switch (dataSource.type) {
case SecretScanningDataSource.GitHub:
return <GitHubDataSourceConfigDisplay dataSource={dataSource} />;
default:
throw new Error(
`Unhandled dataSource type ${(dataSource as TSecretScanningDataSource).type}`
);
}
};
@@ -0,0 +1,18 @@
import { GenericFieldLabel } from "@app/components/v2";
import { TGitHubDataSource } from "@app/hooks/api/secretScanningV2/types/github-data-source";
type Props = {
dataSource: TGitHubDataSource;
};
export const GitHubDataSourceConfigDisplay = ({ dataSource }: Props) => {
const {
config: { includeRepos }
} = dataSource;
return (
<GenericFieldLabel label="Scan Repositories">
{includeRepos.includes("*") ? "All" : includeRepos.join(", ")}
</GenericFieldLabel>
);
};
@@ -0,0 +1,79 @@
import { faBan, faCheck, faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EditSecretScanningDataSourceModal } from "@app/components/secret-scanning";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Badge, IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { DataSourceConfigDisplay } from "@app/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay";
type Props = {
dataSource: TSecretScanningDataSource;
};
export const SecretScanningDataSourceSection = ({ dataSource }: Props) => {
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editDataSource"] as const);
const { name, description, connection, isAutoScanEnabled } = dataSource;
return (
<>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Details</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit data source"
onClick={() => handlePopUpOpen("editDataSource")}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
{connection && (
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
)}
<DataSourceConfigDisplay dataSource={dataSource} />
<GenericFieldLabel label="Auto-Scan">
{isAutoScanEnabled ? (
<Badge
variant="success"
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faCheck} />
<span>Enabled</span>
</Badge>
) : (
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
<span>Disabled</span>
</Badge>
)}
</GenericFieldLabel>
</div>
</div>
</div>
<EditSecretScanningDataSourceModal
dataSource={dataSource}
onOpenChange={(isOpen) => handlePopUpToggle("editDataSource", isOpen)}
isOpen={popUp.editDataSource.isOpen}
/>
</>
);
};
@@ -0,0 +1,236 @@
import { useCallback } from "react";
import {
faCheck,
faCopy,
faEllipsisV,
faExpand,
faInfoCircle,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionSecretScanningDataSourceActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
import { useToggle } from "@app/hooks";
import {
SecretScanningFindingStatus,
SecretScanningScanStatus,
TSecretScanningDataSource,
TSecretScanningResourceWithDetails,
useTriggerSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
type Props = {
resource: TSecretScanningResourceWithDetails;
dataSource: TSecretScanningDataSource;
};
export const SecretScanningResourceRow = ({ resource, dataSource }: Props) => {
const { id, name, lastScannedAt, lastScanStatus, unresolvedFindings, lastScanStatusMessage } =
resource;
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
const navigate = useNavigate();
const handleTriggerScan = async () => {
try {
await triggerDataSourceScan.mutateAsync({
dataSourceId: dataSource.id,
type: dataSource.type,
projectId: dataSource.projectId,
resourceId: id
});
createNotification({
text: `Successfully triggered scan for ${name}`,
type: "success"
});
} catch {
createNotification({
text: `Failed to trigger scan for ${name}`,
type: "error"
});
}
};
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(
(idToCopy: string) => {
setIsIdCopied.on();
navigator.clipboard.writeText(idToCopy);
createNotification({
text: "Resource ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
},
[isIdCopied]
);
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
return (
<Tr
className={twMerge(
"group h-10 transition-colors duration-100 hover:bg-mineshaft-700",
lastScanStatus === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
)}
key={`resource-${id}`}
>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{name}</p>
</div>
</Td>
<Td>
{/* eslint-disable-next-line no-nested-ternary */}
{lastScanStatus?.match(/queued|scanning/) ? (
<Badge
variant="primary"
className="flex h-5 w-min animate-pulse items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"
>
<FontAwesomeIcon icon={faSearch} />
<span>Scanning For Leaks</span>
</Badge>
) : // eslint-disable-next-line no-nested-ternary
lastScannedAt ? (
// eslint-disable-next-line no-nested-ternary
unresolvedFindings ? (
<Badge
onClick={() =>
navigate({
to: "/secret-scanning/$projectId/findings",
params: {
projectId: dataSource.projectId
},
search: {
search: name,
status: SecretScanningFindingStatus.Unresolved
}
})
}
variant="primary"
className="flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faWarning} />
<span>
{unresolvedFindings} Secret{unresolvedFindings > 1 ? "s" : ""} Detected
</span>
</Badge>
) : lastScanStatus === SecretScanningScanStatus.Failed ? (
<span className="text-mineshaft-400">No findings</span>
) : (
<Badge
variant="success"
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faCheck} />
No Secrets Detected
</Badge>
)
) : (
<span className="text-mineshaft-400">No findings</span>
)}
</Td>
<Td className="whitespace-nowrap">
{/* eslint-disable-next-line no-nested-ternary */}
{lastScanStatus?.match(/queued|scanning|failed/) ? (
<SecretScanningScanStatusBadge
status={lastScanStatus}
statusMessage={lastScanStatusMessage}
scannedAt={lastScannedAt}
/>
) : lastScannedAt ? (
formatDistance(new Date(lastScannedAt), new Date(), { addSuffix: true })
) : (
<span className="text-mineshaft-400">No scans</span>
)}
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId(id);
}}
>
Copy Resource ID
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faExpand} />}
onClick={(e) => {
e.stopPropagation();
handleTriggerScan();
}}
>
<Tooltip
position="left"
sideOffset={42}
content={`Manually trigger a scan for this ${resourceDetails.singularNoun}.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span> Trigger Scan</span>
<FontAwesomeIcon
className="text-bunker-300"
size="sm"
icon={faInfoCircle}
/>
</div>
</Tooltip>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,79 @@
import { faExpand } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import {
RESOURCE_DESCRIPTION_HELPER,
SECRET_SCANNING_DATA_SOURCE_MAP
} from "@app/helpers/secretScanningV2";
import {
TSecretScanningDataSource,
useTriggerSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningResourcesTable } from "./SecretScanningResourceTable";
type Props = {
dataSource: TSecretScanningDataSource;
};
export const SecretScanningResourceSection = ({ dataSource }: Props) => {
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
const handleTriggerScan = async () => {
try {
await triggerDataSourceScan.mutateAsync({
dataSourceId: dataSource.id,
type: dataSource.type,
projectId: dataSource.projectId
});
createNotification({
text: `Successfully triggered scan for ${dataSource.name}`,
type: "success"
});
} catch {
createNotification({
text: `Failed to trigger scan for ${dataSource.name}`,
type: "error"
});
}
};
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xl font-semibold text-mineshaft-100">{resourceDetails.pluralTitle}</p>
<p className="text-sm text-bunker-300">
{resourceDetails.pluralTitle} associated with this{" "}
{SECRET_SCANNING_DATA_SOURCE_MAP[dataSource.type].name} Data Source
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faExpand} />}
onClick={handleTriggerScan}
isDisabled={!isAllowed || triggerDataSourceScan.isPending}
isLoading={triggerDataSourceScan.isPending}
>
Scan {resourceDetails.pluralTitle}
</Button>
)}
</ProjectPermissionCan>
</div>
<SecretScanningResourcesTable dataSource={dataSource} />
</div>
);
};
@@ -0,0 +1,240 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faBan,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionSub, useProjectPermission } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
SecretScanningScanStatus,
TSecretScanningDataSource,
useListSecretScanningResources
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningResourceRow } from "./SecretScanningResourceRow";
enum ResourcesOrderBy {
Name = "name",
Findings = "findings",
LastScanned = "last-scanned"
}
type Props = {
dataSource: TSecretScanningDataSource;
};
export const SecretScanningResourcesTable = ({ dataSource }: Props) => {
const { permission } = useProjectPermission();
const canReadResources = permission.can(
ProjectPermissionSecretScanningDataSourceActions.ReadResources,
ProjectPermissionSub.SecretScanningDataSources
);
const { data: resources = [], isPending: isResourcesPending } = useListSecretScanningResources(
{ dataSourceId: dataSource.id, type: dataSource.type },
{
refetchInterval: 30000,
enabled: canReadResources
}
);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<ResourcesOrderBy>(ResourcesOrderBy.Name, { initPerPage: 10 });
const filteredResources = useMemo(
() =>
resources
.filter((resource) => {
const { name } = resource;
const searchValue = search.trim().toLowerCase();
return name.toLowerCase().includes(searchValue);
})
.sort((a, b) => {
const [resourceOne, resourceTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case ResourcesOrderBy.Findings:
if (
resourceOne.unresolvedFindings === 0 &&
resourceOne.lastScanStatus === SecretScanningScanStatus.Failed
)
return 1;
if (
resourceTwo.unresolvedFindings === 0 &&
resourceTwo.lastScanStatus === SecretScanningScanStatus.Failed
)
return -1;
return resourceTwo.unresolvedFindings - resourceOne.unresolvedFindings;
case ResourcesOrderBy.LastScanned:
return (
new Date(resourceTwo.lastScannedAt).getTime() -
new Date(resourceOne.lastScannedAt).getTime()
);
case ResourcesOrderBy.Name:
default:
return resourceOne.name.toLowerCase().localeCompare(resourceTwo.name.toLowerCase());
}
}),
[resources, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredResources.length,
offset,
setPage
});
const handleSort = (column: ResourcesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: ResourcesOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: ResourcesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder={`Search ${resourceDetails.pluralNoun}...`}
className="flex-1"
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/2">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(ResourcesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(ResourcesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-1/4">
<div className="flex items-center">
Findings
<IconButton
variant="plain"
className={getClassName(ResourcesOrderBy.Findings)}
ariaLabel="sort"
onClick={() => handleSort(ResourcesOrderBy.Findings)}
>
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.Findings)} />
</IconButton>
</div>
</Th>
<Th className="w-1/4 whitespace-nowrap">
<div className="flex items-center">
Last Scan
<IconButton
variant="plain"
className={getClassName(ResourcesOrderBy.LastScanned)}
ariaLabel="sort"
onClick={() => handleSort(ResourcesOrderBy.LastScanned)}
>
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.LastScanned)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{canReadResources && isResourcesPending && (
<TableSkeleton columns={5} innerKey="resource" />
)}
{filteredResources.slice(offset, perPage * page).map((resource) => (
<SecretScanningResourceRow
key={resource.id}
resource={resource}
dataSource={dataSource}
/>
))}
</TBody>
</Table>
{!canReadResources && (
<EmptyState
icon={faBan}
title={`You do not have permission to view data source ${resourceDetails.pluralNoun}`}
/>
)}
{Boolean(filteredResources.length) && (
<Pagination
count={filteredResources.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isResourcesPending && !filteredResources?.length && (
<EmptyState
title={
resources.length
? "No resources match search..."
: `This data source has no ${resourceDetails.pluralNoun} associated with it.`
}
icon={resources.length ? faSearch : undefined}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,164 @@
import { useCallback } from "react";
import { faCheck, faCopy, faEllipsisV, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
SecretScanningScanStatus,
SecretScanningScanType,
TSecretScanningScanWithDetails
} from "@app/hooks/api/secretScanningV2";
type Props = {
scan: TSecretScanningScanWithDetails;
};
export const SecretScanningScanRow = ({ scan }: Props) => {
const {
id,
resourceName,
createdAt,
status,
statusMessage,
unresolvedFindings,
resolvedFindings,
type
} = scan;
const { currentWorkspace } = useWorkspace();
const totalFindings = resolvedFindings + unresolvedFindings;
const navigate = useNavigate();
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(id);
createNotification({
text: "Scan ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
return (
<Tr
className={twMerge(
"group h-10 transition-colors duration-100 hover:bg-mineshaft-700",
status === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
)}
key={`scan-${id}`}
>
<Td>
<p>
{format(createdAt, "MMM dd yyyy")}{" "}
<span className="text-mineshaft-300">{format(createdAt, "h:mm aa")}</span>
</p>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex flex-col">
<p className="truncate">{resourceName}</p>
</div>
</Td>
<Td className="whitespace-nowrap">
{type === SecretScanningScanType.FullScan ? "Full scan" : "Diff scan"}
</Td>
<Td>
{
// eslint-disable-next-line no-nested-ternary
status?.match(/queued|scanning|failed/) ? (
<SecretScanningScanStatusBadge status={status} statusMessage={statusMessage} />
) : // eslint-disable-next-line no-nested-ternary
totalFindings ? (
<div className="flex flex-col">
<Badge
onClick={() =>
navigate({
to: "/secret-scanning/$projectId/findings",
params: {
projectId: currentWorkspace.id
},
search: {
search: `scanId:${id}`
}
})
}
variant={unresolvedFindings ? "primary" : undefined}
className={twMerge(
"flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap",
!unresolvedFindings && "bg-mineshaft-400/50 text-bunker-300"
)}
>
<FontAwesomeIcon icon={unresolvedFindings ? faWarning : faCheck} />
<span className="text-xs">
{totalFindings}{" "}
{unresolvedFindings
? `Secret ${totalFindings > 1 ? "s" : ""} Detected`
: "Leak Resolved"}
</span>
</Badge>
</div>
) : status === SecretScanningScanStatus.Failed ? (
<span className="text-mineshaft-400">No findings</span>
) : (
<Badge
variant="success"
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faCheck} />
No Secrets Detected
</Badge>
)
}
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId();
}}
>
Copy Scan ID
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,18 @@
import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { SecretScanningScanTable } from "./SecretScanningScanTable";
type Props = {
dataSource: TSecretScanningDataSource;
};
export const SecretScanningScanSection = ({ dataSource }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Scan History</p>
</div>
<SecretScanningScanTable dataSource={dataSource} />
</div>
);
};
@@ -0,0 +1,299 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faBan,
faCheckCircle,
faExpand,
faFilter,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionSub, useProjectPermission } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
SecretScanningScanStatus,
TSecretScanningDataSource,
useListSecretScanningScans
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningScanRow } from "./SecretScanningScanRow";
enum ScansOrderBy {
ResourceName = "resource-name",
Findings = "findings",
Timestamp = "timestamp",
Status = "status"
}
type Props = {
dataSource: TSecretScanningDataSource;
};
type ScanFilters = {
resourceNames: string[];
};
export const SecretScanningScanTable = ({ dataSource }: Props) => {
const { permission } = useProjectPermission();
const canReadScans = permission.can(
ProjectPermissionSecretScanningDataSourceActions.ReadScans,
ProjectPermissionSub.SecretScanningDataSources
);
const { data: scans = [], isPending: isScansPending } = useListSecretScanningScans(
{ dataSourceId: dataSource.id, type: dataSource.type },
{
refetchInterval: 30000,
enabled: canReadScans
}
);
const [filters, setFilters] = useState<ScanFilters>({
resourceNames: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<ScansOrderBy>(ScansOrderBy.Timestamp, { initPerPage: 10 });
const filteredResources = useMemo(
() =>
scans
.filter((scan) => {
const { resourceName } = scan;
if (filters.resourceNames.length && !filters.resourceNames.includes(resourceName))
return false;
const searchValue = search.trim().toLowerCase();
return resourceName.toLowerCase().includes(searchValue);
})
.sort((a, b) => {
const [scanOne, scanTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case ScansOrderBy.Findings:
if (
scanOne.unresolvedFindings === 0 &&
scanOne.status === SecretScanningScanStatus.Failed
)
return 1;
if (
scanTwo.unresolvedFindings === 0 &&
scanTwo.status === SecretScanningScanStatus.Failed
)
return -1;
return scanTwo.unresolvedFindings - scanOne.unresolvedFindings;
case ScansOrderBy.Timestamp:
return new Date(scanTwo.createdAt).getTime() - new Date(scanOne.createdAt).getTime();
case ScansOrderBy.ResourceName:
default:
return scanOne.resourceName
.toLowerCase()
.localeCompare(scanTwo.resourceName.toLowerCase());
}
}),
[scans, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredResources.length,
offset,
setPage
});
const handleSort = (column: ScansOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: ScansOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: ScansOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceNames.length);
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search scans..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter data sources"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>{resourceDetails.pluralTitle}</DropdownMenuLabel>
{scans.length ? (
[...new Set(scans.map(({ resourceName }) => resourceName))].map((resourceName) => {
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
resourceNames: prev.resourceNames.includes(resourceName)
? prev.resourceNames.filter((a) => a !== resourceName)
: [...prev.resourceNames, resourceName]
}));
}}
key={resourceName}
icon={
filters.resourceNames.includes(resourceName) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
{resourceName}
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Data Sources Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/4 whitespace-nowrap">
<div className="flex items-center">
Timestamp
<IconButton
variant="plain"
className={getClassName(ScansOrderBy.Timestamp)}
ariaLabel="sort"
onClick={() => handleSort(ScansOrderBy.Timestamp)}
>
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.Timestamp)} />
</IconButton>
</div>
</Th>
<Th className="w-1/2">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(ScansOrderBy.ResourceName)}
ariaLabel="sort"
onClick={() => handleSort(ScansOrderBy.ResourceName)}
>
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.ResourceName)} />
</IconButton>
</div>
</Th>
<Th className="w-20">Type</Th>
<Th className="w-1/5 whitespace-nowrap">
<div className="flex items-center">
Findings
<IconButton
variant="plain"
className={getClassName(ScansOrderBy.Findings)}
ariaLabel="sort"
onClick={() => handleSort(ScansOrderBy.Findings)}
>
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.Findings)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{canReadScans && isScansPending && <TableSkeleton columns={5} innerKey="resource" />}
{filteredResources.slice(offset, perPage * page).map((scan) => (
<SecretScanningScanRow key={scan.id} scan={scan} />
))}
</TBody>
</Table>
{!canReadScans && (
<EmptyState icon={faBan} title="You do not have permission to view data source scans" />
)}
{Boolean(filteredResources.length) && (
<Pagination
count={filteredResources.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isScansPending && !filteredResources?.length && (
<EmptyState
title={
scans.length
? "No scans match search..."
: "This data source has no scans associated with it."
}
icon={scans.length ? faSearch : faExpand}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,3 @@
export * from "./SecretScanningDataSourceSection";
export * from "./SecretScanningResourceSection";
export * from "./SecretScanningScanSection";
@@ -0,0 +1,26 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { SecretScanningDataSourceByIdPage } from "./SecretScanningDataSourceByIdPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/data-sources/$type/$dataSourceId"
)({
component: SecretScanningDataSourceByIdPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Data Sources",
link: linkOptions({
to: "/secret-scanning/$projectId/data-sources",
params
})
},
{
label: "Data Source"
}
]
};
}
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SecretScanningDataSourcesSection } from "./components";
export const SecretScanningDataSourcesPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Secret Scanning" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Data Sources"
description="Manage your Secret Scanning data sources."
/>
<SecretScanningDataSourcesSection />
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,327 @@
import { useCallback } from "react";
import {
faBan,
faCheck,
faCopy,
faEdit,
faEllipsisV,
faExpand,
faInfoCircle,
faSearch,
faToggleOff,
faToggleOn,
faTrash,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import {
RESOURCE_DESCRIPTION_HELPER,
SECRET_SCANNING_DATA_SOURCE_MAP
} from "@app/helpers/secretScanningV2";
import { useToggle } from "@app/hooks";
import {
SecretScanningFindingStatus,
SecretScanningScanStatus,
TSecretScanningDataSourceWithDetails
} from "@app/hooks/api/secretScanningV2";
type Props = {
dataSource: TSecretScanningDataSourceWithDetails;
onDelete: (dataSource: TSecretScanningDataSourceWithDetails) => void;
onTriggerScan: (dataSource: TSecretScanningDataSourceWithDetails) => void;
onToggleEnableAutoScan: (dataSource: TSecretScanningDataSourceWithDetails) => void;
onEdit: (dataSource: TSecretScanningDataSourceWithDetails) => void;
};
export const SecretScanningDataSourceRow = ({
dataSource,
onDelete,
onEdit,
onTriggerScan,
onToggleEnableAutoScan
}: Props) => {
const navigate = useNavigate();
const {
id,
name,
description,
isAutoScanEnabled,
projectId,
type,
unresolvedFindings,
lastScannedAt,
lastScanStatus,
lastScanStatusMessage
} = dataSource;
const sourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type];
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(id);
createNotification({
text: "Data Source ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type];
return (
<Tr
onClick={() =>
navigate({
to: ROUTE_PATHS.SecretScanning.DataSourceByIdPage.path,
params: {
dataSourceId: id,
type,
projectId
}
})
}
className={twMerge(
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
lastScanStatus === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
)}
key={`data-source-${id}`}
>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<img
alt={`${sourceDetails.name} Data Source`}
src={`/images/integrations/${sourceDetails.image}`}
className="w-5"
/>
<p className="ml-2 truncate">{sourceDetails.name}</p>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{name}</p>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="ml-1 text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td>
{/* eslint-disable-next-line no-nested-ternary */}
{lastScanStatus?.match(/queued|scanning/) ? (
<Badge
variant="primary"
className="flex h-5 w-min animate-pulse items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"
>
<FontAwesomeIcon icon={faSearch} />
<span>Scanning For Leaks</span>
</Badge>
) : // eslint-disable-next-line no-nested-ternary
lastScannedAt ? (
// eslint-disable-next-line no-nested-ternary
unresolvedFindings ? (
<Badge
variant="primary"
className="flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/secret-scanning/$projectId/findings",
params: {
projectId
},
search: {
search: name,
status: SecretScanningFindingStatus.Unresolved
}
});
}}
>
<FontAwesomeIcon icon={faWarning} />
<span>
{unresolvedFindings} Secret{unresolvedFindings > 1 ? "s" : ""} Detected
</span>
</Badge>
) : lastScanStatus === SecretScanningScanStatus.Failed ? (
<span className="text-mineshaft-400">No findings</span>
) : (
<Badge
variant="success"
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faCheck} />
No Secrets Detected
</Badge>
)
) : (
<span className="text-mineshaft-400">No findings</span>
)}
</Td>
<Td>
<div className="flex w-full items-center gap-1">
{/* eslint-disable-next-line no-nested-ternary */}
{lastScanStatus?.match(/queued|scanning|failed/) ? (
<SecretScanningScanStatusBadge
status={lastScanStatus}
statusMessage={lastScanStatusMessage}
scannedAt={lastScannedAt}
/>
) : lastScannedAt ? (
<span>{formatDistance(new Date(lastScannedAt), new Date(), { addSuffix: true })}</span>
) : (
<span className="text-mineshaft-400">No scans</span>
)}
{!isAutoScanEnabled && (
<Tooltip
className="text-xs"
content={`Auto-Scan is disabled. Scans will not be automatically triggered when a ${autoScanDescription.verb} occurs to ${autoScanDescription.pluralNoun} associated with this data source`}
>
<div className="ml-auto">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
<span>Auto-Scan Disabled</span>
</Badge>
</div>
</Tooltip>
)}
</div>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId();
}}
>
Copy Data Source ID
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faExpand} />}
onClick={(e) => {
e.stopPropagation();
onTriggerScan(dataSource);
}}
isDisabled={!isAllowed}
>
<Tooltip
position="left"
sideOffset={42}
content={`Manually trigger a scan for this ${sourceDetails.name} data source.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span> Trigger Scan</span>
<FontAwesomeIcon
className="text-bunker-300"
size="sm"
icon={faInfoCircle}
/>
</div>
</Tooltip>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={isAutoScanEnabled ? faToggleOff : faToggleOn} />}
onClick={(e) => {
e.stopPropagation();
onToggleEnableAutoScan(dataSource);
}}
>
{isAutoScanEnabled ? "Disable" : "Enable"} Auto-Scan
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
onEdit(dataSource);
}}
>
Edit Data Source
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.Delete}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={(e) => {
e.stopPropagation();
onDelete(dataSource);
}}
>
Delete Data Source
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,82 @@
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CreateSecretScanningDataSourceModal } from "@app/components/secret-scanning";
import { Button, Spinner } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useListSecretScanningDataSources } from "@app/hooks/api/secretScanningV2";
import { SecretScanningDataSourcesTable } from "./SecretScanningDataSourcesTable";
export const SecretScanningDataSourcesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addDataSource"] as const);
const { currentWorkspace } = useWorkspace();
const { data: dataSources = [], isPending: isDataSourcesPending } =
useListSecretScanningDataSources(currentWorkspace.id, {
refetchInterval: 30000
});
if (isDataSourcesPending)
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
<Spinner />
</div>
);
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Data Sources</p>
<a
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Use App Connections to scan for secret leaks from third-party services.
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionSecretScanningDataSourceActions.Create}
a={ProjectPermissionSub.SecretScanningDataSources}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addDataSource")}
isDisabled={!isAllowed}
>
Add Data Source
</Button>
)}
</ProjectPermissionCan>
</div>
<SecretScanningDataSourcesTable dataSources={dataSources} />
</div>
<CreateSecretScanningDataSourceModal
isOpen={popUp.addDataSource.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addDataSource", isOpen)}
/>
</>
);
};
@@ -0,0 +1,387 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faPuzzlePiece,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { EditSecretScanningDataSourceModal } from "@app/components/secret-scanning";
import { DeleteSecretScanningDataSourceModal } from "@app/components/secret-scanning/DeleteSecretScanningDataSourceModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
SecretScanningDataSource,
SecretScanningScanStatus,
TSecretScanningDataSource,
TSecretScanningDataSourceWithDetails,
useTriggerSecretScanningDataSource,
useUpdateSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningDataSourceRow } from "./SecretScanningDataSourceRow";
// import { getSecretSyncDestinationColValues } from "./helpers";
enum DataSourcesOrderBy {
Findings = "findings",
Name = "name",
LastScan = "last-scan"
}
type DataSourceFilters = {
types: SecretScanningDataSource[];
};
type Props = {
dataSources: TSecretScanningDataSourceWithDetails[];
};
export const SecretScanningDataSourcesTable = ({ dataSources }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteDataSource",
"editDataSource",
"triggerDataSourceScan"
] as const);
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
const updateDataSource = useUpdateSecretScanningDataSource();
const [filters, setFilters] = useState<DataSourceFilters>({
types: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<DataSourcesOrderBy>(DataSourcesOrderBy.Name, { initPerPage: 20 });
const filteredDataSources = useMemo(
() =>
dataSources
.filter((dataSource) => {
const { type, name, connection } = dataSource;
if (filters.types.length && !filters.types.includes(type)) return false;
const searchValue = search.trim().toLowerCase();
return (
SECRET_SCANNING_DATA_SOURCE_MAP[type].name.toLowerCase().includes(searchValue) ||
name.toLowerCase().includes(searchValue) ||
connection.name.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [dataSourceOne, dataSourceTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case DataSourcesOrderBy.LastScan:
if (dataSourceOne.lastScannedAt === null && dataSourceTwo.lastScannedAt === null)
return 0;
if (dataSourceOne.lastScannedAt === null) return 1;
if (dataSourceTwo.lastScannedAt === null) return -1;
return (
new Date(dataSourceTwo.lastScannedAt).getTime() -
new Date(dataSourceOne.lastScannedAt).getTime()
);
case DataSourcesOrderBy.Findings:
if (
dataSourceOne.unresolvedFindings === null &&
dataSourceTwo.unresolvedFindings === null
)
return 0;
if (dataSourceOne.unresolvedFindings === null) return 1;
if (dataSourceTwo.unresolvedFindings === null) return -1;
if (
dataSourceOne.unresolvedFindings === 0 &&
dataSourceOne.lastScanStatus === SecretScanningScanStatus.Failed
)
return 1;
if (
dataSourceTwo.unresolvedFindings === 0 &&
dataSourceTwo.lastScanStatus === SecretScanningScanStatus.Failed
)
return -1;
return dataSourceTwo.unresolvedFindings - dataSourceOne.unresolvedFindings;
case DataSourcesOrderBy.Name:
default:
return dataSourceOne.name
.toLowerCase()
.localeCompare(dataSourceTwo.name.toLowerCase());
}
}),
[dataSources, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredDataSources.length,
offset,
setPage
});
const handleSort = (column: DataSourcesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: DataSourcesOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: DataSourcesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.types.length);
const handleDelete = (dataSource: TSecretScanningDataSource) =>
handlePopUpOpen("deleteDataSource", dataSource);
const handleEdit = (dataSource: TSecretScanningDataSource) =>
handlePopUpOpen("editDataSource", dataSource);
const handleToggleEnableAutoScan = async (dataSource: TSecretScanningDataSource) => {
const destinationName = SECRET_SCANNING_DATA_SOURCE_MAP[dataSource.type].name;
const isAutoScanEnabled = !dataSource.isAutoScanEnabled;
try {
await updateDataSource.mutateAsync({
dataSourceId: dataSource.id,
type: dataSource.type,
isAutoScanEnabled,
projectId: dataSource.projectId
});
createNotification({
text: `Successfully ${isAutoScanEnabled ? "enabled" : "disabled"} auto-scan for ${destinationName} Data Source`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${isAutoScanEnabled ? "enable" : "disable"} auto-scan for ${destinationName} Data Source`,
type: "error"
});
}
};
const handleTriggerScan = async (dataSource: TSecretScanningDataSource) => {
try {
await triggerDataSourceScan.mutateAsync({
dataSourceId: dataSource.id,
type: dataSource.type,
projectId: dataSource.projectId
});
createNotification({
text: "Successfully triggered scan",
type: "success"
});
} catch {
createNotification({
text: "Failed to trigger scan",
type: "error"
});
}
};
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search data sources..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter data sources"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Platform</DropdownMenuLabel>
{dataSources.length ? (
[...new Set(dataSources.map(({ type }) => type))].map((type) => {
const { name, image } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
types: prev.types.includes(type)
? prev.types.filter((a) => a !== type)
: [...prev.types, type]
}));
}}
key={type}
icon={
filters.types.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} integration`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Data Sources Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-60">Platform</Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(DataSourcesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(DataSourcesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Findings
<IconButton
variant="plain"
className={getClassName(DataSourcesOrderBy.Findings)}
ariaLabel="sort"
onClick={() => handleSort(DataSourcesOrderBy.Findings)}
>
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.Findings)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Last Scan
<IconButton
variant="plain"
className={getClassName(DataSourcesOrderBy.LastScan)}
ariaLabel="sort"
onClick={() => handleSort(DataSourcesOrderBy.LastScan)}
>
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.LastScan)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredDataSources.slice(offset, perPage * page).map((dataSource) => (
<SecretScanningDataSourceRow
key={dataSource.id}
dataSource={dataSource}
onDelete={handleDelete}
onEdit={handleEdit}
onTriggerScan={handleTriggerScan}
onToggleEnableAutoScan={handleToggleEnableAutoScan}
/>
))}
</TBody>
</Table>
{Boolean(filteredDataSources.length) && (
<Pagination
count={filteredDataSources.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!filteredDataSources?.length && (
<EmptyState
title={
dataSources.length
? "No data sources match search..."
: "This project has no data sources configured"
}
icon={dataSources.length ? faSearch : faPuzzlePiece}
/>
)}
</TableContainer>
<DeleteSecretScanningDataSourceModal
onOpenChange={(isOpen) => handlePopUpToggle("deleteDataSource", isOpen)}
isOpen={popUp.deleteDataSource.isOpen}
dataSource={popUp.deleteDataSource.data}
/>
<EditSecretScanningDataSourceModal
onOpenChange={(isOpen) => handlePopUpToggle("editDataSource", isOpen)}
isOpen={popUp.editDataSource.isOpen}
dataSource={popUp.editDataSource.data}
/>
</div>
);
};
@@ -0,0 +1 @@
export { SecretScanningDataSourcesSection } from "./SecretScanningDataSourcesSection";
@@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { SecretScanningDataSourcesPage } from "./SecretScanningDataSourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/data-sources/"
)({
component: SecretScanningDataSourcesPage
// beforeLoad: ({ context }) => {
// return {
// breadcrumbs: [
// ...context.breadcrumbs,
// {
// label: "Data Sources"
// }
// ]
// };
// }
});
@@ -0,0 +1,25 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SecretScanningFindingsSection } from "./components";
export const SecretScanningFindingsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Secret Scanning" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Findings" description="View Secret Leaks across your project." />
<SecretScanningFindingsSection />
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,213 @@
import { useCallback } from "react";
import { faCheck, faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import {
SECRET_SCANNING_DATA_SOURCE_MAP,
SECRET_SCANNING_FINDING_STATUS_ICON_MAP
} from "@app/helpers/secretScanningV2";
import { useToggle } from "@app/hooks";
import {
SecretScanningFindingStatus,
TSecretScanningFinding
} from "@app/hooks/api/secretScanningV2";
type Props = {
finding: TSecretScanningFinding;
onUpdate: (finding: TSecretScanningFinding) => void;
};
export const SecretScanningFindingRow = ({ finding, onUpdate }: Props) => {
const {
resourceName,
id,
dataSourceType,
createdAt,
dataSourceName,
rule,
status,
details,
remarks
} = finding;
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(
(idToCopy: string) => {
setIsIdCopied.on();
navigator.clipboard.writeText(idToCopy);
createNotification({
text: "Resource ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
},
[isIdCopied]
);
const sourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[dataSourceType];
const [isExpanded, setIsExpanded] = useToggle(false);
return (
<>
<Tr
onClick={setIsExpanded.toggle}
className={twMerge(
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
)}
key={`resource-${id}`}
>
<Td className="!min-w-[4rem] max-w-0">
<div className="flex w-full items-center">
<img
alt={`${sourceDetails.name} Data Source`}
src={`/images/integrations/${sourceDetails.image}`}
className="w-5"
/>
<p className="ml-2 truncate">{sourceDetails.name}</p>
</div>
</Td>
<Td>
<div className="flex items-center gap-1 whitespace-nowrap">
<p>{format(createdAt, "MMM dd yyyy")}</p>
<p className="text-mineshaft-300">{format(createdAt, "h:mm aa")}</p>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="w-full items-center">
<p className="truncate">{resourceName}</p>
<p className="truncate text-xs text-mineshaft-400">{dataSourceName}</p>
</div>
</Td>
<Td className="whitespace-nowrap">{rule}</Td>
<Td className="whitespace-nowrap">
<Tooltip position="left" content={remarks}>
<div className="w-min">
<Badge
variant={status === SecretScanningFindingStatus.Resolved ? "success" : "primary"}
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap",
(status === SecretScanningFindingStatus.FalsePositive ||
status === SecretScanningFindingStatus.Ignore) &&
"bg-mineshaft-400/50 text-bunker-300"
)}
>
<FontAwesomeIcon icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon} />
<span className="capitalize">{status.replace("-", " ")}</span>
</Badge>
</div>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId(id);
}}
>
Copy Finding ID
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
<Tr>
<Td colSpan={6} className="!border-none p-0">
<div
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ${
isExpanded ? "max-h-[50rem] opacity-100" : "max-h-0"
}`}
>
<div className="grid gap-4 p-4 2xl:grid-cols-6">
<GenericFieldLabel truncate className="col-span-full" label="Description">
{details.description}
</GenericFieldLabel>
<GenericFieldLabel truncate label="Date">
{format(details.date, "MMM dd yyyy h:mm aa")}
</GenericFieldLabel>
<GenericFieldLabel truncate label="Author">
{details.author}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-4" truncate label="Email">
{details.email}
</GenericFieldLabel>
<GenericFieldLabel truncate className="col-span-full" label="File">
{details.file}
</GenericFieldLabel>
<GenericFieldLabel truncate className="col-span-full" label="Commit">
{details.commit}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-full" label="Commit Message">
{details.message}
</GenericFieldLabel>
<GenericFieldLabel label="Start Line">{details.startLine}</GenericFieldLabel>
<GenericFieldLabel label="End Line">{details.endLine}</GenericFieldLabel>
<GenericFieldLabel label="Start Column">{details.startColumn}</GenericFieldLabel>
<GenericFieldLabel label="End Column">{details.endColumn}</GenericFieldLabel>
<GenericFieldLabel className="col-span-full" label="Link">
<a
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer text-mineshaft-300 underline underline-offset-2 hover:text-mineshaft-100"
href={details.link}
>
{details.link}
</a>
</GenericFieldLabel>
<Button
onClick={() => onUpdate(finding)}
colorSchema="secondary"
leftIcon={
<FontAwesomeIcon
className={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].className}
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon}
/>
}
>
Update Status
</Button>
</div>
</div>
</Td>
</Tr>
</>
);
};
@@ -0,0 +1,56 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useListSecretScanningFindings } from "@app/hooks/api/secretScanningV2";
import { SecretScanningFindingsTable } from "./SecretScanningFindingsTable";
export const SecretScanningFindingsSection = () => {
const { currentWorkspace } = useWorkspace();
const { data: findings = [], isPending: isFindingsPending } = useListSecretScanningFindings(
currentWorkspace.id,
{
refetchInterval: 30000
}
);
if (isFindingsPending)
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
<Spinner />
</div>
);
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Findings</p>
<a
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Secret leaks discovered across your data sources
</p>
</div>
</div>
<SecretScanningFindingsTable findings={findings} />
</div>
);
};
@@ -0,0 +1,372 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
SECRET_SCANNING_DATA_SOURCE_MAP,
SECRET_SCANNING_FINDING_STATUS_ICON_MAP
} from "@app/helpers/secretScanningV2";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
SecretScanningDataSource,
SecretScanningFindingStatus,
TSecretScanningFinding
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningFindingRow } from "./SecretScanningFindingRow";
import { SecretScanningUpdateFindingModal } from "./SecretScanningUpdateFindingModal";
enum FindingsOrderBy {
ResourceName = "resource-name",
Status = "status",
Rule = "rule",
Timestamp = "timestamp"
}
type DataSourceFilters = {
dataSourceTypes: SecretScanningDataSource[];
status: SecretScanningFindingStatus[];
};
type Props = {
findings: TSecretScanningFinding[];
};
const getStatusOrderValue = (status: SecretScanningFindingStatus) => {
switch (status) {
case SecretScanningFindingStatus.Unresolved:
return 0;
case SecretScanningFindingStatus.Ignore:
return 1;
case SecretScanningFindingStatus.FalsePositive:
return 2;
default:
return 3;
}
};
export const SecretScanningFindingsTable = ({ findings }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["updateFinding"] as const);
const { search: initSearch, status: initStatus } = useSearch({
from: ROUTE_PATHS.SecretScanning.FindingsPage.id
});
const [filters, setFilters] = useState<DataSourceFilters>({
dataSourceTypes: [],
status: initStatus ? [initStatus] : []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<FindingsOrderBy>(FindingsOrderBy.Timestamp, { initPerPage: 20, initSearch });
const filteredFindings = useMemo(
() =>
findings
.filter((finding) => {
const { rule, resourceName, dataSourceType, dataSourceName, status, scanId } = finding;
if (filters.dataSourceTypes.length && !filters.dataSourceTypes.includes(dataSourceType))
return false;
if (filters.status.length && !filters.status.includes(status)) {
return false;
}
if (search.includes("scanId")) {
return search.split(":")[1] === scanId;
}
const searchValue = search.trim().toLowerCase();
return (
SECRET_SCANNING_DATA_SOURCE_MAP[dataSourceType].name
.toLowerCase()
.includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
dataSourceName.toLowerCase().includes(searchValue) ||
rule.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [findingOne, findingTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case FindingsOrderBy.Timestamp:
return (
new Date(findingTwo.createdAt).getTime() - new Date(findingOne.createdAt).getTime()
);
case FindingsOrderBy.Rule:
return findingOne.rule.toLowerCase().localeCompare(findingTwo.rule.toLowerCase());
case FindingsOrderBy.Status:
return (
getStatusOrderValue(findingOne.status) - getStatusOrderValue(findingTwo.status)
);
case FindingsOrderBy.ResourceName:
default:
return findingOne.resourceName
.toLowerCase()
.localeCompare(findingTwo.resourceName.toLowerCase());
}
}),
[findings, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredFindings.length,
offset,
setPage
});
const handleSort = (column: FindingsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: FindingsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: FindingsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.dataSourceTypes.length || filters.status.length);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search findings..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter findings"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{Object.values(SecretScanningFindingStatus).map((status) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((s) => s !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon}
className={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].className}
/>
<span className="capitalize">{status.replace("-", " ")}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Platform</DropdownMenuLabel>
{findings.length ? (
[...new Set(findings.map(({ dataSourceType: type }) => type))].map((type) => {
const { name, image } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
dataSourceTypes: prev.dataSourceTypes.includes(type)
? prev.dataSourceTypes.filter((a) => a !== type)
: [...prev.dataSourceTypes, type]
}));
}}
key={type}
icon={
filters.dataSourceTypes.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} integration`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Data Source Findings</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="min-w-[10rem]">Platform</Th>
<Th className="w-1/4">
<div className="flex items-center">
Timestamp
<IconButton
variant="plain"
className={getClassName(FindingsOrderBy.Timestamp)}
ariaLabel="sort"
onClick={() => handleSort(FindingsOrderBy.Timestamp)}
>
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Timestamp)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3 whitespace-nowrap">
<div className="flex items-center">
Resource Name
<IconButton
variant="plain"
className={getClassName(FindingsOrderBy.ResourceName)}
ariaLabel="sort"
onClick={() => handleSort(FindingsOrderBy.ResourceName)}
>
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.ResourceName)} />
</IconButton>
</div>
</Th>
<Th className="w-1/4">
<div className="flex items-center">
Rule
<IconButton
variant="plain"
className={getClassName(FindingsOrderBy.Rule)}
ariaLabel="sort"
onClick={() => handleSort(FindingsOrderBy.Rule)}
>
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Rule)} />
</IconButton>
</div>
</Th>
<Th className="w-1/4">
<div className="flex items-center">
Status
<IconButton
variant="plain"
className={getClassName(FindingsOrderBy.Status)}
ariaLabel="sort"
onClick={() => handleSort(FindingsOrderBy.Status)}
>
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Status)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredFindings.slice(offset, perPage * page).map((finding) => (
<SecretScanningFindingRow
key={finding.id}
finding={finding}
onUpdate={() => handlePopUpOpen("updateFinding", finding)}
/>
))}
</TBody>
</Table>
{Boolean(filteredFindings.length) && (
<Pagination
count={filteredFindings.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!filteredFindings?.length && (
<EmptyState
title={
findings.length
? "No findings match search..."
: "This project has not uncovered any secret leaks"
}
icon={faSearch}
/>
)}
</TableContainer>
<SecretScanningUpdateFindingModal
isOpen={popUp.updateFinding.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFinding", isOpen)}
finding={popUp.updateFinding.data}
/>
</div>
);
};
@@ -0,0 +1,141 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { SECRET_SCANNING_FINDING_STATUS_ICON_MAP } from "@app/helpers/secretScanningV2";
import {
SecretScanningFindingStatus,
TSecretScanningFinding,
useUpdateSecretScanningFinding
} from "@app/hooks/api/secretScanningV2";
type Props = {
finding?: TSecretScanningFinding;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
const FormSchema = z.object({
remarks: z.string().optional(),
status: z.nativeEnum(SecretScanningFindingStatus)
});
type FormType = z.infer<typeof FormSchema>;
type ContentProps = {
finding: TSecretScanningFinding;
onComplete: () => void;
};
const Content = ({ finding, onComplete }: ContentProps) => {
const updateFinding = useUpdateSecretScanningFinding();
const { handleSubmit, control } = useForm<FormType>({
resolver: zodResolver(FormSchema),
defaultValues: {
status: finding.status,
remarks: finding.remarks ?? ""
}
});
const onSubmit = async (data: FormType) => {
try {
await updateFinding.mutateAsync({
...data,
findingId: finding.id,
projectId: finding.projectId
});
createNotification({
type: "success",
text: "Finding status successfully updated"
});
onComplete();
} catch {
createNotification({
type: "error",
text: "Failed to update Finding status"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="status"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl label="Status" isError={Boolean(error)} errorText={error?.message}>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].icon}
iconClassName={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].className}
>
{Object.values(SecretScanningFindingStatus).map((status) => {
return (
<SelectItem className="capitalize" value={status} key={status}>
{status.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl>
);
}}
/>
<Controller
control={control}
name="remarks"
render={({ field, fieldState: { error } }) => {
return (
<FormControl label="Remarks" isError={Boolean(error)} errorText={error?.message}>
<TextArea className="h-40 !resize-none" {...field} />
</FormControl>
);
}}
/>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
type="submit"
isLoading={updateFinding.isPending}
isDisabled={updateFinding.isPending}
colorSchema="secondary"
>
Update Status
</Button>
<ModalClose asChild>
<Button colorSchema="secondary">Cancel</Button>
</ModalClose>
</div>
</form>
);
};
export const SecretScanningUpdateFindingModal = ({ finding, isOpen, onOpenChange }: Props) => {
if (!finding) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Resolve Finding" subTitle="Mark this finding as resolved">
<Content finding={finding} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export * from "./SecretScanningFindingsSection";
@@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SecretScanningFindingStatus } from "@app/hooks/api/secretScanningV2";
import { SecretScanningFindingsPage } from "./SecretScanningFindingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/findings"
)({
validateSearch: zodValidator(
z.object({
search: z.string().optional(),
status: z.nativeEnum(SecretScanningFindingStatus).optional().catch(undefined)
})
),
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Findings"
}
]
};
},
component: SecretScanningFindingsPage
});
@@ -0,0 +1,48 @@
import { Helmet } from "react-helmet";
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 { ProjectGeneralTab } from "./components/ProjectGeneralTab";
export const SettingsPage = () => {
const { t } = useTranslation();
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.project.title")} />
<Tabs defaultValue="tab-project-general">
<TabList>
<Tab value="tab-project-general">General</Tab>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) =>
isAllowed && <Tab value="tab-project-secret-scannings">Rule Settings</Tab>
}
</ProjectPermissionCan>
</TabList>
<TabPanel value="tab-project-general">
<ProjectGeneralTab />
</TabPanel>
{/* <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) =>
isAllowed && (
<TabPanel value="tab-project-ssh">
<ProjectSshTab />
</TabPanel>
)
}
</ProjectPermissionCan> */}
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useProjectPermission, useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { useUpdateWorkspaceAuditLogsRetention } from "@app/hooks/api/workspace/queries";
const formSchema = z.object({
auditLogsRetentionDays: z.coerce.number().min(0)
});
type TForm = z.infer<typeof formSchema>;
export const AuditLogsRetentionSection = () => {
const { mutateAsync: updateAuditLogsRetention } = useUpdateWorkspaceAuditLogsRetention();
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
auditLogsRetentionDays:
currentWorkspace?.auditLogsRetentionDays ?? subscription?.auditLogsRetentionDays ?? 0
}
});
if (!currentWorkspace) return null;
const handleAuditLogsRetentionSubmit = async ({ auditLogsRetentionDays }: TForm) => {
try {
if (!subscription?.auditLogs) {
handlePopUpOpen("upgradePlan", {
description: "You can only configure audit logs retention if you upgrade your plan."
});
return;
}
if (subscription && auditLogsRetentionDays > subscription?.auditLogsRetentionDays) {
handlePopUpOpen("upgradePlan", {
description:
"To update your audit logs retention period to a higher value, upgrade your plan."
});
return;
}
await updateAuditLogsRetention({
auditLogsRetentionDays,
projectSlug: currentWorkspace.slug
});
createNotification({
text: "Successfully updated audit logs retention period",
type: "success"
});
} catch {
createNotification({
text: "Failed updating audit logs retention period",
type: "error"
});
}
};
// render only for dedicated/self-hosted instances of Infisical
if (
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com")
) {
return null;
}
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Audit Logs Retention</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
Set the number of days to keep your project audit logs.
</p>
<form onSubmit={handleSubmit(handleAuditLogsRetentionSubmit)} autoComplete="off">
<div className="max-w-xs">
<Controller
control={control}
defaultValue={0}
name="auditLogsRetentionDays"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Number of days"
>
<Input {...field} type="number" min={1} step={1} isDisabled={!isAdmin} />
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isAdmin || !isDirty}
>
Save
</Button>
</form>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</>
);
};
@@ -0,0 +1 @@
export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection";
@@ -0,0 +1,192 @@
import { useMemo } from "react";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useDeleteWorkspace, useGetWorkspaceUsers, useLeaveProject } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const DeleteProjectSection = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteWorkspace",
"leaveWorkspace"
] as const);
const { currentOrg } = useOrganization();
const { hasProjectRole, membership } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const [isDeleting, setIsDeleting] = useToggle();
const [isLeaving, setIsLeaving] = useToggle();
const deleteWorkspace = useDeleteWorkspace();
const leaveProject = useLeaveProject();
const { data: members, isPending: isMembersLoading } = useGetWorkspaceUsers(
currentWorkspace?.id || ""
);
// If isNoAccessMember is true, then the user can't read the workspace members. So we need to handle this case separately.
const isNoAccessMember = hasProjectRole("no-access");
const isOnlyAdminMember = useMemo(() => {
if (!members || !membership || !hasProjectRole("admin")) return false;
const adminMembers = members.filter(
(member) => member.roles.map((r) => r.role).includes("admin") && member.id !== membership.id // exclude the current user
);
return !adminMembers.length;
}, [members, membership]);
const handleDeleteWorkspaceSubmit = async () => {
setIsDeleting.on();
try {
if (!currentWorkspace?.id) return;
await deleteWorkspace.mutateAsync({
workspaceID: currentWorkspace?.id
});
createNotification({
text: "Successfully deleted project",
type: "success"
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
handlePopUpClose("deleteWorkspace");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete project",
type: "error"
});
} finally {
setIsDeleting.off();
}
};
const handleLeaveWorkspaceSubmit = async () => {
console.log({
currentWorkspace,
currentOrg,
members,
isNoAccessMember,
membership
});
try {
setIsLeaving.on();
if (!currentWorkspace?.id || !currentOrg?.id) return;
// If there's no members, and the user has access to read members, something went wrong.
if (!members && !isNoAccessMember) return;
// If the user has elevated permissions and can read members:
if (!isNoAccessMember) {
if (!members) return;
if (members.length < 2) {
createNotification({
text: "You can't leave the project as you are the only member",
type: "error"
});
return;
}
// If the user has access to read members, and there's less than 1 admin member excluding the current user, they can't leave the project.
if (isOnlyAdminMember) {
createNotification({
text: "You can't leave a project with no admin members left. Promote another member to admin first.",
type: "error"
});
return;
}
}
// If it's actually a no-access member, then we don't really care about the members.
await leaveProject.mutateAsync({
workspaceId: currentWorkspace.id
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to leave project",
type: "error"
});
} finally {
setIsLeaving.off();
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
<div className="space-x-4">
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
isLoading={isDeleting}
isDisabled={!isAllowed || isDeleting}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteWorkspace")}
>
{`Delete ${currentWorkspace?.name}`}
</Button>
)}
</ProjectPermissionCan>
{!isOnlyAdminMember && (
<Button
disabled={isMembersLoading || (members && members?.length < 2)}
isLoading={isLeaving}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("leaveWorkspace")}
>
{`Leave ${currentWorkspace?.name}`}
</Button>
)}
</div>
<DeleteActionModal
isOpen={popUp.deleteWorkspace.isOpen}
title="Are you sure want to delete this project?"
subTitle={`Permanently delete ${currentWorkspace?.name} and all of its data. This action is not reversible, so please be careful.`}
onChange={(isOpen) => handlePopUpToggle("deleteWorkspace", isOpen)}
deleteKey="confirm"
buttonText="Delete Project"
onDeleteApproved={handleDeleteWorkspaceSubmit}
/>
<LeaveProjectModal
isOpen={popUp.leaveWorkspace.isOpen}
title="Are you sure want to leave this project?"
subTitle={`If you leave ${currentWorkspace?.name} you will lose access to the project and it's contents.`}
onChange={(isOpen) => handlePopUpToggle("leaveWorkspace", isOpen)}
deleteKey="confirm"
buttonText="Leave Project"
onLeaveApproved={handleLeaveWorkspaceSubmit}
/>
</div>
);
};
@@ -0,0 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";
@@ -0,0 +1,14 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection";
export const ProjectGeneralTab = () => {
return (
<div>
<ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection />
<DeleteProjectSection />
</div>
);
};
@@ -0,0 +1 @@
export { ProjectGeneralTab } from "./ProjectGeneralTab";
@@ -0,0 +1,9 @@
import { ProjectSshConfigCasSection } from "./components";
export const ProjectSshTab = () => {
return (
<div>
<ProjectSshConfigCasSection />
</div>
);
};
@@ -0,0 +1,139 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
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, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetProjectSshConfig,
useListWorkspaceSshCas,
useUpdateProjectSshConfig
} from "@app/hooks/api";
const schema = z
.object({
defaultUserSshCaId: z.string(),
defaultHostSshCaId: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
export const ProjectSshConfigCasSection = () => {
const { currentWorkspace } = useWorkspace();
const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id);
const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id);
const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
if (sshConfig) {
reset({
defaultUserSshCaId: sshConfig.defaultUserSshCaId || "",
defaultHostSshCaId: sshConfig.defaultHostSshCaId || ""
});
}
}, [sshConfig]);
const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => {
try {
await updateProjectSshConfig({
projectId: currentWorkspace.id,
defaultUserSshCaId: defaultUserSshCaId || undefined,
defaultHostSshCaId: defaultHostSshCaId || undefined
});
createNotification({
text: "Successfully updated SSH project settings",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update SSH project settings",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="defaultUserSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default User CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="defaultHostSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default Host CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";
@@ -0,0 +1 @@
export { ProjectSshTab } from "./ProjectSshTab";
@@ -0,0 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";
@@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "./SettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/settings"
)({
component: SettingsPage,
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Settings"
}
]
};
}
});
@@ -0,0 +1,41 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { workspaceKeys } from "@app/hooks/api";
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
import { fetchWorkspaceById } from "@app/hooks/api/workspace/queries";
import { ProjectLayout } from "@app/layouts/ProjectLayout";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout"
)({
component: ProjectLayout,
beforeLoad: async ({ params, context }) => {
const project = await context.queryClient.ensureQueryData({
queryKey: workspaceKeys.getWorkspaceById(params.projectId),
queryFn: () => fetchWorkspaceById(params.projectId)
});
await context.queryClient.ensureQueryData({
queryKey: roleQueryKeys.getUserProjectPermissions({
workspaceId: params.projectId
}),
queryFn: () => fetchUserProjectPermissions({ workspaceId: params.projectId })
});
return {
breadcrumbs: [
{
label: "Secret Scanning",
link: linkOptions({ to: "/organization/secret-scanning/overview" })
},
{
label: project.name,
link: linkOptions({
to: "/secret-scanning/$projectId/data-sources",
params: { projectId: project.id }
})
}
]
};
}
});