diff --git a/backend/src/api/alert/index.ts b/backend/src/api/alert/index.ts index 7745160b..909f084b 100644 --- a/backend/src/api/alert/index.ts +++ b/backend/src/api/alert/index.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express" import { AlertService } from "services/alert" -import { GetAlertParams } from "@common/types" +import { GetAlertParams, UpdateAlertParams } from "@common/types" import ApiResponseHandler from "api-response-handler" export const getAlertsHandler = async ( @@ -16,18 +16,15 @@ export const getAlertsHandler = async ( } } -export const resolveAlertHandler = async ( +export const updateAlertHandler = async ( req: Request, res: Response, ): Promise => { try { const { alertId } = req.params - const { resolutionMessage } = req.body - const resolvedAlert = await AlertService.resolveAlert( - alertId, - resolutionMessage, - ) - await ApiResponseHandler.success(res, resolvedAlert) + const updateAlertParams: UpdateAlertParams = req.body + const updatedAlert = await AlertService.updateAlert(alertId, updateAlertParams) + await ApiResponseHandler.success(res, updatedAlert) } catch (err) { await ApiResponseHandler.error(res, err) } diff --git a/backend/src/constants.ts b/backend/src/constants.ts index 91c8a846..f3927e95 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -17,7 +17,7 @@ export const DATA_CLASS_TO_RISK_SCORE: Record = { export const ALERT_TYPE_TO_RISK_SCORE: Record = { [AlertType.NEW_ENDPOINT]: RiskScore.LOW, - [AlertType.OPEN_API_SPEC_DIFF]: RiskScore.LOW, + [AlertType.OPEN_API_SPEC_DIFF]: RiskScore.MEDIUM, [AlertType.PII_DATA_DETECTED]: RiskScore.HIGH, [AlertType.UNDOCUMENTED_ENDPOINT]: RiskScore.LOW, } diff --git a/backend/src/index.ts b/backend/src/index.ts index 70eb36ba..e822c39c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,7 +24,7 @@ import { import { getAlertsHandler, getTopAlertsHandler, - resolveAlertHandler, + updateAlertHandler, } from "api/alert" import { updateDataFieldClasses } from "api/data-field" import { getSummaryHandler } from "api/summary" @@ -91,7 +91,7 @@ app.post("/api/v1/data-field/:dataFieldId/update-classes", updateDataFieldClasse app.get("/api/v1/alerts", getAlertsHandler) app.get("/api/v1/topAlerts", getTopAlertsHandler) -app.put("/api/v1/alert/resolve/:alertId", resolveAlertHandler) +app.put("/api/v1/alert/:alertId", updateAlertHandler) app.post("/api/v1/setup_connection", setup_connection) app.post("/api/v1/setup_connection/aws/os", aws_os_choices) diff --git a/backend/src/models/alert.ts b/backend/src/models/alert.ts index c283886e..bf03a048 100644 --- a/backend/src/models/alert.ts +++ b/backend/src/models/alert.ts @@ -7,7 +7,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm" -import { AlertType, RiskScore } from "@common/enums" +import { AlertType, RiskScore, Status } from "@common/enums" import { ApiEndpoint } from "models/api-endpoint" @Entity() @@ -36,8 +36,8 @@ export class Alert extends BaseEntity { @UpdateDateColumn({ type: "timestamptz" }) updatedAt: Date - @Column({ type: "boolean", default: false }) - resolved: boolean + @Column({ type: "enum", enum: Status, default: Status.OPEN }) + status: Status @Column({ nullable: true }) resolutionMessage: string diff --git a/backend/src/services/alert/index.ts b/backend/src/services/alert/index.ts index 1c1ce70e..aa313661 100644 --- a/backend/src/services/alert/index.ts +++ b/backend/src/services/alert/index.ts @@ -1,58 +1,147 @@ -import { FindOptionsWhere } from "typeorm" +import { FindOptionsWhere, FindManyOptions, In, FindOptionsOrder, Not } from "typeorm" import { AppDataSource } from "data-source" import { Alert, ApiEndpoint, ApiTrace } from "models" -import { AlertType } from "@common/enums" -import { ALERT_TYPE_TO_RISK_SCORE, RISK_SCORE_ORDER_QUERY } from "~/constants" -import { GetAlertParams, Alert as AlertResponse } from "@common/types" +import { AlertType, SpecExtension, Status, UpdateAlertType } from "@common/enums" +import { ALERT_TYPE_TO_RISK_SCORE } from "~/constants" +import { GetAlertParams, Alert as AlertResponse, UpdateAlertParams } from "@common/types" +import Error409Conflict from "errors/error-409-conflict" +import Error500InternalServer from "errors/error-500-internal-server" export class AlertService { + static async updateAlert( + alertId: string, + updateAlertParams: UpdateAlertParams + ): Promise { + const alertRepository = AppDataSource.getRepository(Alert) + const alert = await alertRepository.findOne({ + where: { + uuid: alertId, + }, + relations: { + apiEndpoint: true, + } + }) + switch (updateAlertParams.updateType) { + case UpdateAlertType.IGNORE: + if (alert.status === Status.IGNORED) { + throw new Error409Conflict("Alert is already being ignored.") + } else if (alert.status === Status.RESOLVED) { + throw new Error409Conflict("Alert is resolved and cannot be ignored.") + } + alert.status = Status.IGNORED + break + case UpdateAlertType.UNIGNORE: + if (alert.status !== Status.IGNORED) { + throw new Error409Conflict("Alert is currently not ignored.") + } + alert.status = Status.OPEN + break + case UpdateAlertType.RESOLVE: + if (alert.status === Status.RESOLVED) { + throw new Error409Conflict("Alert is already resolved.") + } else if (alert.status === Status.IGNORED) { + throw new Error409Conflict("Alert is ignored and cannot be resolved.") + } + alert.status = Status.RESOLVED + alert.resolutionMessage = updateAlertParams.resolutionMessage?.trim() || null + break + case UpdateAlertType.UNRESOLVE: + if (alert.status !== Status.RESOLVED) { + throw new Error409Conflict("Alert is currently not resolved.") + } + alert.status = Status.OPEN + break + default: + throw new Error500InternalServer("Unknown update type.") + } + return await alertRepository.save(alert) + } + static async getAlerts( alertParams: GetAlertParams, ): Promise<[AlertResponse[], number]> { const alertRepository = AppDataSource.getRepository(Alert) + let whereConditions: FindOptionsWhere[] | FindOptionsWhere = {}; + let paginationParams: FindManyOptions = {}; + let orderParams: FindOptionsOrder = {}; - let alertsQb = alertRepository - .createQueryBuilder("alert") - .leftJoinAndSelect("alert.apiEndpoint", "apiEndpoint") - + if (alertParams?.apiEndpointUuid) { + whereConditions = { + ...whereConditions, + apiEndpointUuid: alertParams.apiEndpointUuid + } + } if (alertParams?.alertTypes) { - alertsQb = alertsQb.where("alert.type IN (:...types)", { - types: alertParams.alertTypes, - }) + whereConditions = { + ...whereConditions, + type: In(alertParams.alertTypes), + } } if (alertParams?.riskScores) { - alertsQb = alertsQb.andWhere("alert.riskScore IN (:...scores)", { - scores: alertParams.riskScores, - }) + whereConditions = { + ...whereConditions, + riskScore: In(alertParams.riskScores), + } } - if (alertParams?.resolved) { - alertsQb = alertsQb.andWhere("alert.resolved = :resolved", { - resolved: alertParams.resolved, - }) + if (alertParams?.status) { + whereConditions = { + ...whereConditions, + status: In(alertParams.status) + } } - alertsQb = alertsQb - .orderBy(RISK_SCORE_ORDER_QUERY("alert", "riskScore"), "DESC") - .addOrderBy("alert.createdAt", "DESC") if (alertParams?.offset) { - alertsQb = alertsQb.offset(alertParams.offset) + paginationParams = { + ...paginationParams, + skip: alertParams.offset, + } } if (alertParams?.limit) { - alertsQb = alertsQb.limit(alertParams.limit) + paginationParams = { + ...paginationParams, + take: alertParams.limit, + } + } + if (alertParams?.order) { + orderParams = { + riskScore: alertParams.order + } + } else { + orderParams = { + riskScore: "DESC" + } } - return await alertsQb.getManyAndCount() + const alerts = await alertRepository.findAndCount({ + where: whereConditions, + ...paginationParams, + relations: { + apiEndpoint: true, + }, + order: { + status: "ASC", + ...orderParams, + createdAt: "DESC", + }, + }); + + return alerts; } static async getTopAlerts(): Promise { const alertRepository = AppDataSource.getRepository(Alert) - return await alertRepository - .createQueryBuilder("alert") - .leftJoinAndSelect("alert.apiEndpoint", "apiEndpoint") - .where("alert.resolved = false") - .orderBy(RISK_SCORE_ORDER_QUERY("alert", "riskScore"), "DESC") - .addOrderBy("alert.createdAt", "DESC") - .limit(20) - .getMany() + return await alertRepository.find({ + where: { + status: Status.OPEN + }, + relations: { + apiEndpoint: true + }, + order: { + riskScore: "DESC", + createdAt: "DESC" + }, + take: 20 + }); } static async getAlert(alertId: string): Promise { @@ -76,7 +165,7 @@ export class AlertService { return await alertRepository.findOneBy({ apiEndpointUuid, type, - resolved: false, + status: Not(Status.RESOLVED), description, }) } @@ -128,6 +217,8 @@ export class AlertService { alertItems: Record, apiEndpointUuid: string, apiTrace: ApiTrace, + specString: string, + specExtension: SpecExtension, ): Promise { if (!alertItems) { return [] @@ -151,6 +242,8 @@ export class AlertService { newAlert.context = { pathPointer: alertItems[key], trace: apiTrace, + spec: specString, + specExtension, } newAlert.description = key alerts.push(newAlert) @@ -165,7 +258,7 @@ export class AlertService { ): Promise { const alertRepository = AppDataSource.getRepository(Alert) const existingAlert = await alertRepository.findOneBy({ uuid: alertId }) - existingAlert.resolved = true + existingAlert.status = Status.RESOLVED existingAlert.resolutionMessage = resolutionMessage || "" return await alertRepository.save(existingAlert) } diff --git a/backend/src/services/get-endpoints/index.ts b/backend/src/services/get-endpoints/index.ts index 861e635d..5ddbcb2f 100644 --- a/backend/src/services/get-endpoints/index.ts +++ b/backend/src/services/get-endpoints/index.ts @@ -107,7 +107,7 @@ export class GetEndpointsService { relations: { dataFields: true, openapiSpec: true, - alerts: true, + alerts: true }, order: { dataFields: { diff --git a/backend/src/services/spec/index.ts b/backend/src/services/spec/index.ts index 5846c3fe..f47ba931 100644 --- a/backend/src/services/spec/index.ts +++ b/backend/src/services/spec/index.ts @@ -329,6 +329,8 @@ export class SpecService { errorItems, endpoint.uuid, trace, + openApiSpec.spec, + openApiSpec.extension, ) } catch (err) { console.error(`Error finding OpenAPI Spec diff: ${err}`) diff --git a/backend/src/services/summary/index.ts b/backend/src/services/summary/index.ts index 59798301..ad304204 100644 --- a/backend/src/services/summary/index.ts +++ b/backend/src/services/summary/index.ts @@ -1,4 +1,4 @@ -import { DataTag, RiskScore } from "@common/enums" +import { DataTag, RiskScore, Status } from "@common/enums" import { Alert, ApiEndpoint, DataField } from "models" import { AppDataSource } from "data-source" import { Summary as SummaryResponse } from "@common/types" @@ -10,9 +10,9 @@ export class SummaryService { const dataFieldRepository = AppDataSource.getRepository(DataField) const highRiskAlerts = await alertRepository.countBy({ riskScore: RiskScore.HIGH, - resolved: false, + status: Status.OPEN, }) - const newAlerts = await alertRepository.countBy({ resolved: false }) + const newAlerts = await alertRepository.countBy({ status: Status.OPEN }) const endpointsTracked = await apiEndpointRepository.count({}) const piiDataFields = await dataFieldRepository.countBy({ dataTag: DataTag.PII, diff --git a/common/src/enums.ts b/common/src/enums.ts index af16f154..8374e44e 100644 --- a/common/src/enums.ts +++ b/common/src/enums.ts @@ -97,3 +97,16 @@ export enum TestTags { MASS_ASSIGNMENT = "Mass Assignment", SECURITY_MISCONFIGURATION = "Security Misconfiguration", } + +export enum Status { + RESOLVED = "Resolved", + IGNORED = "Ignored", + OPEN = "Open", +} + +export enum UpdateAlertType { + RESOLVE = "resolve", + UNRESOLVE = "unresolve", + IGNORE = "ignore", + UNIGNORE = "unignore", +} diff --git a/common/src/types.ts b/common/src/types.ts index cc61b8d2..937d7f77 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -10,7 +10,9 @@ import { RestMethod, RiskScore, SpecExtension, + Status, STEPS, + UpdateAlertType, } from "./enums" import "axios" @@ -72,11 +74,13 @@ export interface GetEndpointParams { } export interface GetAlertParams { + apiEndpointUuid?: string riskScores?: RiskScore[] - resolved?: boolean + status?: Status[] alertTypes?: AlertType[] offset?: number limit?: number + order?: "DESC" | "ASC" } export interface UpdateDataFieldClassesParams { @@ -89,6 +93,11 @@ export interface UpdateDataFieldParams { isRisk: boolean } +export interface UpdateAlertParams { + updateType: UpdateAlertType + resolutionMessage?: string +} + export type JSONValue = | string | number @@ -121,7 +130,7 @@ export interface Alert { description: string createdAt: Date updatedAt: Date - resolved: boolean + status: Status resolutionMessage: string context: object } diff --git a/frontend/package.json b/frontend/package.json index 80fad425..94b2823e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@chakra-ui/icons": "^2.0.6", + "@atlaskit/pagination": "^14.1.10", "@chakra-ui/react": "^2.2.1", "@chakra-ui/theme-tools": "^2.0.7", "@emotion/react": "^11.9.0", @@ -22,6 +23,8 @@ "date-fns": "^2.29.1", "framer-motion": "^6.3.0", "js-yaml": "^4.1.0", + "js-yaml-source-map": "^0.2.2", + "json-source-map": "^0.6.1", "luxon": "^3.0.1", "next": "latest", "prism-react-renderer": "^1.3.5", diff --git a/frontend/src/api/alerts/index.ts b/frontend/src/api/alerts/index.ts index 1152f67a..6360d28f 100644 --- a/frontend/src/api/alerts/index.ts +++ b/frontend/src/api/alerts/index.ts @@ -1,5 +1,5 @@ import axios from "axios" -import { GetAlertParams, Alert } from "@common/types" +import { GetAlertParams, Alert, UpdateAlertParams } from "@common/types" import { getAPIURL } from "~/constants" export const getAlerts = async ( @@ -37,3 +37,22 @@ export const resolveAlert = async ( return null } } + +export const updateAlert = async ( + alertId: string, + updateAlertParams: UpdateAlertParams, +) => { + try { + const resp = await axios.put( + `${getAPIURL()}/alert/${alertId}`, + updateAlertParams + ) + if (resp.status === 200 && resp.data) { + return resp.data + } + return null + } catch (err) { + console.error(`Error updating alert: ${err}`) + return null + } +} diff --git a/frontend/src/components/Alert/AlertComponent.tsx b/frontend/src/components/Alert/AlertComponent.tsx new file mode 100644 index 00000000..728e1546 --- /dev/null +++ b/frontend/src/components/Alert/AlertComponent.tsx @@ -0,0 +1,103 @@ +import { useState } from "react" +import { Badge, Box, Heading, HStack, VStack, Button, useDisclosure, Modal, ModalOverlay, ModalHeader, ModalContent, ModalCloseButton, ModalBody, ModalFooter } from "@chakra-ui/react" +import { RiEyeOffFill } from "@react-icons/all-files/ri/RiEyeOffFill" +import { RiEyeFill } from "@react-icons/all-files/ri/RiEyeFill" +import { AiOutlineExclamationCircle } from "@react-icons/all-files/ai/AiOutlineExclamationCircle" +import { FiCheckCircle } from "@react-icons/all-files/fi/FiCheckCircle" +import { AiOutlineFileSearch } from "@react-icons/all-files/ai/AiOutlineFileSearch" +import { Alert, UpdateAlertParams } from "@common/types" +import { Status, UpdateAlertType } from "@common/enums" +import { RISK_TO_COLOR } from "~/constants" +import { AlertPanel } from "./AlertPanel" +import { AlertDetail } from "./AlertDetail" + +interface AlertComponentProps { + alert: Alert + handleUpdateAlert: (alertId: string, updateAlertParams: UpdateAlertParams) => Promise + updating: boolean +} + +export const AlertComponent: React.FC = ({ alert, handleUpdateAlert, updating }) => { + const { isOpen, onOpen, onClose } = useDisclosure() + const [resolutionMessage, setResolutionMessage] = useState(alert.resolutionMessage) + + return ( + + + + + + {alert.type} + {alert.status === Status.IGNORED && } + {alert.status === Status.RESOLVED && } + + + {alert.riskScore} + + + + + {alert.status !== Status.RESOLVED && } + + + + + + + {alert.type} + + + {alert &&} + + {alert.status !== Status.IGNORED && + {alert.status !== Status.RESOLVED ? ( + + ) : ( + + )} + } + + + + ) +} diff --git a/frontend/src/components/Alert/AlertDetail.tsx b/frontend/src/components/Alert/AlertDetail.tsx new file mode 100644 index 00000000..cb07a919 --- /dev/null +++ b/frontend/src/components/Alert/AlertDetail.tsx @@ -0,0 +1,229 @@ +import { useEffect, useRef, useState } from "react"; +import { Modal, Box, Grid, GridItem, VStack, Text, Code, HStack, Badge , useColorMode, useColorModeValue, Textarea} from "@chakra-ui/react"; +import jsonMap from "json-source-map" +import yaml from "js-yaml" +import SourceMap from "js-yaml-source-map" +import darkTheme from "prism-react-renderer/themes/duotoneDark" +import lightTheme from "prism-react-renderer/themes/github" +import Highlight, { defaultProps } from "prism-react-renderer" +import { AlertType, SpecExtension, Status } from "@common/enums"; +import { Alert } from "@common/types" +import { getDateTimeString } from "utils" +import { METHOD_TO_COLOR, RISK_TO_COLOR, STATUS_TO_COLOR } from "~/constants" +import { SpecDiffContext } from "./AlertPanel"; +import { TraceView } from "components/Endpoint/TraceDetail" + +interface AlertDetailProps { + alert: Alert + resolutionMessage: string + setResolutionMessage: React.Dispatch> +} + +export const AlertDetail: React.FC = ({ alert, resolutionMessage, setResolutionMessage }) => { + const colorMode = useColorMode().colorMode + const theme = useColorModeValue(lightTheme, darkTheme) + const panelColor = useColorModeValue("#F6F8FA", "#2A2734") + const scrollRef = useRef(null) + const topDivRef = useRef(null) + let panel = null + + const executeScroll = () => { + scrollRef.current?.scrollIntoView() + topDivRef.current?.scrollIntoView() + } + + useEffect(() => { + executeScroll() + }, []) + + switch (alert.type) { + case AlertType.OPEN_API_SPEC_DIFF: + let lineNumber = null + const context = alert.context as SpecDiffContext + const trace = context.trace + if (context.specExtension) { + switch (context.specExtension) { + case SpecExtension.JSON: + const result = jsonMap.parse(context.spec) + let pathKey = "" + for (let i = 0; i < context.pathPointer?.length; i++) { + let pathToken = context.pathPointer[i] + pathToken = pathToken.replaceAll("/", "~1") + pathKey += `/${pathToken}` + } + lineNumber = result.pointers?.[pathKey]?.key?.line + if (lineNumber) { + lineNumber += 1 + } + break + case SpecExtension.YAML: + const map = new SourceMap() + yaml.load(context.spec, { listener: map.listen() }) + lineNumber = map.lookup(context.pathPointer).line + if (lineNumber) { + lineNumber -= 1 + } + break + default: + break + } + } + panel = ( + + + Spec + + + {({ className, style, tokens, getLineProps, getTokenProps }) => { + return ( +
+                      {tokens.map((line, i) => {
+                        const lineProps = getLineProps({ line, key: i })
+                        if (i + 1 === lineNumber) {
+                          lineProps.className = `${lineProps.className} highlight-line ${colorMode}`
+                        }
+                        return (
+                          
+                            
+                              {i + 1}
+                            
+                            
+                              {line.map((token, key) => (
+                                
+                              ))}
+                            
+                          
+ ) + })} +
+ ) + }} +
+
+
+ + Differing Trace + + + Request Path + {trace.path} + + + + +
+ ) + break + default: + panel = + } + + return ( + + + + + + Status + + {alert.status} + + + + + + Time + + {getDateTimeString(alert.createdAt)} + + + + + + Endpoint + + + {alert.apiEndpoint.method.toUpperCase()} + + + {alert.apiEndpoint.path} + + + + + + + Risk Score + + {alert.riskScore} + + + + + + Description + + {alert.description} + + + {panel} + {alert.status !== Status.IGNORED && + Resolution Reason +