Add risk list to mitigations

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-04-01 10:19:01 +02:00
parent 747ed7ec9b
commit 543958175b
4 changed files with 495 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file.
- Add import framework support.
- Add risk object management.
- Add risk template.
- Add mapping between control and risk.
- Add mapping between control and risk.
### Changed

View File

@@ -38,6 +38,7 @@ import {
Link as LinkIcon,
CheckSquare,
ShieldCheck,
AlertTriangle,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
@@ -88,7 +89,10 @@ import { MitigationViewAssignTaskMutation as MitigationViewAssignTaskMutationTyp
import { MitigationViewUnassignTaskMutation as MitigationViewUnassignTaskMutationType } from "./__generated__/MitigationViewUnassignTaskMutation.graphql";
import { MitigationViewUpdateMitigationStateMutation as MitigationViewUpdateMitigationStateMutationType } from "./__generated__/MitigationViewUpdateMitigationStateMutation.graphql";
import { MitigationViewQuery as MitigationViewQueryType } from "./__generated__/MitigationViewQuery.graphql";
import { MitigationViewOrganizationQuery$data } from "./__generated__/MitigationViewOrganizationQuery.graphql";
import {
MitigationViewOrganizationQuery,
MitigationViewOrganizationQuery$data,
} from "./__generated__/MitigationViewOrganizationQuery.graphql";
import {
MitigationViewFrameworksQuery,
MitigationViewFrameworksQuery$data,
@@ -97,6 +101,10 @@ import {
MitigationViewLinkedControlsQuery,
MitigationViewLinkedControlsQuery$data,
} from "./__generated__/MitigationViewLinkedControlsQuery.graphql";
import {
MitigationViewRisksQuery,
MitigationViewRisksQuery$data,
} from "./__generated__/MitigationViewRisksQuery.graphql";
// Function to format ISO8601 duration to human-readable format
const formatDuration = (isoDuration: string): string => {
@@ -408,6 +416,30 @@ const deleteControlMappingMutation = graphql`
}
`;
// Add query to fetch risks linked to a mitigation
const mitigationRisksQuery = graphql`
query MitigationViewRisksQuery($mitigationId: ID!) {
mitigation: node(id: $mitigationId) {
id
... on Mitigation {
risks(first: 100) @connection(key: "MitigationView_risks") {
edges {
node {
id
name
description
probability
impact
createdAt
updatedAt
}
}
}
}
}
}
`;
// Define type for framework structure
type FrameworkEdge = {
node: {
@@ -433,6 +465,29 @@ type ControlNode = {
description?: string;
};
// Define Risk type
type RiskNode = {
id: string;
name: string;
description: string;
probability: number;
impact: number;
createdAt: string;
updatedAt: string;
};
// Define the risks data type
type MitigationRisksData = {
mitigation?: {
id: string;
risks?: {
edges: Array<{
node: RiskNode;
}>;
};
};
};
function MitigationViewContent({
queryRef,
}: {
@@ -508,6 +563,25 @@ function MitigationViewContent({
}
}, [environment, mitigationId]);
// Add state for risks data
const [risksData, setRisksData] = useState<MitigationRisksData | null>(null);
// Load risks data when component mounts
useEffect(() => {
if (mitigationId) {
fetchQuery(environment, mitigationRisksQuery, { mitigationId }).subscribe(
{
next: (data: unknown) => {
setRisksData(data as MitigationRisksData);
},
error: (error: Error) => {
console.error("Error fetching risks:", error);
},
}
);
}
}, [environment, mitigationId]);
const formatImportance = (importance: string | undefined): string => {
if (!importance) return "";
@@ -1618,6 +1692,47 @@ function MitigationViewContent({
);
}, [controlSearchQuery, getControls]);
// Helper function to get risk severity color
const getRiskSeverityColor = (
probability: number,
impact: number
): string => {
const severity = probability * impact;
if (severity >= 0.75) return "bg-red-100 text-red-800";
if (severity >= 0.5) return "bg-orange-100 text-orange-800";
if (severity >= 0.25) return "bg-yellow-100 text-yellow-800";
return "bg-green-100 text-green-800";
};
// Helper function to get risk severity text
const getRiskSeverityText = (probability: number, impact: number): string => {
const severity = probability * impact;
if (severity >= 0.75) return "Critical";
if (severity >= 0.5) return "High";
if (severity >= 0.25) return "Medium";
return "Low";
};
// Format probability as text
const formatProbability = (value: number): string => {
if (value <= 0.1) return "Very Low";
if (value <= 0.3) return "Low";
if (value <= 0.5) return "Medium";
if (value <= 0.7) return "High";
return "Very High";
};
// Format impact as text
const formatImpact = (value: number): string => {
if (value <= 0.1) return "Very Low";
if (value <= 0.3) return "Low";
if (value <= 0.5) return "Medium";
if (value <= 0.7) return "High";
return "Very High";
};
return (
<PageTemplate
title={data.mitigation.name ?? ""}
@@ -1692,6 +1807,16 @@ function MitigationViewContent({
</span>
)}
</TabsTrigger>
<TabsTrigger value="risks" className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Risks
{risksData?.mitigation?.risks?.edges &&
risksData.mitigation.risks.edges.length > 0 && (
<span className="ml-1.5 bg-blue-100 text-blue-800 rounded-full text-xs px-2 py-0.5">
{risksData.mitigation.risks.edges.length}
</span>
)}
</TabsTrigger>
</TabsList>
{/* Controls Tab Content */}
@@ -2490,6 +2615,88 @@ function MitigationViewContent({
</div>
</div>
</TabsContent>
{/* Risks Tab Content */}
<TabsContent value="risks">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Risks</h2>
</div>
<Card>
<CardContent className="p-4">
{risksData?.mitigation?.risks?.edges &&
risksData.mitigation.risks.edges.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left font-medium text-sm py-2 px-4">
Risk Name
</th>
<th className="text-left font-medium text-sm py-2 px-4">
Probability
</th>
<th className="text-left font-medium text-sm py-2 px-4">
Impact
</th>
<th className="text-left font-medium text-sm py-2 px-4">
Severity
</th>
</tr>
</thead>
<tbody>
{risksData.mitigation.risks.edges.map(({ node }) => (
<tr
key={node.id}
className="border-b hover:bg-gray-50"
onClick={() =>
navigate(
`/organizations/${organizationId}/risks/${node.id}`
)
}
style={{ cursor: "pointer" }}
>
<td className="py-3 px-4 font-medium text-sm">
{node.name}
</td>
<td className="py-3 px-4">
<div className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full text-xs inline-block">
{formatProbability(node.probability)}
</div>
</td>
<td className="py-3 px-4">
<div className="bg-purple-100 text-purple-800 px-2 py-0.5 rounded-full text-xs inline-block">
{formatImpact(node.impact)}
</div>
</td>
<td className="py-3 px-4">
<div
className={`${getRiskSeverityColor(
node.probability,
node.impact
)} px-2 py-0.5 rounded-full text-xs inline-block`}
>
{getRiskSeverityText(
node.probability,
node.impact
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
No risks linked to this mitigation yet.
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* Right task panel */}

View File

@@ -0,0 +1,286 @@
/**
* @generated SignedSource<<7ecc796e34bf88aa378e81249110be90>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MitigationViewRisksQuery$variables = {
mitigationId: string;
};
export type MitigationViewRisksQuery$data = {
readonly mitigation: {
readonly id: string;
readonly risks?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly impact: number;
readonly name: string;
readonly probability: number;
readonly updatedAt: string;
};
}>;
};
};
};
export type MitigationViewRisksQuery = {
response: MitigationViewRisksQuery$data;
variables: MitigationViewRisksQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "mitigationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "mitigationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "probability",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "impact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MitigationViewRisksQuery",
"selections": [
{
"alias": "mitigation",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": "risks",
"args": null,
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "__MitigationView_risks_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"type": "Mitigation",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MitigationViewRisksQuery",
"selections": [
{
"alias": "mitigation",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "risks(first:100)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "MitigationView_risks",
"kind": "LinkedHandle",
"name": "risks"
}
],
"type": "Mitigation",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "61c985f00c12fb3cdab3e345a8c65957",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"mitigation",
"risks"
]
}
]
},
"name": "MitigationViewRisksQuery",
"operationKind": "query",
"text": "query MitigationViewRisksQuery(\n $mitigationId: ID!\n) {\n mitigation: node(id: $mitigationId) {\n __typename\n id\n ... on Mitigation {\n risks(first: 100) {\n edges {\n node {\n id\n name\n description\n probability\n impact\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e05802ec519933edce3d0f3487742e3d";
export default node;

View File

@@ -79,7 +79,6 @@ WITH rsks AS (
SELECT
id,
organization_id,
tenant_id,
name,
description,
probability,