diff --git a/backend/package.json b/backend/package.json index ad8fe0d6..31ef7a6e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "dev-collector": "nodemon -r tsconfig-paths/register src/collector.ts", "start-collector": "TS_NODE_BASEURL=./dist/ node -r tsconfig-paths/register dist/collector.js", "sync-endpoints": "ts-node -r tsconfig-paths/register src/scripts/generate-endpoints.ts", + "generate-attacks": "ts-node -r tsconfig-paths/register src/scripts/generate-attacks.ts", "generate-alerts": "ts-node -r tsconfig-paths/register src/scripts/generate-alerts.ts", "format": "prettier --write './src/**/*.{ts,tsx}'" }, diff --git a/backend/src/models/attack.ts b/backend/src/models/attack.ts index d5218e6e..95873b5b 100644 --- a/backend/src/models/attack.ts +++ b/backend/src/models/attack.ts @@ -43,6 +43,9 @@ export class Attack extends BaseEntity { @Column({ type: "text", nullable: true }) uniqueSessionKey: string + @Column() + host: string + @Column({ nullable: true }) sourceIP: string diff --git a/backend/src/scripts/generate-attacks.ts b/backend/src/scripts/generate-attacks.ts new file mode 100644 index 00000000..0ca57c1e --- /dev/null +++ b/backend/src/scripts/generate-attacks.ts @@ -0,0 +1,62 @@ +import yargs from "yargs" +import { randomBytes } from "crypto" +import { AppDataSource } from "data-source" +import { ApiEndpoint, Attack } from "models" +import { AttackType } from "@common/enums" +import { ATTACK_TYPE_TO_RISK_SCORE } from "@common/maps" +import { DateTime } from "luxon" + +const randomDate = (start?: boolean) => { + const startTime = start + ? DateTime.now().minus({ hours: 5 }).toJSDate().getTime() + : DateTime.now().minus({ minutes: 50 }).toJSDate().getTime() + const endTime = start + ? DateTime.now().minus({ hours: 1 }).toJSDate().getTime() + : DateTime.now().toJSDate().getTime() + return new Date(startTime + Math.random() * (endTime - startTime)) +} + +const generateAttacks = async (numAttacks: number) => { + const queryRunner = AppDataSource.createQueryRunner() + await queryRunner.connect() + try { + const endpoints = await queryRunner.manager.find(ApiEndpoint, { + select: { uuid: true, host: true }, + }) + const attackTypes = Object.keys(AttackType) + const insertAttacks: Attack[] = [] + for (let i = 0; i < numAttacks; i++) { + const newAttack = new Attack() + const randTypeNum = Math.floor(Math.random() * attackTypes.length) + const randEndpointNum = Math.floor(Math.random() * endpoints.length) + newAttack.attackType = AttackType[attackTypes[randTypeNum]] + newAttack.riskScore = ATTACK_TYPE_TO_RISK_SCORE[newAttack.attackType] + newAttack.description = `${newAttack.attackType} detected.` + newAttack.startTime = randomDate(true) + newAttack.endTime = randomDate() + newAttack.uniqueSessionKey = randomBytes(16).toString("hex") + newAttack.apiEndpointUuid = endpoints[randEndpointNum].uuid + newAttack.host = endpoints[randEndpointNum].host + insertAttacks.push(newAttack) + } + await queryRunner.manager.insert(Attack, insertAttacks) + } catch (err) { + console.error(`Encountered error while generating sample attacks: ${err}`) + } finally { + await queryRunner.release() + } +} + +const main = async () => { + const datasource = await AppDataSource.initialize() + if (!datasource.isInitialized) { + console.error("Couldn't initialize datasource...") + return + } + console.log("AppDataSource Initialized...") + const args = yargs.argv + const numAttacks = args["numAttacks"] ?? 20 + await generateAttacks(numAttacks) +} + +main() diff --git a/backend/src/services/attacks/index.ts b/backend/src/services/attacks/index.ts index 8027ffb0..afe203a2 100644 --- a/backend/src/services/attacks/index.ts +++ b/backend/src/services/attacks/index.ts @@ -1,25 +1,39 @@ -import { In } from "typeorm" +import { In, FindOptionsWhere } from "typeorm" import { AttackResponse, GetAttackParams } from "@common/types" import { AppDataSource } from "data-source" import Error500InternalServer from "errors/error-500-internal-server" import { Attack } from "models/attack" -import { FindOptionsWhere } from "typeorm" import { hasValidLicense } from "utils/license" +import { AttackType } from "@common/enums" export const getAttacks = async ( getAttackParams: GetAttackParams, ): Promise => { + const queryRunner = AppDataSource.createQueryRunner() + await queryRunner.connect() try { const validLicense = await hasValidLicense() if (!validLicense) { return { + attackTypeCount: {} as Record, attacks: [], totalAttacks: 0, + totalEndpoints: 0, validLicense: false, } } - const attackRepository = AppDataSource.getRepository(Attack) + const totalEndpointsQb = queryRunner.manager + .createQueryBuilder() + .select([ + 'CAST(COUNT(DISTINCT("apiEndpointUuid")) AS INTEGER) as "totalEndpoints"', + ]) + .from(Attack, "attacks") + const attackTypeCountQb = queryRunner.manager + .createQueryBuilder() + .select(['"attackType"', "CAST(COUNT(*) AS INTEGER) as count"]) + .from(Attack, "attacks") + let whereConditions: FindOptionsWhere = {} if (getAttackParams?.riskScores) { @@ -27,15 +41,40 @@ export const getAttacks = async ( ...whereConditions, riskScore: In(getAttackParams.riskScores), } + totalEndpointsQb.where('"riskScore" IN(:...scores)', { + scores: getAttackParams.riskScores, + }) + attackTypeCountQb.where('"riskScore" IN(:...scores)', { + scores: getAttackParams.riskScores, + }) + } + if (getAttackParams?.hosts) { + whereConditions = { + ...whereConditions, + host: In(getAttackParams.hosts), + } + totalEndpointsQb.andWhere("host IN(:...hosts)", { + hosts: getAttackParams.hosts, + }) + attackTypeCountQb.andWhere("host IN(:...hosts)", { + hosts: getAttackParams.hosts, + }) } - const resp = await attackRepository.findAndCount({ + const totalEndpointsRes = await totalEndpointsQb.getRawOne() + const attackTypeCountRes = await attackTypeCountQb + .groupBy('"attackType"') + .orderBy('"attackType"') + .getRawMany() + const resp = await queryRunner.manager.findAndCount(Attack, { where: whereConditions, relations: { apiEndpoint: true, }, order: { riskScore: "DESC", + resolved: "DESC", + startTime: "DESC", }, skip: getAttackParams?.offset ?? 0, take: getAttackParams?.limit ?? 10, @@ -44,10 +83,16 @@ export const getAttacks = async ( return { attacks: resp[0], totalAttacks: resp[1], + totalEndpoints: totalEndpointsRes?.totalEndpoints, + attackTypeCount: Object.fromEntries( + attackTypeCountRes.map(e => [e.attackType, e.count]), + ) as any, validLicense: true, } } catch (err) { console.error(`Error Getting Attacks: ${err}`) throw new Error500InternalServer(err) + } finally { + await queryRunner.release() } } diff --git a/common/src/maps.ts b/common/src/maps.ts index 06a6ab1a..80299fad 100644 --- a/common/src/maps.ts +++ b/common/src/maps.ts @@ -5,6 +5,7 @@ import { DataClass, RiskScore, AlertType, + AttackType, } from "./enums" export const AWS_NEXT_STEP: Record = { @@ -86,3 +87,11 @@ export const ALERT_TYPE_TO_RISK_SCORE: Record = { [AlertType.BASIC_AUTHENTICATION_DETECTED]: RiskScore.MEDIUM, [AlertType.UNSECURED_ENDPOINT_DETECTED]: RiskScore.HIGH, } + +export const ATTACK_TYPE_TO_RISK_SCORE: Record = { + [AttackType.HIGH_ERROR_RATE]: RiskScore.HIGH, + [AttackType.ANOMALOUS_CALL_ORDER]: RiskScore.MEDIUM, + [AttackType.BOLA]: RiskScore.HIGH, + [AttackType.HIGH_USAGE_SENSITIVE_ENDPOINT]: RiskScore.HIGH, + [AttackType.UNAUTHENTICATED_ACCESS]: RiskScore.HIGH, +} \ No newline at end of file diff --git a/common/src/types.ts b/common/src/types.ts index 566b2386..40220b6d 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -91,6 +91,7 @@ export interface GetVulnerabilityAggParams { } export interface GetAttackParams { + hosts?: string[] riskScores?: RiskScore[] offset?: number limit?: number @@ -310,6 +311,7 @@ export interface Attack { sourceIP: string apiEndpointUuid: string apiEndpoint: ApiEndpoint + host: string resolved: boolean snoozed: boolean @@ -317,8 +319,10 @@ export interface Attack { } export interface AttackResponse { + attackTypeCount: Record attacks: Attack[] totalAttacks: number + totalEndpoints: number validLicense: boolean } diff --git a/frontend/src/api/attacks/index.ts b/frontend/src/api/attacks/index.ts index a1285456..d25d0b0e 100644 --- a/frontend/src/api/attacks/index.ts +++ b/frontend/src/api/attacks/index.ts @@ -1,16 +1,11 @@ import axios from "axios" -import { GetAttackParams } from "@common/types" +import { AttackResponse, GetAttackParams } from "@common/types" import { getAPIURL } from "~/constants" -interface GetAttacksResp { - validLicense: boolean - attacks: any[] -} - export const getAttacks = async ( params: GetAttackParams, -): Promise => { - const resp = await axios.get(`${getAPIURL()}/attacks`, { +): Promise => { + const resp = await axios.get(`${getAPIURL()}/attacks`, { params, }) return resp.data diff --git a/frontend/src/components/Protection/AggAttackChart.tsx b/frontend/src/components/Protection/AggAttackChart.tsx new file mode 100644 index 00000000..3ae12c58 --- /dev/null +++ b/frontend/src/components/Protection/AggAttackChart.tsx @@ -0,0 +1,129 @@ +import React from "react" +import { + Chart as ChartJS, + ArcElement, + Tooltip, + Legend, + ChartOptions, +} from "chart.js" +import { Doughnut } from "react-chartjs-2" +import { + HStack, + StackProps, + Grid, + GridItem, + Text, + VStack, + Box, + StackDivider, +} from "@chakra-ui/react" +import { AttackType } from "@common/enums" +import { PIE_BACKGROUND_COLORS, PIE_BORDER_COLORS } from "~/constants" + +ChartJS.register(ArcElement, Tooltip, Legend) + +interface AggAttackChartProps extends StackProps { + attackTypeCount: Record + totalAttacks: number + totalEndpoints: number +} + +export const AggAttackChart: React.FC = React.memo( + ({ totalAttacks, totalEndpoints, attackTypeCount, ...props }) => { + const data = Object.values(attackTypeCount) + const labels = Object.keys(attackTypeCount) + const chartData = { + labels, + datasets: [ + { + data, + backgroundColor: PIE_BACKGROUND_COLORS, + borderColor: PIE_BORDER_COLORS, + borderWidth: 1, + }, + ], + } + const options = { + responsive: true, + cutout: "60%", + plugins: { + legend: { + display: false, + }, + tooltip: { + caretSize: 0, + bodyFont: { + size: 11, + }, + }, + }, + } as ChartOptions + return ( + } + w="full" + h="60" + > + } spacing="0" h="full"> + + + {totalAttacks} + + + Attacks + + + + + {totalEndpoints} + + + Endpoints + + + + + + + + + + {labels.map((e, i) => ( + + + + {e} + + + ))} + + + + + ) + }, +) diff --git a/frontend/src/components/Protection/Filters.tsx b/frontend/src/components/Protection/Filters.tsx new file mode 100644 index 00000000..6ad41af5 --- /dev/null +++ b/frontend/src/components/Protection/Filters.tsx @@ -0,0 +1,83 @@ +import React from "react" +import { Text, Stack, Box } from "@chakra-ui/react" +import { Select } from "chakra-react-select" +import { GetAttackParams } from "@common/types" +import { RiskScore } from "@common/enums" + +interface AttackFilterProps { + hosts?: string[] + riskScores?: string[] + hostList: string[] + setParams: (t: (params: GetAttackParams) => GetAttackParams) => void +} + +const FilterHeader: React.FC<{ title: string }> = React.memo(({ title }) => ( + + {title} + +)) + +export const AttackFilters: React.FC = React.memo( + ({ hosts, riskScores, hostList, setParams }) => { + return ( + + + + ({ + label: riskScore, + value: riskScore, + })) + : undefined + } + isMulti={true} + size="sm" + options={Object.values(RiskScore).map(e => ({ + label: e, + value: e, + }))} + placeholder="Filter by risk..." + instanceId="attack-tbl-env-risk" + onChange={e => + setParams(params => ({ + ...params, + riskScores: e.map(riskScore => riskScore.label as RiskScore), + offset: 0, + })) + } + /> + + + ) + }, +) diff --git a/frontend/src/components/Protection/List.tsx b/frontend/src/components/Protection/List.tsx new file mode 100644 index 00000000..b8db8689 --- /dev/null +++ b/frontend/src/components/Protection/List.tsx @@ -0,0 +1,188 @@ +import React from "react" +import dynamic from "next/dynamic" +import { useRouter } from "next/router" +import { Badge, useColorMode, Box, HStack, Text } from "@chakra-ui/react" +import EmptyView from "components/utils/EmptyView" +import { TableColumn } from "react-data-table-component" +import { ATTACK_PAGE_LIMIT, RISK_TO_COLOR } from "~/constants" +import { + getCustomStyles, + rowStyles, + SkeletonCell, +} from "components/utils/TableUtils" +import { Attack } from "@common/types" +import { getDateTimeString } from "utils" +const DataTable = dynamic(() => import("react-data-table-component"), { + ssr: false, +}) + +interface AttackTableProps { + items: Attack[] + totalCount: number + setCurrentPage: (page: number) => void + currentPage: number + fetching: boolean +} + +interface TableLoaderProps { + currentPage: number + totalCount: number +} + +const TableLoader: React.FC = ({ + currentPage, + totalCount, +}) => { + const colorMode = useColorMode() + const loadingColumns: TableColumn[] = [ + { + name: "Attack Type", + id: "type", + grow: 2, + }, + { + name: "Risk Score", + id: "risk", + grow: 0, + }, + { + name: "Endpoint", + id: "endpoint", + grow: 2, + }, + { + name: "Host", + id: "host", + grow: 1, + }, + { + name: "Start Time", + id: "startTime", + grow: 1.5, + }, + { + name: "End Time", + id: "endTime", + grow: 1.5, + }, + ].map(e => ({ + ...e, + sortable: false, + cell: () => , + })) + + return ( + + { + return {} + })} + customStyles={getCustomStyles(colorMode.colorMode)} + pagination + paginationDefaultPage={currentPage} + /> + + ) +} + +export const List: React.FC = React.memo( + ({ items, totalCount, setCurrentPage, currentPage, fetching }) => { + const colorMode = useColorMode() + const router = useRouter() + const columns: TableColumn[] = [ + { + name: "Attack Type", + sortable: false, + selector: (row: Attack) => row.attackType, + id: "type", + grow: 2, + }, + { + name: "Risk Score", + sortable: false, + selector: (row: Attack) => row.riskScore, + cell: (row: Attack) => ( + + {row.riskScore} + + ), + id: "risk", + grow: 0, + }, + { + name: "Endpoint", + sortable: false, + selector: (row: Attack) => row.apiEndpointUuid, + cell: (row: Attack) => ( + + {row.apiEndpoint?.method} + {row.apiEndpoint.path} + + ), + id: "count", + grow: 2, + }, + { + name: "Host", + sortable: false, + selector: (row: Attack) => row.host, + id: "host", + grow: 1, + }, + { + name: "Start Time", + sortable: false, + selector: (row: Attack) => getDateTimeString(row.startTime), + id: "startTime", + grow: 1.5, + }, + { + name: "End Time", + sortable: false, + selector: (row: Attack) => getDateTimeString(row.endTime) || "N/A", + grow: 1.5, + }, + ] + + if (items.length == 0) { + return + } + if (items.length > 0) { + return ( + + } + paginationPerPage={ATTACK_PAGE_LIMIT} + columns={columns} + data={items} + customStyles={getCustomStyles(colorMode.colorMode, false, true)} + paginationDefaultPage={currentPage} + pagination + /> + ) + } + return null + }, +) diff --git a/frontend/src/components/Protection/index.tsx b/frontend/src/components/Protection/index.tsx new file mode 100644 index 00000000..14219f79 --- /dev/null +++ b/frontend/src/components/Protection/index.tsx @@ -0,0 +1,97 @@ +import React, { useState } from "react" +import { Box, Heading, useToast, VStack } from "@chakra-ui/react" +import { AttackResponse, GetAttackParams } from "@common/types" +import { ATTACK_PAGE_LIMIT } from "~/constants" +import { getAttacks } from "api/attacks" +import { ContentContainer } from "components/utils/ContentContainer" +import { AttackFilters } from "./Filters" +import { AggAttackChart } from "./AggAttackChart" +import { List } from "./List" + +interface ProtectionPageProps { + initAttackResponse: AttackResponse + hosts: string[] +} + +export const ProtectionPage: React.FC = React.memo( + ({ initAttackResponse, hosts }) => { + const [fetching, setFetching] = useState(false) + const [response, setResponse] = useState(initAttackResponse) + const [params, setParamsInner] = useState({ + hosts: [], + riskScores: [], + offset: 0, + limit: ATTACK_PAGE_LIMIT, + }) + const toast = useToast() + + const fetchAttacks = (fetchParams: GetAttackParams) => { + setFetching(true) + getAttacks(fetchParams) + .then(res => setResponse(res)) + .catch(err => + toast({ + title: "Fetching Protection Data failed...", + status: "error", + duration: 5000, + isClosable: true, + }), + ) + .finally(() => setFetching(false)) + } + + const setParams = (t: (params: GetAttackParams) => GetAttackParams) => { + const newParams = t(params) + setParamsInner(newParams) + fetchAttacks(newParams) + } + + const setCurrentPage = (page: number) => { + setParams(oldParams => ({ + ...oldParams, + offset: (page - 1) * ATTACK_PAGE_LIMIT, + })) + } + + return ( + + + + Protection + + + + + + + + + + + + + ) + }, +) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index dc1681b5..ef108127 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -71,3 +71,4 @@ export const getAPIURL = () => { export const ENDPOINT_PAGE_LIMIT = 10 export const ALERT_PAGE_LIMIT = 10 +export const ATTACK_PAGE_LIMIT = 10 diff --git a/frontend/src/pages/protection.tsx b/frontend/src/pages/protection.tsx index ebce5be4..0f8e8d04 100644 --- a/frontend/src/pages/protection.tsx +++ b/frontend/src/pages/protection.tsx @@ -5,13 +5,17 @@ import { SidebarLayoutShell } from "components/SidebarLayoutShell" import { ContentContainer } from "components/utils/ContentContainer" import { getAttacks } from "api/attacks" import { ProtectionEmptyView } from "components/Protection/ProtectionEmptyView" +import { ProtectionPage } from "components/Protection" +import { AttackResponse } from "@common/types" +import { getHosts } from "api/endpoints" -const Protection = ({ validLicense, attacks }) => { - const parsedAttacks = superjson.parse(attacks) +const Protection = ({ attacksResponse, hosts }) => { + const parsedAttacks = superjson.parse(attacksResponse) + const parsedHosts = superjson.parse(hosts) let page = ( - + ) - if (!validLicense) { + if (!parsedAttacks?.validLicense) { page = ( @@ -29,11 +33,16 @@ const Protection = ({ validLicense, attacks }) => { } export const getServerSideProps: GetServerSideProps = async context => { - const { attacks, validLicense } = await getAttacks({}) + const attacksPromise = getAttacks({}) + const hostsPromise = getHosts() + const [hosts, attacksResponse] = await Promise.all([ + hostsPromise, + attacksPromise, + ]) return { props: { - validLicense, - attacks: superjson.stringify(attacks), + attacksResponse: superjson.stringify(attacksResponse), + hosts: superjson.stringify(hosts), }, } }