Merge pull request #2988 from Infisical/daniel/audit-logs-searchability

feat(radar): pagination and filtering
This commit is contained in:
Daniel Hougaard
2025-01-16 01:24:24 +01:00
committed by GitHub
12 changed files with 497 additions and 129 deletions
@@ -1,9 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas"; import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas";
import { SecretScanningRiskStatus } from "@app/ee/services/secret-scanning/secret-scanning-types"; import {
SecretScanningResolvedStatus,
SecretScanningRiskStatus
} from "@app/ee/services/secret-scanning/secret-scanning-types";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -97,6 +101,45 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
} }
}); });
server.route({
url: "/organization/:organizationId/risks/export",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({ organizationId: z.string().trim() }),
querystring: z.object({
repositoryNames: z
.string()
.optional()
.nullable()
.transform((val) => (val ? val.split(",") : undefined)),
resolvedStatus: z.nativeEnum(SecretScanningResolvedStatus).optional()
}),
response: {
200: z.object({
risks: SecretScanningGitRisksSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const risks = await server.services.secretScanning.getAllRisksByOrg({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.organizationId,
filter: {
repositoryNames: req.query.repositoryNames,
resolvedStatus: req.query.resolvedStatus
}
});
return { risks };
}
});
server.route({ server.route({
url: "/organization/:organizationId/risks", url: "/organization/:organizationId/risks",
method: "GET", method: "GET",
@@ -105,20 +148,46 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
}, },
schema: { schema: {
params: z.object({ organizationId: z.string().trim() }), params: z.object({ organizationId: z.string().trim() }),
querystring: z.object({
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(20000).default(100),
orderBy: z.enum(["createdAt", "name"]).default("createdAt"),
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.DESC),
repositoryNames: z
.string()
.optional()
.nullable()
.transform((val) => (val ? val.split(",") : undefined)),
resolvedStatus: z.nativeEnum(SecretScanningResolvedStatus).optional()
}),
response: { response: {
200: z.object({ risks: SecretScanningGitRisksSchema.array() }) 200: z.object({
risks: SecretScanningGitRisksSchema.array(),
totalCount: z.number(),
repos: z.array(z.string())
})
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const { risks } = await server.services.secretScanning.getRisksByOrg({ const { risks, totalCount, repos } = await server.services.secretScanning.getRisksByOrg({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
orgId: req.params.organizationId orgId: req.params.organizationId,
filter: {
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection,
repositoryNames: req.query.repositoryNames,
resolvedStatus: req.query.resolvedStatus
}
}); });
return { risks }; return { risks, totalCount, repos };
} }
}); });
@@ -1,9 +1,12 @@
import { Knex } from "knex"; import knex, { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TSecretScanningGitRisksInsert } from "@app/db/schemas"; import { TableName, TSecretScanningGitRisksInsert } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretScanningResolvedStatus, TGetOrgRisksDTO } from "./secret-scanning-types";
export type TSecretScanningDALFactory = ReturnType<typeof secretScanningDALFactory>; export type TSecretScanningDALFactory = ReturnType<typeof secretScanningDALFactory>;
@@ -19,5 +22,70 @@ export const secretScanningDALFactory = (db: TDbClient) => {
} }
}; };
return { ...gitRiskOrm, upsert }; const findByOrgId = async (orgId: string, filter: TGetOrgRisksDTO["filter"], tx?: Knex) => {
try {
// Find statements
const sqlQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
// eslint-disable-next-line func-names
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId);
if (filter.repositoryNames) {
void sqlQuery.whereIn(`${TableName.SecretScanningGitRisk}.repositoryFullName`, filter.repositoryNames);
}
if (filter.resolvedStatus) {
if (filter.resolvedStatus !== SecretScanningResolvedStatus.All) {
const isResolved = filter.resolvedStatus === SecretScanningResolvedStatus.Resolved;
void sqlQuery.where(`${TableName.SecretScanningGitRisk}.isResolved`, isResolved);
}
}
// Select statements
void sqlQuery
.select(selectAllTableCols(TableName.SecretScanningGitRisk))
.limit(filter.limit)
.offset(filter.offset);
if (filter.orderBy) {
const orderDirection = filter.orderDirection || OrderByDirection.ASC;
void sqlQuery.orderBy(filter.orderBy, orderDirection);
}
const countQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId)
.count();
const uniqueReposQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId)
.distinct("repositoryFullName")
.select("repositoryFullName");
// we timeout long running queries to prevent DB resource issues (2 minutes)
const docs = await sqlQuery.timeout(1000 * 120);
const uniqueRepos = await uniqueReposQuery.timeout(1000 * 120);
const totalCount = await countQuery;
return {
risks: docs,
totalCount: Number(totalCount?.[0].count),
repos: uniqueRepos
.filter(Boolean)
.map((r) => r.repositoryFullName!)
.sort((a, b) => a.localeCompare(b))
};
} catch (error) {
if (error instanceof knex.KnexTimeoutError) {
throw new GatewayTimeoutError({
error,
message: "Failed to fetch secret leaks due to timeout. Add more search filters."
});
}
throw new DatabaseError({ error });
}
};
return { ...gitRiskOrm, upsert, findByOrgId };
}; };
@@ -15,6 +15,7 @@ import { TSecretScanningDALFactory } from "./secret-scanning-dal";
import { TSecretScanningQueueFactory } from "./secret-scanning-queue"; import { TSecretScanningQueueFactory } from "./secret-scanning-queue";
import { import {
SecretScanningRiskStatus, SecretScanningRiskStatus,
TGetAllOrgRisksDTO,
TGetOrgInstallStatusDTO, TGetOrgInstallStatusDTO,
TGetOrgRisksDTO, TGetOrgRisksDTO,
TInstallAppSessionDTO, TInstallAppSessionDTO,
@@ -118,11 +119,21 @@ export const secretScanningServiceFactory = ({
return Boolean(appInstallation); return Boolean(appInstallation);
}; };
const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetOrgRisksDTO) => { const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId, filter }: TGetOrgRisksDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
const results = await secretScanningDAL.findByOrgId(orgId, filter);
return results;
};
const getAllRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetAllOrgRisksDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] }); const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] });
return { risks }; return risks;
}; };
const updateRiskStatus = async ({ const updateRiskStatus = async ({
@@ -189,6 +200,7 @@ export const secretScanningServiceFactory = ({
linkInstallationToOrg, linkInstallationToOrg,
getOrgInstallationStatus, getOrgInstallationStatus,
getRisksByOrg, getRisksByOrg,
getAllRisksByOrg,
updateRiskStatus, updateRiskStatus,
handleRepoPushEvent, handleRepoPushEvent,
handleRepoDeleteEvent handleRepoDeleteEvent
@@ -1,4 +1,4 @@
import { TOrgPermission } from "@app/lib/types"; import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export enum SecretScanningRiskStatus { export enum SecretScanningRiskStatus {
FalsePositive = "RESOLVED_FALSE_POSITIVE", FalsePositive = "RESOLVED_FALSE_POSITIVE",
@@ -7,6 +7,12 @@ export enum SecretScanningRiskStatus {
Unresolved = "UNRESOLVED" Unresolved = "UNRESOLVED"
} }
export enum SecretScanningResolvedStatus {
All = "all",
Resolved = "resolved",
Unresolved = "unresolved"
}
export type TInstallAppSessionDTO = TOrgPermission; export type TInstallAppSessionDTO = TOrgPermission;
export type TLinkInstallSessionDTO = { export type TLinkInstallSessionDTO = {
@@ -16,7 +22,22 @@ export type TLinkInstallSessionDTO = {
export type TGetOrgInstallStatusDTO = TOrgPermission; export type TGetOrgInstallStatusDTO = TOrgPermission;
export type TGetOrgRisksDTO = TOrgPermission; type RiskFilter = {
offset: number;
limit: number;
orderBy?: "createdAt" | "name";
orderDirection?: OrderByDirection;
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
export type TGetOrgRisksDTO = {
filter: RiskFilter;
} & TOrgPermission;
export type TGetAllOrgRisksDTO = {
filter: Omit<RiskFilter, "offset" | "limit" | "orderBy" | "orderDirection">;
} & TOrgPermission;
export type TUpdateRiskStatusDTO = { export type TUpdateRiskStatusDTO = {
riskId: string; riskId: string;
@@ -1,5 +1,6 @@
export { export {
useCreateNewInstallationSession, useCreateNewInstallationSession,
useExportSecretScanningRisks,
useLinkGitAppInstallationWithOrg, useLinkGitAppInstallationWithOrg,
useUpdateRiskStatus useUpdateRiskStatus
} from "./mutation"; } from "./mutation";
@@ -2,7 +2,12 @@ import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { RiskStatus, TGitAppOrg, TSecretScanningGitRisks } from "./types"; import {
RiskStatus,
SecretScanningResolvedStatus,
TGitAppOrg,
TSecretScanningGitRisks
} from "./types";
export const useCreateNewInstallationSession = () => { export const useCreateNewInstallationSession = () => {
return useMutation<{ sessionId: string }, object, { organizationId: string }>({ return useMutation<{ sessionId: string }, object, { organizationId: string }>({
@@ -43,3 +48,31 @@ export const useLinkGitAppInstallationWithOrg = () => {
} }
}); });
}; };
export const useExportSecretScanningRisks = () => {
return useMutation<
TSecretScanningGitRisks[],
object,
{
orgId: string;
filter: {
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
}
>({
mutationFn: async ({ filter, orgId }) => {
const params = new URLSearchParams({
...(filter.resolvedStatus && { resolvedStatus: filter.resolvedStatus }),
...(filter.repositoryNames && { repositoryNames: filter.repositoryNames.join(",") })
});
const { data } = await apiRequest.get<{
risks: TSecretScanningGitRisks[];
}>(`/api/v1/secret-scanning/organization/${orgId}/risks/export`, {
params
});
return data.risks;
}
});
};
@@ -2,11 +2,19 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { TSecretScanningGitRisks } from "./types"; import { SecretScanningOrderBy, SecretScanningRiskFilter, TSecretScanningGitRisks } from "./types";
export const secretScanningQueryKeys = { export const secretScanningQueryKeys = {
getInstallationStatus: (orgId: string) => ["secret-scanning-installation-status", { orgId }], getInstallationStatus: (orgId: string) => ["secret-scanning-installation-status", { orgId }],
getRisksByOrganizatio: (orgId: string) => ["secret-scanning-risks", { orgId }] getRisksByOrganization: (
orgId: string,
sort: {
offset: number;
limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) => ["secret-scanning-risks", { orgId, sort, filter }]
}; };
const fetchSecretScanningInstallationStatus = async (organizationId: string) => { const fetchSecretScanningInstallationStatus = async (organizationId: string) => {
@@ -22,15 +30,43 @@ export const useGetSecretScanningInstallationStatus = (orgId: string) =>
queryFn: () => fetchSecretScanningInstallationStatus(orgId) queryFn: () => fetchSecretScanningInstallationStatus(orgId)
}); });
const fetchSecretScanningRisksByOrgId = async (oranizationId: string) => { const fetchSecretScanningRisksByOrgId = async (
const { data } = await apiRequest.get<{ risks: TSecretScanningGitRisks[] }>( organizationId: string,
`/api/v1/secret-scanning/organization/${oranizationId}/risks` sort: {
); offset: number;
return data.risks; limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) => {
const params = new URLSearchParams({
offset: String(sort.offset),
limit: String(sort.limit),
orderBy: sort.orderBy,
...(filter.resolvedStatus && { resolvedStatus: filter.resolvedStatus }),
...(filter.repositoryNames && { repositoryNames: filter.repositoryNames.join(",") })
});
const { data } = await apiRequest.get<{
risks: TSecretScanningGitRisks[];
totalCount: number;
repos: string[];
}>(`/api/v1/secret-scanning/organization/${organizationId}/risks`, {
params
});
return data;
}; };
export const useGetSecretScanningRisks = (orgId: string) => export const useGetSecretScanningRisks = (
orgId: string,
sort: {
offset: number;
limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) =>
useQuery({ useQuery({
queryKey: secretScanningQueryKeys.getRisksByOrganizatio(orgId), queryKey: secretScanningQueryKeys.getRisksByOrganization(orgId, sort, filter),
queryFn: () => fetchSecretScanningRisksByOrgId(orgId) queryFn: () => fetchSecretScanningRisksByOrgId(orgId, sort, filter)
}); });
@@ -5,6 +5,21 @@ export enum RiskStatus {
UNRESOLVED = "UNRESOLVED" UNRESOLVED = "UNRESOLVED"
} }
export enum SecretScanningOrderBy {
CreatedAt = "createdAt"
}
export enum SecretScanningResolvedStatus {
All = "all",
Resolved = "resolved",
Unresolved = "unresolved"
}
export type SecretScanningRiskFilter = {
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
export type TSecretScanningGitRisks = { export type TSecretScanningGitRisks = {
id: string; id: string;
description: string; description: string;
@@ -1,9 +1,11 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, NoticeBanner } from "@app/components/v2"; import { Button, NoticeBanner, Pagination } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { import {
OrgPermissionActions, OrgPermissionActions,
@@ -12,28 +14,58 @@ import {
useServerConfig useServerConfig
} from "@app/context"; } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePagination, usePopUp } from "@app/hooks";
import { import {
useCreateNewInstallationSession, useCreateNewInstallationSession,
useGetSecretScanningInstallationStatus, useGetSecretScanningInstallationStatus,
useGetSecretScanningRisks, useGetSecretScanningRisks,
useLinkGitAppInstallationWithOrg useLinkGitAppInstallationWithOrg
} from "@app/hooks/api/secretScanning"; } from "@app/hooks/api/secretScanning";
import { SecretScanningOrderBy } from "@app/hooks/api/secretScanning/types";
import { ExportSecretScansModal } from "./components/ExportSecretScansModal"; import { ExportSecretScansModal } from "./components/ExportSecretScansModal";
import { SecretScanningFilter } from "./components/SecretScanningFilters";
import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types";
import { SecretScanningLogsTable } from "./components"; import { SecretScanningLogsTable } from "./components";
const PER_PAGE_INIT = 25;
export const SecretScanningPage = withPermission( export const SecretScanningPage = withPermission(
() => { () => {
const queryParams = useSearch({ const queryParams = useSearch({
from: ROUTE_PATHS.Organization.SecretScanning.id from: ROUTE_PATHS.Organization.SecretScanning.id
}); });
const { control, watch } = useForm<SecretScanningFilterFormData>({
resolver: zodResolver(secretScanningFilterFormSchema),
defaultValues: {}
});
const { config } = useServerConfig(); const { config } = useServerConfig();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const organizationId = currentOrg.id; const organizationId = currentOrg.id;
const { isPending, data: gitRisks } = useGetSecretScanningRisks(organizationId); 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 } = const { mutateAsync: linkGitAppInstallationWithOrganization } =
useLinkGitAppInstallationWithOrg(); useLinkGitAppInstallationWithOrg();
@@ -72,7 +104,7 @@ export const SecretScanningPage = withPermission(
}; };
return ( return (
<div> <div className="py-2">
<Helmet> <Helmet>
<title>Secret scanning</title> <title>Secret scanning</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
@@ -141,21 +173,33 @@ export const SecretScanningPage = withPermission(
)} )}
</div> </div>
<div className="mt-8 space-y-3"> <div className="mt-8 space-y-3">
<div className="flex w-full justify-end"> {integrationEnabled && (
<Button <div className="flex w-full items-center justify-end">
onClick={() => handlePopUpToggle("exportSecretScans", true)} <SecretScanningFilter
variant="solid" repositories={risksData?.repos || []}
colorSchema="secondary" handlePopUpToggle={handlePopUpToggle}
> control={control}
Export />
</Button> </div>
</div> )}
<SecretScanningLogsTable gitRisks={gitRisks} isPending={isPending} /> <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> </div>
</div> </div>
<ExportSecretScansModal <ExportSecretScansModal
gitRisks={gitRisks || []} repositories={risksData?.repos || []}
handlePopUpToggle={handlePopUpToggle} handlePopUpToggle={handlePopUpToggle}
popUp={popUp} popUp={popUp}
/> />
@@ -1,11 +1,12 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import { z } from "zod"; import { z } from "zod";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { TSecretScanningGitRisks } from "@app/hooks/api/secretScanning/types"; 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 { UsePopUpState } from "@app/hooks/usePopUp";
import { convertJsonToCsv } from "@app/lib/fn/csv"; import { convertJsonToCsv } from "@app/lib/fn/csv";
@@ -15,7 +16,7 @@ type Props = {
popUpName: keyof UsePopUpState<["exportSecretScans"]>, popUpName: keyof UsePopUpState<["exportSecretScans"]>,
state?: boolean state?: boolean
) => void; ) => void;
gitRisks: TSecretScanningGitRisks[]; repositories: string[];
}; };
enum ExportFormat { enum ExportFormat {
@@ -23,71 +24,52 @@ enum ExportFormat {
Csv = "csv" Csv = "csv"
} }
enum ExportStatus {
All = "all",
Unresolved = "unresolved",
Resolved = "resolved"
}
const formSchema = z.object({ const formSchema = z.object({
githubOrganization: z.string().trim(),
githubRepository: z.string().trim(), githubRepository: z.string().trim(),
status: z.nativeEnum(ExportStatus), status: z.nativeEnum(SecretScanningResolvedStatus),
exportFormat: z.nativeEnum(ExportFormat) exportFormat: z.nativeEnum(ExportFormat)
}); });
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: Props) => { export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, repositories }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync } = useExportSecretScanningRisks();
const { const {
control, control,
handleSubmit, handleSubmit,
watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
exportFormat: ExportFormat.Json, exportFormat: ExportFormat.Json,
status: ExportStatus.All, status: SecretScanningResolvedStatus.All,
githubOrganization: "all",
githubRepository: "all" githubRepository: "all"
} }
}); });
const selectedOrganization = watch("githubOrganization");
const uniqueOrganizations = useMemo(() => {
const organizations = gitRisks.map((risk) => risk.repositoryFullName.split("/")[0]);
return Array.from(new Set(organizations));
}, [gitRisks]);
const uniqueRepositories = useMemo(() => {
const repositories = gitRisks
.filter((risk) => risk.repositoryFullName.split("/")[0] === selectedOrganization)
.map((risk) => risk.repositoryFullName.split("/")[1]);
return Array.from(new Set(repositories));
}, [gitRisks, selectedOrganization]);
const onFormSubmit = async (data: TFormSchema) => { 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 const filteredRisks = gitRisks
.filter((risk) => .filter((risk) =>
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
data.status === ExportStatus.All data.status === SecretScanningResolvedStatus.All
? true ? true
: data.status === ExportStatus.Resolved : data.status === SecretScanningResolvedStatus.Resolved
? risk.isResolved ? risk.isResolved
: !risk.isResolved : !risk.isResolved
) )
.filter((risk) => { .filter((risk) => {
if (data.githubOrganization === "all") return true; if (data.githubRepository === "all") return true;
return risk.repositoryFullName === data.githubRepository;
if (data.githubRepository === "all")
return risk.repositoryFullName.split("/")[0] === data.githubOrganization;
return risk.repositoryFullName === `${data.githubOrganization}/${data.githubRepository}`;
}); });
const formattedRisks = filteredRisks.map((risk) => { const formattedRisks = filteredRisks.map((risk) => {
@@ -148,27 +130,6 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
</Button> </Button>
]} ]}
> >
<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(ExportStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
name="exportFormat" name="exportFormat"
@@ -192,19 +153,18 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
<Controller <Controller
control={control} control={control}
name="githubOrganization" name="status"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<FormControl label="GitHub Organization"> <FormControl label="Risk Status">
<Select <Select
defaultValue="all" defaultValue="all"
value={value} value={value}
onValueChange={onChange} onValueChange={onChange}
className="w-full" className="w-full"
> >
<SelectItem value="all">All Organizations</SelectItem> {Object.values(SecretScanningResolvedStatus).map((status) => (
{uniqueOrganizations.map((orgName) => ( <SelectItem key={status} value={status}>
<SelectItem key={orgName} value={orgName}> {status.charAt(0).toUpperCase() + status.slice(1)}
{orgName}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -212,29 +172,27 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
)} )}
/> />
{selectedOrganization && selectedOrganization !== "all" && ( <Controller
<Controller control={control}
control={control} name="githubRepository"
name="githubRepository" render={({ field: { value, onChange } }) => (
render={({ field: { value, onChange } }) => ( <FormControl label="GitHub Repository">
<FormControl label="GitHub Repository"> <Select
<Select defaultValue="all"
defaultValue="all" value={value}
value={value} onValueChange={onChange}
onValueChange={onChange} className="w-full"
className="w-full" >
> <SelectItem value="all">All Repositories</SelectItem>
<SelectItem value="all">All Repositories</SelectItem> {repositories.map((repoName) => (
{uniqueRepositories.map((repoName) => ( <SelectItem key={repoName} value={repoName}>
<SelectItem key={repoName} value={repoName}> {repoName}
{repoName} </SelectItem>
</SelectItem> ))}
))} </Select>
</Select> </FormControl>
</FormControl> )}
)} />
/>
)}
</ModalContent> </ModalContent>
</Modal> </Modal>
</form> </form>
@@ -0,0 +1,89 @@
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>
);
};
@@ -0,0 +1,22 @@
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;