diff --git a/app/public/manifest.json b/app/public/manifest.json
index 7dd5b7c4..e324eea2 100644
--- a/app/public/manifest.json
+++ b/app/public/manifest.json
@@ -3,7 +3,7 @@
"name": "Gram - Threat Modelling",
"icons": [
{
- "src": "icon/gram_logo.svg",
+ "src": "gram_logo.svg",
"sizes": "any"
}
],
diff --git a/app/src/components/elements/CollapsePaper.js b/app/src/components/elements/CollapsePaper.js
new file mode 100644
index 00000000..71c1035e
--- /dev/null
+++ b/app/src/components/elements/CollapsePaper.js
@@ -0,0 +1,60 @@
+import {
+ KeyboardArrowDownRounded,
+ KeyboardArrowUpRounded,
+} from "@mui/icons-material";
+import { Badge, Box, Collapse, IconButton, Paper } from "@mui/material";
+import { useState } from "react";
+
+export function CollapsePaper({
+ title,
+ count,
+ children,
+ defaultExpanded = false,
+ sx,
+}) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+
+ return (
+
+ {
+ if (e.target === e.currentTarget) {
+ setExpanded(!expanded);
+ }
+ }}
+ >
+ setExpanded(!expanded)}
+ sx={{
+ alignItems: "center",
+ gap: "10px",
+ "& span": {
+ position: "relative",
+ transform: "scale(1)",
+ backgroundColor: "dimgray",
+ },
+ }}
+ >
+ {title}
+
+ setExpanded(!expanded)}
+ >
+ {expanded ? : }
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/src/components/elements/ColorSlider.js b/app/src/components/elements/ColorSlider.js
index 36cf3ec6..90ed50fd 100644
--- a/app/src/components/elements/ColorSlider.js
+++ b/app/src/components/elements/ColorSlider.js
@@ -5,7 +5,7 @@ import { useState } from "react";
const baseMarks = [
{
value: 0,
- color: "green",
+ color: "grey",
},
{
value: 1,
@@ -25,35 +25,49 @@ const baseMarks = [
},
];
-export function ColorSlider({ marks, defaultValue, onChange }) {
+export function ColorSlider({
+ marks,
+ defaultValue,
+ onChange,
+ hideDescription,
+ ...props
+}) {
const joinedMarks = marks.map((m, i) => ({
...(baseMarks.length > i ? baseMarks[i] : {}),
...m,
}));
- const defaultMark = joinedMarks.find((m) => m.value === defaultValue);
+ const defaultMark = joinedMarks.find((m) => m.textValue === defaultValue);
const [selectedMark, setSelectedMark] = useState(defaultMark);
return (
<>
joinedMarks[i].label}
onChange={(e) => {
- setSelectedMark(
- joinedMarks.find((m) => m.value === e.target.value)
- );
- return onChange(e);
+ const mark = joinedMarks.find((m) => m.value === e.target.value);
+ setSelectedMark(mark);
+ return onChange(mark);
+ }}
+ sx={{
+ color: selectedMark?.color || "primary",
+ ".MuiSlider-markLabel": {
+ fontSize: "9px",
+ },
+ "&.Mui-disabled .MuiSlider-track": {
+ color: selectedMark?.color || "primary",
+ },
}}
- sx={{ color: selectedMark?.color || "primary" }}
+ {...props}
/>
- {selectedMark?.description && (
+ {!hideDescription && selectedMark?.description && (
theme.palette.review.text,
+ }}
+ variant="outlined"
+ label={user.name}
+ icon={
+ <>
+ {user?.slackUrl && (
+
+
+
+ )}
+ {user?.mail && (
+
+
+
+ )}
+ >
+ }
+ />
+ );
+}
diff --git a/app/src/components/elements/modal/ModalManager.js b/app/src/components/elements/modal/ModalManager.js
index 1a2d1bae..38db7db6 100644
--- a/app/src/components/elements/modal/ModalManager.js
+++ b/app/src/components/elements/modal/ModalManager.js
@@ -10,7 +10,6 @@ import { ChangeReviewer } from "../../model/modals/ChangeReviewer";
import { Tutorial } from "../../model/tutorial/Tutorial";
import { CancelReview } from "../../reviews/modals/CancelReview";
import { DeclineReview } from "../../reviews/modals/DeclineReview";
-import { ViewActionItems } from "../../model/modals/ViewActionItems";
export const MODALS = {
ChangeReviewer,
@@ -23,7 +22,6 @@ export const MODALS = {
DeleteSelected,
DeclineReview,
CancelReview,
- ViewActionItems,
};
export function ModalManager() {
diff --git a/app/src/components/model/hooks/useActionItems.js b/app/src/components/model/hooks/useActionItems.js
new file mode 100644
index 00000000..10546ea6
--- /dev/null
+++ b/app/src/components/model/hooks/useActionItems.js
@@ -0,0 +1,20 @@
+import { useListThreatsQuery } from "../../../api/gram/threats";
+import { useModelID } from "./useModelID";
+
+export function useActionItems() {
+ const modelId = useModelID();
+ const { data: threats } = useListThreatsQuery({ modelId });
+
+ const actionItems = threats?.threats
+ ? Object.keys(threats?.threats)
+ .map((componentId) => ({
+ componentId,
+ threats: threats?.threats[componentId].filter(
+ (th) => th.isActionItem
+ ),
+ }))
+ .filter(({ threats }) => threats && threats.length > 0)
+ : [];
+
+ return actionItems;
+}
diff --git a/app/src/components/model/modals/ApproveReview.js b/app/src/components/model/modals/ApproveReview.js
index 20f99b14..d20782bc 100644
--- a/app/src/components/model/modals/ApproveReview.js
+++ b/app/src/components/model/modals/ApproveReview.js
@@ -9,6 +9,7 @@ import {
DialogContentText,
DialogTitle,
Divider,
+ Paper,
TextField,
Typography,
} from "@mui/material";
@@ -20,98 +21,14 @@ import {
useGetReviewQuery,
} from "../../../api/gram/review";
import { modalActions } from "../../../redux/modalSlice";
-import { ColorSlider } from "../../elements/ColorSlider";
import { LoadingPage } from "../../elements/loading/loading-page/LoadingPage";
import { PERMISSIONS } from "../constants";
-import { ActionItemList } from "./ActionItemList";
-
-function LikelihoodSlider({ onChange }) {
- const marks = [
- {
- label: "Rare",
- description: `➢ This will probably never happen/recur
-➢ Every 25 years`,
- },
- {
- label: "Unlikely",
- description: `➢ This is not likely to happen/recur but could
-➢ Every 10 years`,
- },
- {
- label: "Occasional",
- description: `➢ This is unexpected to happen/recur but is certainly possible to occur
-➢ Every 5 years`,
- },
- {
- label: "Likely",
- description: `➢ This will probably happen/recur but is not a persisting issue.
-➢ Every 3 years`,
- },
- {
- label: "Almost certain",
- description: `➢ This will undoubtedly happen/recur
-➢ Every year`,
- },
- ];
-
- return (
- <>
- onChange(marks[e.target.value])}
- />
- >
- );
-}
-
-function ImpactSlider({ onChange }) {
- const marks = [
- {
- label: "Very low",
- description: `➢ Users can not interact with the service <1h
-➢ No regulatory sanctions/fines`,
- },
- {
- label: "Low",
- description: `➢ Users can not interact with the service <1-4h
-➢ Incident reviewed by authorities but dismissed`,
- },
- {
- label: "Medium",
- description: `➢ Users can not interact with the service <4-10h
-➢ Incident reviewed by authorities and regulatory warning`,
- },
- {
- label: "High",
- description: `➢ Users can not interact with the service <10-16h
-➢ Incident reviewed by authorities and sanctions/fines imposed`,
- },
- {
- label: "Very high",
- description: `➢ Users can not interact with the service >16h
-➢ Incident reviewed by authorities and sanctions/fines threaten operations / Loss of licence`,
- },
- ];
-
- return (
- onChange(marks[e.target.value])}
- />
- );
-}
+import { ActionItemList } from "../panels/left/ActionItemList";
export function ApproveReview({ modelId }) {
const dispatch = useDispatch();
const { data: review } = useGetReviewQuery({ modelId });
- const [extras, setExtras] = useState({
- impact: "Low",
- likelihood: "Unlikely",
- });
const { data: permissions, isLoading: permissionsIsLoading } =
useGetModelPermissionsQuery({ modelId });
@@ -145,44 +62,18 @@ export function ApproveReview({ modelId }) {
{(isUninitialized || isLoading) && (
<>
- Risk Evaluation
- {/*
- Every system threat model is connected to a risk ticket. When you
- approve this threat model, it will be automatically created for
- you (if the model is connected to a system).
- */}
- {/*
*/}
-
- Based on the threat model, set the risk value as your estimate of
- the overall risk of all threats/controls found in the threat
- model.
-
-
-
-
- Impact
-
- setExtras({ ...extras, impact: value.label })
- }
- />
-
-
-
- Likelihood
-
- setExtras({ ...extras, likelihood: value.label })
- }
- />
-
-
Action Items
-
-
+
+ Assess the severity of each threat based on what the impact of a
+ vulnerability could be and the current level of mitigation against
+ the threat.
+
+
+
+
+
Summary
-
Do you have any further recommendations to the owning team? Your
notes here will be forwarded via email.
@@ -236,7 +127,7 @@ export function ApproveReview({ modelId }) {
{(isUninitialized || isLoading) && (
- );
-}
-
function RequestReviewButton({ permissions, modelId }) {
const dispatch = useDispatch();
@@ -356,45 +327,6 @@ function ReviewReviewedBy(props) {
);
}
-function UserChip({ user }) {
- return (
- theme.palette.review.text,
- }}
- variant="outlined"
- label={user.name}
- icon={
- <>
- {user?.slackUrl && (
-
-
-
- )}
- {user?.mail && (
-
-
-
- )}
- >
- }
- />
- );
-}
-
export function Review() {
const modelId = useModelID();
diff --git a/app/src/components/model/panels/left/constants.js b/app/src/components/model/panels/left/constants.js
index 70e5c10d..50b0193b 100644
--- a/app/src/components/model/panels/left/constants.js
+++ b/app/src/components/model/panels/left/constants.js
@@ -1,4 +1,5 @@
export const TAB = {
SYSTEM: 0,
- COMPONENT: 1,
+ ACTION_ITEMS: 1,
+ COMPONENT: 2,
};
diff --git a/app/src/components/model/panels/right/Threat.js b/app/src/components/model/panels/right/Threat.js
index 9b99fc96..304e4190 100644
--- a/app/src/components/model/panels/right/Threat.js
+++ b/app/src/components/model/panels/right/Threat.js
@@ -3,7 +3,16 @@ import {
ClearRounded as ClearRoundedIcon,
} from "@mui/icons-material";
import AssignmentTurnedInIcon from "@mui/icons-material/AssignmentTurnedIn";
-import { Box, Card, CardContent, IconButton, Tooltip } from "@mui/material";
+import {
+ Box,
+ Card,
+ CardContent,
+ IconButton,
+ Paper,
+ Stack,
+ Tooltip,
+ Typography,
+} from "@mui/material";
import { useEffect, useState } from "react";
import { useCreateControlMutation } from "../../../../api/gram/controls";
import {
@@ -26,12 +35,16 @@ import {
useListSuggestionsQuery,
} from "../../../../api/gram/suggestions";
import { useSelectedComponent } from "../../hooks/useSelectedComponent";
+import { SeveritySlider } from "../../modals/SeveritySlider";
+import { CollapsePaper } from "../../../elements/CollapsePaper";
export function Threat({
threat,
scrollToId,
selected,
- readOnly: propReadOnly,
+ hideDelete,
+ hideAddControl,
+ hideSeverityDescription,
}) {
const modelId = useModelID();
const selectedComponent = useSelectedComponent();
@@ -62,12 +75,14 @@ export function Threat({
const { data: mitigations } = useListMitigationsQuery({ modelId });
const threatsMap = mitigations?.threatsMap || {};
- const readOnly = useReadOnly() || propReadOnly;
+ const readOnly = useReadOnly();
const linkedControls = controls.filter((c) =>
threatsMap[threat.id]?.includes(c.id)
);
+ const [severity, setSeverity] = useState(threat.severity || "low");
+
//TODO clean this up, not the correct way to use useEffect imo
useEffect(() => {
if (title !== threat.title || description !== threat.description) {
@@ -163,6 +178,7 @@ export function Threat({
id: threat.id,
modelId: threat.modelId,
isActionItem: !threat.isActionItem,
+ severity: severity,
})
}
disabled={readOnly}
@@ -188,7 +204,7 @@ export function Threat({
}}
/>
- {!readOnly && (
+ {!readOnly && !hideDelete && (
@@ -248,7 +264,7 @@ export function Threat({
)}
- {!readOnly && (
+ {!readOnly && !hideAddControl && (
)}
+
+ {threat.isActionItem && (
+
+
+
+ Severity
+ {
+ updateThreat({
+ id: threat.id,
+ modelId: threat.modelId,
+ severity: v,
+ });
+ setSeverity(v);
+ }}
+ disabled={readOnly}
+ defaultValue={severity}
+ valueLabelDisplay="off"
+ />
+
+
+
+ )}
);
diff --git a/app/src/components/model/panels/right/ThreatTab.js b/app/src/components/model/panels/right/ThreatTab.js
index 47898083..6bf71ca7 100644
--- a/app/src/components/model/panels/right/ThreatTab.js
+++ b/app/src/components/model/panels/right/ThreatTab.js
@@ -76,6 +76,7 @@ export function ThreatTab(props) {
threat={threat}
scrollToId={scrollToId}
selected={selectedId === threat.id}
+ hideSeverityDescription={true}
/>
))}
diff --git a/core/src/data/migrations/23_threats_add_severity.sql b/core/src/data/migrations/23_threats_add_severity.sql
new file mode 100644
index 00000000..c6ee31af
--- /dev/null
+++ b/core/src/data/migrations/23_threats_add_severity.sql
@@ -0,0 +1,4 @@
+ALTER TABLE threats
+ADD severity varchar(20) NULL DEFAULT NULL;
+
+
diff --git a/core/src/data/threats/Threat.ts b/core/src/data/threats/Threat.ts
index e91c8c3a..89df3469 100644
--- a/core/src/data/threats/Threat.ts
+++ b/core/src/data/threats/Threat.ts
@@ -1,5 +1,13 @@
import { SuggestionID } from "../../suggestions/models.js";
+export enum ThreatSeverity {
+ Informative = "informative",
+ Low = "low",
+ Medium = "medium",
+ High = "high",
+ Critical = "critical",
+}
+
/**
* Class definition for threat
*/
@@ -9,6 +17,7 @@ export default class Threat {
updatedAt: number;
deletedAt?: number;
isActionItem?: boolean;
+ severity?: ThreatSeverity;
constructor(
public title: string,
@@ -35,6 +44,7 @@ export default class Threat {
updatedAt: this.updatedAt,
deletedAt: this.deletedAt,
isActionItem: this.isActionItem,
+ severity: this.severity,
};
}
}
diff --git a/core/src/data/threats/ThreatDataService.spec.ts b/core/src/data/threats/ThreatDataService.spec.ts
index 30a3128f..b12c7c86 100644
--- a/core/src/data/threats/ThreatDataService.spec.ts
+++ b/core/src/data/threats/ThreatDataService.spec.ts
@@ -6,7 +6,7 @@ import Mitigation from "../mitigations/Mitigation.js";
import Model from "../models/Model.js";
import { createPostgresPool } from "../postgres.js";
import { _deleteAllTheThings } from "../utils.js";
-import Threat from "./Threat.js";
+import Threat, { ThreatSeverity } from "./Threat.js";
import { ThreatDataService } from "./ThreatDataService.js";
describe("ThreatDataService implementation", () => {
@@ -355,6 +355,7 @@ describe("ThreatDataService implementation", () => {
const updated = await data.update(model.id!, threat.id!, {
title: "newtitle",
description: "baddesc",
+ severity: ThreatSeverity.High,
});
expect(updated).toBeInstanceOf(Threat);
if (updated instanceof Threat) {
@@ -366,6 +367,7 @@ describe("ThreatDataService implementation", () => {
expect(updated!.createdBy).toBe("createdBy");
expect(updated!.suggestionId).toBe(undefined);
expect(updated!.description).toBe("baddesc");
+ expect(updated!.severity).toBe(ThreatSeverity.High);
}
});
});
diff --git a/core/src/data/threats/ThreatDataService.ts b/core/src/data/threats/ThreatDataService.ts
index 70020294..7a656be0 100644
--- a/core/src/data/threats/ThreatDataService.ts
+++ b/core/src/data/threats/ThreatDataService.ts
@@ -10,7 +10,7 @@ import log4js from "log4js";
import { SuggestionID } from "../../suggestions/models.js";
import { DataAccessLayer } from "../dal.js";
import { SuggestionStatus } from "../suggestions/Suggestion.js";
-import Threat from "./Threat.js";
+import Threat, { ThreatSeverity } from "./Threat.js";
export function convertToThreat(row: any): Threat {
const threat = new Threat(
@@ -25,6 +25,7 @@ export function convertToThreat(row: any): Threat {
threat.createdAt = row.created_at * 1000;
threat.updatedAt = row.updated_at * 1000;
threat.isActionItem = row.is_action_item || false;
+ threat.severity = row.severity;
return threat;
}
@@ -90,7 +91,8 @@ export class ThreatDataService extends EventEmitter {
created_by,
extract(epoch from created_at) as created_at,
extract(epoch from updated_at) as updated_at,
- is_action_item
+ is_action_item,
+ severity
FROM threats
WHERE id = $1::uuid
AND deleted_at IS NULL
@@ -122,7 +124,8 @@ export class ThreatDataService extends EventEmitter {
extract(epoch from created_at) as created_at,
extract(epoch from updated_at) as updated_at,
suggestion_id,
- is_action_item
+ is_action_item,
+ severity
FROM threats
WHERE model_id = $1::uuid
AND deleted_at IS NULL
@@ -149,7 +152,8 @@ export class ThreatDataService extends EventEmitter {
extract(epoch from created_at) as created_at,
extract(epoch from updated_at) as updated_at,
suggestion_id,
- is_action_item
+ is_action_item,
+ severity
FROM threats
WHERE model_id = $1::uuid and is_action_item = true
AND deleted_at IS NULL
@@ -172,7 +176,12 @@ export class ThreatDataService extends EventEmitter {
async update(
modelId: string,
id: string,
- fields: { title?: string; description?: string; isActionItem?: boolean }
+ fields: {
+ title?: string;
+ description?: string;
+ isActionItem?: boolean;
+ severity?: ThreatSeverity;
+ }
) {
const fieldStatements = [];
const params = [];
@@ -188,6 +197,10 @@ export class ThreatDataService extends EventEmitter {
params.push(fields.isActionItem);
fieldStatements.push(`is_action_item = $${params.length}::boolean`);
}
+ if (fields.severity !== undefined) {
+ params.push(fields.severity);
+ fieldStatements.push(`severity = $${params.length}::varchar`);
+ }
if (params.length === 0) return false;