diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..62c89355 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/frontend/webEditor/package-lock.json b/frontend/webEditor/package-lock.json index eca35ef6..f1cf310a 100644 --- a/frontend/webEditor/package-lock.json +++ b/frontend/webEditor/package-lock.json @@ -17,6 +17,7 @@ "husky": "^9.1.7", "inversify": "^6.2.2", "lint-staged": "^16.2.7", + "marked": "^17.0.1", "monaco-editor": "^0.52.2", "prettier": "^3.7.4", "reflect-metadata": "^0.2.2", @@ -1072,7 +1073,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1297,7 +1297,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1654,7 +1653,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2269,6 +2267,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2564,8 +2575,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/resolve-from": { "version": "4.0.0", @@ -2888,7 +2898,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2948,7 +2957,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3090,7 +3098,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/webEditor/package.json b/frontend/webEditor/package.json index 2b2dc125..3c90f008 100644 --- a/frontend/webEditor/package.json +++ b/frontend/webEditor/package.json @@ -16,6 +16,7 @@ "husky": "^9.1.7", "inversify": "^6.2.2", "lint-staged": "^16.2.7", + "marked": "^17.0.1", "monaco-editor": "^0.52.2", "prettier": "^3.7.4", "reflect-metadata": "^0.2.2", diff --git a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts index ad0e53e2..3ed3fe5f 100644 --- a/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts +++ b/frontend/webEditor/src/commandPalette/commandPaletteProvider.ts @@ -10,6 +10,9 @@ import { LayoutMethod } from "../layout/layoutMethod"; import { LayoutModelAction } from "../layout/command"; import { SaveJsonFileAction } from "../serialize/saveJsonFile"; import { SaveDfdAndDdFileAction } from "../serialize/saveDfdAndDdFile"; +import { SaveThreatsTableAction } from "../serialize/saveThreatsTable.ts"; +import { LoadThreatModelingUserFileAction } from "../serialize/loadThreatModelingUserFile.ts"; +import { LoadThreatModelingLinddunFileAction } from "../serialize/loadThreatModelingLinddunFile.ts"; /** * Provides possible actions for the command palette. @@ -31,6 +34,16 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct [LoadPalladioFileAction.create(), commitAction], "fa-puzzle-piece", ), + new LabeledAction( + "Load Threat Modeling File (JSON)", + [LoadThreatModelingUserFileAction.create(), commitAction], + "fa-triangle-exclamation" + ), + new LabeledAction( + "Load LINDDUN Threat Modeling File", + [LoadThreatModelingLinddunFileAction.create(), commitAction], + "fa-triangle-exclamation" + ), ], "go-to-file", ), @@ -40,6 +53,7 @@ export class WebEditorCommandPaletteActionProvider implements ICommandPaletteAct new LabeledAction("Save diagram as JSON", [SaveJsonFileAction.create()], "json"), new LabeledAction("Save diagram as DFD and DD", [SaveDfdAndDdFileAction.create()], "coffee"), //new LabeledAction("Save viewport as image", [SaveImageAction.create()], "device-camera"), + new LabeledAction("Save threats table", [SaveThreatsTableAction.create()], "fa-triangle-exclamation") ], "save", ), diff --git a/frontend/webEditor/src/diagram/ports/DfdOutputPort.tsx b/frontend/webEditor/src/diagram/ports/DfdOutputPort.tsx index 32c24814..bb34050f 100644 --- a/frontend/webEditor/src/diagram/ports/DfdOutputPort.tsx +++ b/frontend/webEditor/src/diagram/ports/DfdOutputPort.tsx @@ -16,6 +16,9 @@ export interface DfdOutputPort extends SPort { @injectable() export class DfdOutputPortImpl extends DfdPortImpl { + static readonly PORT_COLOR = "var(--color-primary)"; + + private color?: string; private behavior: string = ""; private validBehavior: boolean = true; private tree?: LanguageTreeNode[]; @@ -52,6 +55,8 @@ export class DfdOutputPortImpl extends DfdPortImpl { style["--port-color"] = "#ff6961"; } + if (this.color) style["--port-color"] = this.color + return style; } @@ -75,6 +80,10 @@ export class DfdOutputPortImpl extends DfdPortImpl { public getBehavior() { return this.behavior; } + + public setColor(color: string, override: boolean = true) { + if (override || this.color === DfdOutputPortImpl.PORT_COLOR) this.color = color; + } } @injectable() diff --git a/frontend/webEditor/src/index.ts b/frontend/webEditor/src/index.ts index ce0f74b7..6305d770 100644 --- a/frontend/webEditor/src/index.ts +++ b/frontend/webEditor/src/index.ts @@ -25,6 +25,7 @@ import { constraintModule } from "./constraint/di.config"; import { assignmentModule } from "./assignment/di.config"; import { editorModeOverwritesModule } from "./editModeOverwrites/di.config"; import { loadingIndicatorModule } from "./loadingIndicator/di.config"; +import { labelingProcessModule } from "./labelingProcess/di.config.ts"; const container = new Container(); @@ -48,6 +49,7 @@ container.load( layoutModule, fileNameModule, settingsModule, + labelingProcessModule, toolPaletteModule, constraintModule, assignmentModule, diff --git a/frontend/webEditor/src/labelingProcess/di.config.ts b/frontend/webEditor/src/labelingProcess/di.config.ts new file mode 100644 index 00000000..fe480af0 --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/di.config.ts @@ -0,0 +1,24 @@ +import { ContainerModule } from "inversify"; +import { configureCommand, TYPES } from "sprotty"; +import { LabelingProcessUi } from "./labelingProcessUi.ts"; +import { LabelingProcessCommand } from "./labelingProcessCommand.ts"; +import { EDITOR_TYPES } from "../editorTypes.ts"; +import { ThreatModelingLabelAssignmentToOutputPortCommand } from "./threatModelingLabelAssignmentToOutputPortCommand.ts"; +import { LabelingProcessMouseListener } from "./labelingProcessMouseListener.ts"; +import { ExcludesDialog } from "./excludesDialog.ts"; +import { ThreatModelingLabelAssignmentCommand } from "./threatModelingLabelAssignmentCommand.ts"; + +export const labelingProcessModule = new ContainerModule((bind, _, isBound) => { + bind(LabelingProcessUi).toSelf().inSingletonScope(); + bind(TYPES.IUIExtension).toService(LabelingProcessUi); + bind(EDITOR_TYPES.DefaultUIElement).to(LabelingProcessUi); + + bind(ExcludesDialog).toSelf().inSingletonScope(); + bind(TYPES.IUIExtension).toService(ExcludesDialog); + + bind(TYPES.MouseListener).to(LabelingProcessMouseListener).inSingletonScope(); + + configureCommand({bind, isBound}, LabelingProcessCommand) + configureCommand({bind, isBound}, ThreatModelingLabelAssignmentCommand); + configureCommand({bind, isBound}, ThreatModelingLabelAssignmentToOutputPortCommand); +}) \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/dialog.css b/frontend/webEditor/src/labelingProcess/dialog.css new file mode 100644 index 00000000..3ef619ef --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/dialog.css @@ -0,0 +1,54 @@ +.dialog-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +.dialog { + z-index: 101; + position: relative; + + max-width: 50%; + + background-color: var(--color-background); + border-radius: 5px; + padding: 16px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; +} + +.dialog-text { + text-align: center; +} + +.dialog-buttons { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 4px; +} + +.dialog-button { + background-color: green; + color: white; + border: none; + border-radius: 8px; + padding: 5px 10px; + text-align: center; + text-decoration: none; + display: inline-block; + width: fit-content; + cursor: pointer; +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/excludesDialog.ts b/frontend/webEditor/src/labelingProcess/excludesDialog.ts new file mode 100644 index 00000000..aefa4750 --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/excludesDialog.ts @@ -0,0 +1,104 @@ +import { ThreatModelingLabelType, ThreatModelingLabelTypeValue } from "../labels/ThreatModelingLabelType.ts"; +import { AbstractUIExtension, IActionDispatcher, TYPES } from "sprotty"; +import "./dialog.css"; +import { inject } from "inversify"; +import { marked } from "marked"; +import { Action } from "sprotty-protocol"; + +export type ExcludesDialogData = { + previousLabelAssignments: { labelType: ThreatModelingLabelType; labelTypeValue: ThreatModelingLabelTypeValue }[]; + newLabelAssignment: { labelType: ThreatModelingLabelType; labelTypeValue: ThreatModelingLabelTypeValue }; + confirmAction: Action +}; + +export class ExcludesDialog extends AbstractUIExtension { + protected textContainer: HTMLDivElement = document.createElement("div"); + protected buttonContainer: HTMLDivElement = document.createElement("div"); + + private state?: ExcludesDialogData; + + constructor(@inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher) { + super(); + } + + id(): string { + return "excludes-collision-dialog"; + } + + containerClass(): string { + return "dialog-container"; + } + + protected initializeContents(containerElement: HTMLElement): void { + const dialog = document.createElement("div"); + dialog.classList.add("dialog"); + containerElement.appendChild(dialog); + + this.textContainer.classList.add("dialog-text"); + dialog.appendChild(this.textContainer); + + this.buttonContainer.classList.add("dialog-buttons"); + dialog.appendChild(this.buttonContainer); + + this.update(); + } + + public update(state?: ExcludesDialogData) { + if (!this.containerElement) { + if (!this.initialize()) return; + } + + this.state = state; + this.updateText(); + this.updateButtons(); + } + + private updateText(): void { + if (!this.state) { + this.textContainer.innerText = "Something went wrong: This dialog has no state."; + return; + } + + this.textContainer.innerHTML = marked.parse( + "This element already has the labels " + + this.state.previousLabelAssignments + .map((assignment) => `**${assignment.labelType.name}.${assignment.labelTypeValue.text}**`) + .join(", ") + + " assigned to it.\n" + + "The label " + + `**${this.state.newLabelAssignment.labelType.name}.${this.state.newLabelAssignment.labelTypeValue.text}**` + + " cannot be assigned at the same time, since they exclude each other.", + { async: false }, + ); + } + + private updateButtons(): void { + if (!this.state) { + const closeButton = document.createElement("button"); + closeButton.classList.add("dialog-button"); + closeButton.innerText = "Close"; + closeButton.addEventListener("click", () => this.hide()); + this.buttonContainer.replaceChildren(closeButton); + return; + } + + const keepPreviousLabelButton = document.createElement("button"); + keepPreviousLabelButton.classList.add("dialog-button"); + keepPreviousLabelButton.innerText = `Keep previous labels`; + keepPreviousLabelButton.addEventListener("click", () => this.hide()); + + const overwriteWithNewLabelButton = document.createElement("button"); + overwriteWithNewLabelButton.classList.add("dialog-button"); + overwriteWithNewLabelButton.innerText = + "Replace with " + + `${this.state.newLabelAssignment.labelType.name}.${this.state.newLabelAssignment.labelTypeValue.text}`; + + const confirmAction = this.state.confirmAction + overwriteWithNewLabelButton.addEventListener("click", () => { + this.hide(); + this.actionDispatcher.dispatch(confirmAction) + }); + + this.buttonContainer.replaceChildren(keepPreviousLabelButton, overwriteWithNewLabelButton); + } +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/labelingProcessCommand.ts b/frontend/webEditor/src/labelingProcess/labelingProcessCommand.ts new file mode 100644 index 00000000..cdea0bce --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/labelingProcessCommand.ts @@ -0,0 +1,188 @@ +import { inject, injectable } from "inversify"; +import { + Command, + CommandExecutionContext, + CommandReturn, + TYPES, +} from "sprotty"; +import { Action } from "sprotty-protocol"; +import { LabelingProcessState, LabelingProcessUi } from "./labelingProcessUi.ts"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry.ts"; +import { LabelAssignment } from "../labels/LabelType.ts"; +import { + isThreatModelingLabelType, + isThreatModelingLabelTypeValue, + ThreatModelingLabelType, ThreatModelingLabelTypeValue, +} from "../labels/ThreatModelingLabelType.ts"; +import { getAllElements } from "../labels/assignmentCommand.ts"; +import { DfdNodeImpl } from "../diagram/nodes/common.ts"; +import { DfdOutputPortImpl } from "../diagram/ports/DfdOutputPort.tsx"; +import { findCollisions as findNodeCollisions } from "./threatModelingLabelAssignmentCommand.ts"; +import { findCollisions as findOutputPortCollisions } from "./threatModelingLabelAssignmentToOutputPortCommand.ts"; + +export interface LabelingProcessAction extends Action { + state: LabelingProcessState +} + +export namespace ResetLabelingProcessAction { + export function create(): LabelingProcessAction { + return { + kind: LabelingProcessCommand.KIND, + state: { state: 'pending' } + } + } +} + +export namespace BeginLabelingProcessAction { + export function create( + labelTypeRegistry: LabelTypeRegistry + ): LabelingProcessAction { + const allLabels = labelTypeRegistry.getAllLabelAssignments() + + return { + kind: LabelingProcessCommand.KIND, + state: { + state: 'inProgress', + finishedLabels: [], + activeLabel: allLabels [0] + } + } + } +} + +export namespace NextLabelingProcessAction { + export function create( + labelTypeRegistry: LabelTypeRegistry, + finishedLabels: LabelAssignment[] + ): LabelingProcessAction { + const pendingLabels = labelTypeRegistry.getAllLabelAssignments() + .filter( + (label) => !finishedLabels.some( + finishedLabel => finishedLabel.labelTypeId === label.labelTypeId && finishedLabel.labelTypeValueId === label.labelTypeValueId + ) + ) + + if (pendingLabels.length === 0) return CompleteLabelingProcessAction.create(); + + return { + kind: LabelingProcessCommand.KIND, + state: { + state: 'inProgress', + finishedLabels: finishedLabels, + activeLabel: pendingLabels[0] + } + } + } +} + +export namespace CompleteLabelingProcessAction { + export function create(): LabelingProcessAction { + return { + kind: LabelingProcessCommand.KIND, + state: { + state: 'done', + } + } + } +} + +@injectable() +export class LabelingProcessCommand implements Command { + + public static readonly KIND = "labelingProcess" + + private previousState?: LabelingProcessState = undefined; + + constructor( + @inject(TYPES.Action) private readonly action: LabelingProcessAction, + @inject(LabelingProcessUi) private readonly ui: LabelingProcessUi, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, + ) {} + + execute(context: CommandExecutionContext): CommandReturn { + this.previousState = this.ui.getState(); + + this.ui.setState(this.action.state); + this.highlightShapes(context); + + return context.root; + } + + redo(context: CommandExecutionContext): CommandReturn { + if (this.previousState) { + this.ui.setState(this.previousState); + this.highlightShapes(context); + } + return context.root; + } + + undo(context: CommandExecutionContext): CommandReturn { + this.ui.setState(this.action.state); + this.highlightShapes(context); + return context.root; + } + + highlightShapes(context: CommandExecutionContext) { + if (this.action.state.state !== "inProgress") return; + + const { labelType, labelTypeValue } = this.labelTypeRegistry.resolveLabelAssignment(this.action.state.activeLabel) + if (!labelType || !labelTypeValue) return; + + + const applyColorToNode = (node: DfdNodeImpl) => { + if (!isThreatModelingLabelType(labelType) || !isThreatModelingLabelTypeValue(labelTypeValue)) { + node.setColor(LabelingProcessUi.ASSIGNABLE_COLOR) + return; + } + + if (labelType.intendedFor !== "Vertex") { + node.setColor(DfdNodeImpl.NODE_COLOR) + return; + } + + const assignedLabels = node.labels + .map(label => this.labelTypeRegistry.resolveLabelAssignment(label)) + .filter((label) : label is Required<{ labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }> => + label.labelType !== undefined + && label.labelTypeValue !== undefined + ) + .filter(label => + isThreatModelingLabelType(label.labelType) + && isThreatModelingLabelTypeValue(label.labelTypeValue) + ) + if (findNodeCollisions({ labelType, labelTypeValue }, assignedLabels).length === 0) { + node.setColor(LabelingProcessUi.ASSIGNABLE_COLOR) + } else { + node.setColor(LabelingProcessUi.COLLISION_COLOR) + } + } + + const applyColorToOutputPort = (port: DfdOutputPortImpl) => { + if (!isThreatModelingLabelType(labelType) || !isThreatModelingLabelTypeValue(labelTypeValue)) { + port.setColor(LabelingProcessUi.ASSIGNABLE_COLOR) + return; + } + + if (labelType.intendedFor !== "Flow") { + port.setColor(DfdOutputPortImpl.PORT_COLOR) + return; + } + + const behavior = port.getBehavior().split("\n") + if (findOutputPortCollisions(behavior, { labelType, labelTypeValue }, this.labelTypeRegistry).length === 0) { + port.setColor(LabelingProcessUi.ASSIGNABLE_COLOR) + } else { + port.setColor(LabelingProcessUi.COLLISION_COLOR) + } + } + + getAllElements(context.root.children) + .filter((element) => element instanceof DfdNodeImpl) + .forEach(applyColorToNode) + + getAllElements(context.root.children) + .filter((element) => element instanceof DfdOutputPortImpl) + .forEach(applyColorToOutputPort) + } + +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/labelingProcessMouseListener.ts b/frontend/webEditor/src/labelingProcess/labelingProcessMouseListener.ts new file mode 100644 index 00000000..dcadb7bb --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/labelingProcessMouseListener.ts @@ -0,0 +1,40 @@ +import { MouseListener, SModelElementImpl, SNodeImpl } from "sprotty"; +import { Action } from "sprotty-protocol/lib/actions"; +import { DfdOutputPortImpl } from "../diagram/ports/DfdOutputPort.tsx"; +import { inject } from "inversify"; +import { LabelingProcessUi } from "./labelingProcessUi.ts"; +import { AddLabelToOutputPortAction } from "./threatModelingLabelAssignmentToOutputPortCommand.ts"; +import { containsDfdLabels } from "../labels/feature"; +import { getParentWithDfdLabels } from "../labels/dragAndDrop.ts"; +import { AddThreatModelingLabelToNodeAction } from "./threatModelingLabelAssignmentCommand.ts"; + +export class LabelingProcessMouseListener extends MouseListener { + + constructor( + @inject(LabelingProcessUi) private readonly labelingProcessUi: LabelingProcessUi + ) { + super(); + } + + override contextMenu(target: SModelElementImpl): Action[] { + //Only do this while the labeling process is in progress + const processState = this.labelingProcessUi.getState(); + if (processState.state !== "inProgress") return []; + + // Adds label to Output Port + if (target instanceof DfdOutputPortImpl) { + return [AddLabelToOutputPortAction.create(target)] + } + + // Adds label to nodes + const dfdLabelElement = getParentWithDfdLabels(target); + if (!dfdLabelElement) return [] + if (containsDfdLabels(dfdLabelElement)) { + if (!(dfdLabelElement instanceof SNodeImpl)) return []; + + return [AddThreatModelingLabelToNodeAction.create(dfdLabelElement)] + } + + return [] + } +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/labelingProcessUI.css b/frontend/webEditor/src/labelingProcess/labelingProcessUI.css new file mode 100644 index 00000000..4c899886 --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/labelingProcessUI.css @@ -0,0 +1,77 @@ +.labeling-process-container { + position: absolute; + top: 40px; + left: 50%; + transform: translate(-50%, -50%); + width: fit-content; + + padding: 10px 20px; + + display: flex; + flex-direction: row; + gap: 8px; + justify-content: center; + align-items: center; + + /* Make text of the elements non-selectable */ + -webkit-user-select: none; /* Safari only supports user select using the -webkit prefix */ + user-select: none; + + .labeling-process-button { + background-color: green; + color: white; + border: none; + border-radius: 8px; + padding: 5px 10px; + text-align: center; + text-decoration: none; + display: inline-block; + width: fit-content; + cursor: pointer; + } + + .additional-information-icon { + position: relative; + cursor: pointer; + } + + .additional-information-icon::before { + content: ""; + background-image: url("@fortawesome/fontawesome-free/svgs/regular/circle-question.svg"); + background-repeat: no-repeat; + display: inline-block; + filter: invert(var(--dark-mode)); + height: 16px; + width: 16px; + background-size: 16px 16px; + vertical-align: text-top; + margin-left: 4px; + margin-right: 4px; + } + + .additional-information-container { + position: absolute; + top: 120%; + left: 50%; + transform: translateX(-50%); + z-index: 50; + + padding: 4px 16px; + border: 1px solid var(--color-foreground); + font-size: 12px; + white-space: nowrap; + + opacity: 0; + pointer-events: none; /* prevents flicker */ + } + + .additional-information-icon:hover .additional-information-container { + opacity: 1; + } + + .additional-information-colors-explanation { + display: flex; + flex-direction: column; + gap: 2px; + } +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/labelingProcessUi.ts b/frontend/webEditor/src/labelingProcess/labelingProcessUi.ts new file mode 100644 index 00000000..c72256bb --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/labelingProcessUi.ts @@ -0,0 +1,193 @@ +import { + AbstractUIExtension, + IActionDispatcher, + TYPES, +} from "sprotty"; +import { inject, injectable } from "inversify"; +import './labelingProcessUI.css' +import { LabelAssignment } from "../labels/LabelType.ts"; +import { NextLabelingProcessAction } from "./labelingProcessCommand.ts"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry.ts"; +import { AnalyzeAction } from "../serialize/analyze.ts"; +import { SelectConstraintsAction } from "../constraint/selection.ts"; +import { ConstraintRegistry } from "../constraint/constraintRegistry.ts"; +import { SaveThreatsTableAction } from "../serialize/saveThreatsTable.ts"; +import { isThreatModelingLabelType, isThreatModelingLabelTypeValue } from "../labels/ThreatModelingLabelType.ts"; +import { marked } from "marked"; + +export type LabelingProcessState + = { state: 'pending' } + | { state: 'inProgress', finishedLabels: LabelAssignment[], activeLabel: LabelAssignment } + | { state: 'done' } + +@injectable() +export class LabelingProcessUi extends AbstractUIExtension { + static readonly ID = "labeling-process-ui"; + + static readonly ASSIGNABLE_COLOR = "#00ff00" + static readonly ALREADY_ASSIGNED_COLOR = "#ffff00" + static readonly COLLISION_COLOR = "#ff0000" + // ^ The colors are defined here, but the UI elements are colored during the 'LabelingProcessCommand' and the + // respective AssignmentCommand + + private state: LabelingProcessState; + + constructor( + @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, + @inject(ConstraintRegistry) private readonly constraintRegistry: ConstraintRegistry, + ) { + super(); + this.state = { state: 'pending' } + } + + + id(): string { + return LabelingProcessUi.ID; + } + + containerClass(): string { + return "labeling-process-container" + } + + protected initializeContents(): void { + this.updateContents(); + } + + private updateContents(): void { + switch (this.state.state) { + case "pending": return this.showPendingContents(); + case "inProgress": return this.showInProgressContents(); + case "done": return this.showDoneContents(); + } + } + + private showPendingContents(): void { + this.containerElement.classList.remove("ui-float") + this.containerElement.replaceChildren('') + } + + private showInProgressContents(): void { + this.containerElement.classList.add("ui-float"); + if (this.state.state !== 'inProgress') return; + + const text = document.createElement('span') + const { labelType, labelTypeValue } = this.labelTypeRegistry.resolveLabelAssignment(this.state.activeLabel) + if (!labelType || !labelTypeValue) { + text.innerText = `Couldn't resolve the LabelType or LabelTypeValue` + } else { + let targetElement = "" + if (isThreatModelingLabelType(labelType)) { + targetElement = labelType.intendedFor === "Vertex" ? "a node" : "an output pin" + } else { + targetElement = "a node or output pin" + } + + const labelHTML = document.createElement("strong") + labelHTML.innerText = `${labelType.name}.${labelTypeValue.text}` + + text.append( + `Right click ${targetElement} to assign `, + labelHTML, + this.generateAdditionalInformation() ?? '', + ' to it.' + ) + } + + const nextStepButton = document.createElement('button') + nextStepButton.innerText = "Next label" + nextStepButton.classList.add("labeling-process-button") + nextStepButton.addEventListener('click', () => { + if (this.state.state !== 'inProgress') return; + + this.actionDispatcher.dispatch(NextLabelingProcessAction.create( + this.labelTypeRegistry, + [...this.state.finishedLabels, this.state.activeLabel] + )) + }) + + this.containerElement.replaceChildren(text, nextStepButton) + } + + private showDoneContents(): void { + this.containerElement.classList.add("ui-float"); + if (this.state.state !== 'done') return; + + const text = document.createElement('span') + text.innerText = 'You have completed this process.' + + const finalStepsButton = document.createElement('button') + finalStepsButton.innerText = "Check constraints and download threats" + finalStepsButton.classList.add("labeling-process-button") + finalStepsButton.addEventListener('click', () => { + this.actionDispatcher.dispatchAll([ + AnalyzeAction.create(), + SelectConstraintsAction.create(this.constraintRegistry.getConstraintList().map((c) => c.name)), + ]).then(() => + this.actionDispatcher.dispatch(SaveThreatsTableAction.create()) + ) + }) + + this.containerElement.replaceChildren(text, finalStepsButton) + } + + private generateAdditionalInformation(): HTMLElement | undefined { + if (this.state.state !== "inProgress") return; + + const { labelTypeValue } = this.labelTypeRegistry.resolveLabelAssignment(this.state.activeLabel) + if (!labelTypeValue + || !isThreatModelingLabelTypeValue(labelTypeValue) + || !labelTypeValue.additionalInformation + ) return; + + const icon = document.createElement('span') + icon.classList.add('additional-information-icon') + + const container = document.createElement('div') + container.classList.add('additional-information-container', 'ui-float') + + const explanation = document.createElement('div') + explanation.classList.add('additional-information-colors-explanation') + explanation.append( + 'Colors: ', + createColorLabel(LabelingProcessUi.ASSIGNABLE_COLOR, 'Label is assignable'), + createColorLabel(LabelingProcessUi.ALREADY_ASSIGNED_COLOR, 'Label is already assigned'), + createColorLabel(LabelingProcessUi.COLLISION_COLOR, 'Label will collide'), + ) + + icon.appendChild(container) + container.innerHTML = marked.parse(labelTypeValue.additionalInformation, { async: false }) + container.appendChild(explanation) + + return icon; + } + + public getState(): LabelingProcessState { + return this.state; + } + + public setState(state: LabelingProcessState) { + this.state = state; + this.updateContents(); + } +} + +function createColorLabel(color: string, text: string): HTMLElement { + const container = document.createElement('span') + container.style.display = 'flex' + container.style.flexDirection = 'row' + container.style.gap = '4px' + + const box = document.createElement('div') + box.style.background = color + box.style.width = '12px' + box.style.height = '12px' + box.style.display = 'inline-block' + box.style.verticalAlign = 'middle' + + const label = document.createElement('span') + label.innerText = text + + container.append(box, label) + return container +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentCommand.ts b/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentCommand.ts new file mode 100644 index 00000000..e671e137 --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentCommand.ts @@ -0,0 +1,154 @@ +import { Action } from "sprotty-protocol"; +import { injectable, inject } from "inversify"; +import { Command, CommandExecutionContext, CommandReturn, IActionDispatcher, SNodeImpl, TYPES } from "sprotty"; +import { ContainsDfdLabels } from "../labels/feature.ts"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry.ts"; +import { LabelingProcessState, LabelingProcessUi } from "./labelingProcessUi.ts"; +import { ExcludesDialog } from "./excludesDialog.ts"; +import { + isThreatModelingLabelType, + isThreatModelingLabelTypeValue, + ThreatModelingLabelType, + ThreatModelingLabelTypeValue, +} from "../labels/ThreatModelingLabelType.ts"; +import { AddLabelAssignmentAction, RemoveLabelAssignmentAction } from "../labels/assignmentCommand.ts"; +import { DfdNodeImpl } from "../diagram/nodes/common.ts"; + +interface ThreatModelingLabelAssignmentToNodeAction extends Action { + element: ContainsDfdLabels & SNodeImpl; + collisionMode: 'overwrite' | 'askUser' +} + +export namespace AddThreatModelingLabelToNodeAction { + export function create( + element: ContainsDfdLabels & SNodeImpl, + collisionMode?: 'overwrite' | 'askUser' + ): ThreatModelingLabelAssignmentToNodeAction { + return { + kind: ThreatModelingLabelAssignmentCommand.KIND, + element, + collisionMode: collisionMode ?? 'askUser' + }; + } +} + +@injectable() +export class ThreatModelingLabelAssignmentCommand implements Command { + public static readonly KIND = "threatModeling-addLabelToNode"; + + constructor( + @inject(TYPES.Action) private readonly action: ThreatModelingLabelAssignmentToNodeAction, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, + @inject(LabelingProcessUi) private readonly labelingProcessUI: LabelingProcessUi, + @inject(ExcludesDialog) private readonly excludesDialog: ExcludesDialog, + @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher + ) {} + + execute(context: CommandExecutionContext): CommandReturn { + const labelProcessState = this.labelingProcessUI.getState() + if (labelProcessState.state !== "inProgress") return context.root; + + const { labelType, labelTypeValue } = this.labelTypeRegistry.resolveLabelAssignment(labelProcessState.activeLabel) + if (!labelType || !labelTypeValue) return context.root; + + if (!isThreatModelingLabelType(labelType) || !isThreatModelingLabelTypeValue(labelTypeValue)) { + this.handleSimpleCase(labelProcessState) + return context.root; + } + + const possibleCollisions = this.action.element.labels + .map(label => this.labelTypeRegistry.resolveLabelAssignment(label)) + .filter((label) : label is Required<{ labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }> => + label.labelType !== undefined + && label.labelTypeValue !== undefined + ) + .filter(label => + isThreatModelingLabelType(label.labelType) + && isThreatModelingLabelTypeValue(label.labelTypeValue) + ) + const collisions = findCollisions({ labelType, labelTypeValue }, possibleCollisions ) + + if (collisions.length == 0) { + this.handleSimpleCase(labelProcessState) + } else if (this.action.collisionMode === "askUser") { + this.handleAskUser( + { labelType, labelTypeValue }, + collisions, + context + ) + } else { + this.handleOverwrite(labelProcessState, collisions) + } + + return context.root; + } + + redo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + + undo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + + private handleSimpleCase( + labelProcessState: LabelingProcessState & { state: "inProgress" }, + ) { + this.actionDispatcher.dispatch(AddLabelAssignmentAction.create( + labelProcessState.activeLabel, + this.action.element, + )) + if (this.action.element instanceof DfdNodeImpl) { + this.action.element.setColor(LabelingProcessUi.ALREADY_ASSIGNED_COLOR) + } + } + + private handleAskUser( + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + collisions: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[], + context: CommandExecutionContext + ) { + this.excludesDialog.update({ + previousLabelAssignments: collisions , + newLabelAssignment: candidate, + confirmAction: AddThreatModelingLabelToNodeAction.create(this.action.element, "overwrite") + }) + + this.excludesDialog.show(context.root); + } + + private handleOverwrite( + labelProcessState: LabelingProcessState & { state: "inProgress" }, + collisions: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[] + ) { + for (const collision of collisions) { + this.actionDispatcher.dispatch(RemoveLabelAssignmentAction.create( + { labelTypeId: collision.labelType.id, labelTypeValueId: collision.labelTypeValue.id }, + this.action.element, + )) + } + this.actionDispatcher.dispatch(AddLabelAssignmentAction.create( + labelProcessState.activeLabel, + this.action.element, + )) + if (this.action.element instanceof DfdNodeImpl) { + this.action.element.setColor(LabelingProcessUi.ALREADY_ASSIGNED_COLOR) + } + } +} + +export function findCollisions( + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + assigned: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[] +): { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[] { + return assigned.filter(existing => + candidate.labelTypeValue.excludes.some(exclude => + exclude.labelTypeId === existing.labelType.id + && exclude.labelTypeValueId === existing.labelTypeValue.id + ) + || existing.labelTypeValue.excludes.some(exclude => + exclude.labelTypeId === candidate.labelType.id + && exclude.labelTypeValueId === candidate.labelTypeValue.id + ) + ) +} \ No newline at end of file diff --git a/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentToOutputPortCommand.ts b/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentToOutputPortCommand.ts new file mode 100644 index 00000000..fef9a99c --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/threatModelingLabelAssignmentToOutputPortCommand.ts @@ -0,0 +1,261 @@ +import { Action } from "sprotty-protocol"; +import { + Command, + CommandExecutionContext, + CommandReturn, + TYPES, +} from "sprotty"; +import { inject, injectable } from "inversify"; +import { DfdOutputPortImpl } from "../diagram/ports/DfdOutputPort.tsx"; +import { + isThreatModelingLabelType, + isThreatModelingLabelTypeValue, + ThreatModelingLabelType, + ThreatModelingLabelTypeValue, +} from "../labels/ThreatModelingLabelType.ts"; +import { LabelingProcessUi } from "./labelingProcessUi.ts"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry.ts"; +import { ExcludesDialog } from "./excludesDialog.ts"; + + +interface ThreatModelingLabelAssignmentToOutputPortAction extends Action { + element: DfdOutputPortImpl; + collisionMode: 'overwrite' | 'askUser' +} + +export namespace AddLabelToOutputPortAction { + export function create( + element: DfdOutputPortImpl, + collisionMode?: 'overwrite' | 'askUser' + ): ThreatModelingLabelAssignmentToOutputPortAction { + return { + kind: ThreatModelingLabelAssignmentToOutputPortCommand.KIND, + element, + collisionMode: collisionMode ?? 'askUser' + }; + } +} + +@injectable() +export class ThreatModelingLabelAssignmentToOutputPortCommand implements Command { + public static readonly KIND = "addLabelToOutputPort"; + + private previousBehavior?: string + private newBehavior?: string + + constructor( + @inject(TYPES.Action) private readonly action: ThreatModelingLabelAssignmentToOutputPortAction, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, + @inject(LabelingProcessUi) private readonly labelingProcessUI: LabelingProcessUi, + @inject(ExcludesDialog) private readonly excludesDialog: ExcludesDialog + ) {} + + execute(context: CommandExecutionContext): CommandReturn { + const labelProcessState = this.labelingProcessUI.getState() + if (labelProcessState.state !== "inProgress") return context.root; + + const { labelType, labelTypeValue } = this.labelTypeRegistry.resolveLabelAssignment(labelProcessState.activeLabel) + if (!labelType || !labelTypeValue) return context.root; + + this.previousBehavior = this.action.element.getBehavior().trim() + if (!isThreatModelingLabelType(labelType) || !isThreatModelingLabelTypeValue(labelTypeValue)) { + this.applyNewBehavior([ + `${this.previousBehavior}`, + `set ${labelType.name}.${labelTypeValue.text}` + ]) + return context.root; + } + + const lines = this.previousBehavior + .split("\n") + .map(line => line.trim()); + const collisions = findCollisions(lines, { labelType, labelTypeValue }, this.labelTypeRegistry) + + if (collisions.length == 0) { + this.handleSimpleCase(lines, { labelType, labelTypeValue }) + } else if (this.action.collisionMode === "askUser") { + this.handleAskUser({ labelType, labelTypeValue }, collisions, context) + } else { + this.handleOverwrite(lines, { labelType, labelTypeValue }, collisions) + } + + return context.root + } + + redo(context: CommandExecutionContext): CommandReturn { + if (!this.newBehavior + || this.action.collisionMode === "askUser" + ) return context.root; + + this.action.element.setBehavior(this.newBehavior); + return context.root; + } + + undo(context: CommandExecutionContext): CommandReturn { + if (!this.previousBehavior + || this.action.collisionMode === "askUser" + ) return context.root; + + this.action.element.setBehavior(this.previousBehavior); + return context.root; + } + + private handleSimpleCase( + lines: string[], + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + ) { + lines = addLabelAssignment(lines, candidate, this.labelTypeRegistry) + this.applyNewBehavior(lines) + } + + private handleAskUser( + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + collisions: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[], + context: CommandExecutionContext + ) { + this.excludesDialog.update({ + previousLabelAssignments: collisions, + newLabelAssignment: candidate, + confirmAction: AddLabelToOutputPortAction.create(this.action.element, "overwrite") + }) + this.excludesDialog.show(context.root); + } + + private handleOverwrite( + lines: string[], + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + collisions: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[], + ) { + for (const collision of collisions) { + lines = removeLabelAssignment(lines, { labelType: collision.labelType, labelTypeValue: collision.labelTypeValue }) + } + lines = addLabelAssignment(lines, candidate, this.labelTypeRegistry) + this.applyNewBehavior(lines) + } + + private applyNewBehavior(lines: string[]) { + this.newBehavior = lines + .filter(line => line !== '') + .join("\n") + this.action.element.setBehavior(this.newBehavior); + this.action.element.setColor(LabelingProcessUi.ALREADY_ASSIGNED_COLOR) + } +} + +export function findCollisions( + portBehavior: string[], + candidate: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + labelTypeRegistry: LabelTypeRegistry +): { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }[] { + //Prevents duplicate entries. + //Native JS Sets cannot compare { labelType, labelTypeValue } correctly, therefore this complex solution is required. + const collisions = new Map< + string, + { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue } + >(); + const computeCompositeKey = ( + labelType: ThreatModelingLabelType, + labelTypeValue: ThreatModelingLabelTypeValue + ) => `${labelType.id}.${labelTypeValue.id}` + + for (let i = 0; i < portBehavior.length; i++) { + const line = portBehavior[i] + + //Search for a previous assignment that excludes the new assignment + if (line.trim() === `unset ${candidate.labelType.name}.${candidate.labelTypeValue.text}`) { + //Searches for the previous `set` assignment + //Assumes that each `set` assignment is directly followed by its `unset` assignments (based on it's + //'excludes' property) + for (let j = i; j >= 0; j--) { + if (portBehavior[j].startsWith(`set`)) { + const parts = portBehavior[j].split(" ") + const label = parts[1] + const [ labelTypeName, labelTypeValueText ] = label.split(".") + + const labelType = labelTypeRegistry.getLabelTypes() + .find((labelType) => labelType.name === labelTypeName) + if (!labelType) continue; + + const labelTypeValue = labelType.values + .find((labelTypeValue) => labelTypeValue.text === labelTypeValueText) + if (!labelTypeValue) continue; + + if (!isThreatModelingLabelType(labelType) || !isThreatModelingLabelTypeValue(labelTypeValue)) continue; + + collisions.set( + computeCompositeKey(labelType, labelTypeValue), + { labelType, labelTypeValue } + ) + } + } + } + + //Search for a previous assignment that is excluded by the new assignment + for (const exclude of candidate.labelTypeValue.excludes) { + const { labelType, labelTypeValue } = labelTypeRegistry.resolveLabelAssignment(exclude); + if ( + !labelType + || !labelTypeValue + || !isThreatModelingLabelType(labelType) + || !isThreatModelingLabelTypeValue(labelTypeValue) + ) continue; + + if (line.trim() === `set ${labelType.name}.${labelTypeValue.text}`) { + collisions.set( + computeCompositeKey(labelType, labelTypeValue), + { labelType, labelTypeValue } + ); + } + } + } + + return Array.from(collisions.values()); +} + +/** + * Adds a label assignment to the output port behavior string, including the `excludes` relations. + */ +function addLabelAssignment( + portBehavior: string[], + toAdd: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, + labelTypeRegistry: LabelTypeRegistry +): string[] { + const setAssignment = `set ${toAdd.labelType.name}.${toAdd.labelTypeValue.text}` + const unsetAssignments: string[] = toAdd.labelTypeValue.excludes.map((exclude) => { + const { labelType, labelTypeValue } = labelTypeRegistry.resolveLabelAssignment(exclude) + if ( !labelType || !labelTypeValue ) return ""; + return `unset ${labelType.name}.${labelTypeValue.text}` + }) + + return [...portBehavior, setAssignment, ...unsetAssignments] +} + +/** + * Removes all assignments of a label from output port behavior string, including the `excludes` relations. + */ +function removeLabelAssignment( + portBehavior: string[], + toRemove: { labelType: ThreatModelingLabelType, labelTypeValue: ThreatModelingLabelTypeValue }, +): string[] { + let removing = false; + + return portBehavior.filter(line => { + if (line === `set ${toRemove.labelType.name}.${toRemove.labelTypeValue.text}`) { + removing = true; + return false; + } + + if (removing) { + if (line.startsWith("unset ")) { + return false; + } + + if (line.startsWith("set ")) { + removing = false; + return true; + } + } + + return true; + }); +} \ No newline at end of file diff --git a/frontend/webEditor/src/labels/LabelTypeEditorUi.ts b/frontend/webEditor/src/labels/LabelTypeEditorUi.ts index 9f8af3b4..1a641aaf 100644 --- a/frontend/webEditor/src/labels/LabelTypeEditorUi.ts +++ b/frontend/webEditor/src/labels/LabelTypeEditorUi.ts @@ -12,6 +12,7 @@ import { IActionDispatcher, TYPES } from "sprotty"; import { SETTINGS } from "../settings/Settings"; import { EditorModeController } from "../settings/editorMode"; import { ReplaceAction } from "./renameCommand"; +import { BeginLabelingProcessAction } from "../labelingProcess/labelingProcessCommand.ts"; export class LabelTypeEditorUi extends AccordionUiExtension { static readonly ID = "label-type-editor-ui"; @@ -46,6 +47,7 @@ export class LabelTypeEditorUi extends AccordionUiExtension { this.labelSectionContainer = document.createElement("div"); this.renderLabelTypes(); + contentElement.appendChild(this.buildAnnotationProcessButton()) contentElement.appendChild(this.labelSectionContainer); contentElement.appendChild(addButton); } @@ -53,6 +55,17 @@ export class LabelTypeEditorUi extends AccordionUiExtension { headerElement.innerText = "Label Types"; } + private buildAnnotationProcessButton(): HTMLElement { + const button = document.createElement("button"); + button.id = "annotation-process-button"; + button.innerHTML = "Start labeling process"; + button.onclick = () => { + this.actionDispatcher.dispatch(BeginLabelingProcessAction.create(this.labelTypeRegistry)); + }; + + return button; + } + private renderLabelTypes(): void { if (!this.labelSectionContainer) { return; diff --git a/frontend/webEditor/src/labels/LabelTypeRegistry.ts b/frontend/webEditor/src/labels/LabelTypeRegistry.ts index 35ee3295..800ff0c3 100644 --- a/frontend/webEditor/src/labels/LabelTypeRegistry.ts +++ b/frontend/webEditor/src/labels/LabelTypeRegistry.ts @@ -1,5 +1,5 @@ import { generateRandomSprottyId } from "../utils/idGenerator"; -import { LabelType, LabelTypeValue } from "./LabelType"; +import { LabelAssignment, LabelType, LabelTypeValue } from "./LabelType"; export class LabelTypeRegistry { private labelTypes: LabelType[] = []; @@ -98,4 +98,30 @@ export class LabelTypeRegistry { public getLabelType(id: string): LabelType | undefined { return this.labelTypes.find((type) => type.id === id); } + + public getAllLabelAssignments(): LabelAssignment[] { + return this.labelTypes + .map(labelType => labelType.values + .map(labelTypeValue => { + return { labelTypeId: labelType.id, labelTypeValueId: labelTypeValue.id } + }) + ) + .flat(); + } + + /** + * Resolves a `LabelAssignment` and returns the matching `LabelType` and `LabelTypeValue`. + * If the `LabelAssignment` cannot be resolved, returns `{}`. + * @param labelAssignment The IDs of the `LabelType` and `LabelTypeValue`. to resolve. + */ + public resolveLabelAssignment(labelAssignment: LabelAssignment): Partial<{ labelType: LabelType, labelTypeValue: LabelTypeValue }> + { + const labelType = this.getLabelType(labelAssignment.labelTypeId); + const labelTypeValue = labelType?.values + .find((value) => value.id === labelAssignment.labelTypeValueId); + + if (!labelType || !labelTypeValue) return {}; + + return {labelType, labelTypeValue}; + } } diff --git a/frontend/webEditor/src/labels/ThreatModelingLabelType.ts b/frontend/webEditor/src/labels/ThreatModelingLabelType.ts new file mode 100644 index 00000000..4b0bda2c --- /dev/null +++ b/frontend/webEditor/src/labels/ThreatModelingLabelType.ts @@ -0,0 +1,53 @@ +import { LabelAssignment, LabelType, LabelTypeValue } from "./LabelType.ts"; + +export interface ThreatModelingLabelType extends LabelType { + intendedFor: 'Vertex' | 'Flow' //TODO maybe stattdessen hier 'Node' und 'Edge' verwenden +} + +export interface ThreatModelingLabelTypeValue extends LabelTypeValue { + excludes: LabelAssignment[] + additionalInformation?: string +} + +export function isThreatModelingLabelType(labelType: LabelType): labelType is ThreatModelingLabelType { + return "intendedFor" in labelType; +} + +export function isThreatModelingLabelTypeValue(labelTypeValue: LabelTypeValue): labelTypeValue is ThreatModelingLabelTypeValue { + return "excludes" in labelTypeValue +} + +/** + * Transforms a `ThreatModelingLabelType` object to an object than can the backend can handle by removing additional + * attributes. + * @param labelType The `ThreatModelingLabelType` to transform + * @param recursive Whether the values of the `LabelType` should also be transformed into `LabelTypeValue` objects that + * can be sent to the backend + */ +export function threatModelingLabelTypeToBackendPayload( + labelType: ThreatModelingLabelType, + recursive: boolean +): LabelType { + const { intendedFor, values, ...defaultAttributes } = labelType + + let transformedValues = values + if (recursive) { + transformedValues = values.map(value => + isThreatModelingLabelTypeValue(value) + ? threatModelingLabelTypeValueToBackendPayload(value) + : value + ) + } + + return { ...defaultAttributes, values: transformedValues } +} + +/** + * Transforms a `ThreatModelingLabelTypeValue` object to an object than can the backend can handle by removing additional + * attributes. + * @param labelTypeValue The `ThreatModelingLabelTypeValue` to transform + */ +function threatModelingLabelTypeValueToBackendPayload(labelTypeValue: ThreatModelingLabelTypeValue): LabelTypeValue { + const { excludes, additionalInformation, ...defaultAttributes} = labelTypeValue; + return { ...defaultAttributes } +} \ No newline at end of file diff --git a/frontend/webEditor/src/labels/assignmentCommand.ts b/frontend/webEditor/src/labels/assignmentCommand.ts index 4a63efe5..4d305c98 100644 --- a/frontend/webEditor/src/labels/assignmentCommand.ts +++ b/frontend/webEditor/src/labels/assignmentCommand.ts @@ -147,7 +147,7 @@ export class LabelAssignmentCommand implements Command { } } -function getAllElements(elements: readonly SChildElementImpl[]): SModelElementImpl[] { +export function getAllElements(elements: readonly SChildElementImpl[]): SModelElementImpl[] { const elementsList: SModelElementImpl[] = []; for (const element of elements) { elementsList.push(element); diff --git a/frontend/webEditor/src/labels/dragAndDrop.ts b/frontend/webEditor/src/labels/dragAndDrop.ts index 2b0fef80..209308fe 100644 --- a/frontend/webEditor/src/labels/dragAndDrop.ts +++ b/frontend/webEditor/src/labels/dragAndDrop.ts @@ -59,7 +59,7 @@ export class DfdLabelMouseDropListener extends MouseListener { } } -function getParentWithDfdLabels( +export function getParentWithDfdLabels( element: SChildElementImpl | SModelElementImpl, ): (SModelElementImpl & ContainsDfdLabels) | undefined { if (containsDfdLabels(element)) { diff --git a/frontend/webEditor/src/labels/labelTypeEditorUi.css b/frontend/webEditor/src/labels/labelTypeEditorUi.css index f1ac3397..1dd558da 100644 --- a/frontend/webEditor/src/labels/labelTypeEditorUi.css +++ b/frontend/webEditor/src/labels/labelTypeEditorUi.css @@ -37,6 +37,30 @@ } } +#annotation-process-button { + background-color: green; + color: white; + border: none; + border-radius: 8px; + padding: 5px 10px; + text-align: center; + text-decoration: none; + display: inline-block; + width: fit-content; + cursor: pointer; +} + +#annotation-process-button::before { + content: ""; + background-image: url("@fortawesome/fontawesome-free/svgs/solid/play.svg"); + display: inline-block; + filter: invert(1); + height: 16px; + width: 16px; + background-size: 16px 16px; + vertical-align: text-top; +} + /* Label Type value */ .label-type-value input { background-color: var(--color-background); diff --git a/frontend/webEditor/src/serialize/analyze.ts b/frontend/webEditor/src/serialize/analyze.ts index 6b949abe..58347650 100644 --- a/frontend/webEditor/src/serialize/analyze.ts +++ b/frontend/webEditor/src/serialize/analyze.ts @@ -10,6 +10,10 @@ import { EditorModeController } from "../settings/editorMode"; import { Action } from "sprotty-protocol"; import { ConstraintRegistry } from "../constraint/constraintRegistry"; import { LoadingIndicator } from "../loadingIndicator/loadingIndicator"; +import { + isThreatModelingLabelType, + threatModelingLabelTypeToBackendPayload, +} from "../labels/ThreatModelingLabelType.ts"; export namespace AnalyzeAction { export const KIND = "analyze"; @@ -46,11 +50,15 @@ export class AnalyzeCommand extends LoadJsonCommand { protected async getFile(context: CommandExecutionContext): Promise | undefined> { const savedDiagram = { model: context.modelFactory.createSchema(context.root), - labelTypes: this.labelTypeRegistry.getLabelTypes(), + labelTypes: this.labelTypeRegistry.getLabelTypes().map(label => + isThreatModelingLabelType(label) ? + threatModelingLabelTypeToBackendPayload(label, true) + : label + ), constraints: this.constraintRegistry.getConstraintList(), mode: this.editorModeController.get(), version: CURRENT_VERSION, }; return await this.dfdWebSocket.requestDiagram("Json:" + JSON.stringify(savedDiagram)); } -} +} \ No newline at end of file diff --git a/frontend/webEditor/src/serialize/di.config.ts b/frontend/webEditor/src/serialize/di.config.ts index 2e34a49c..ec48e709 100644 --- a/frontend/webEditor/src/serialize/di.config.ts +++ b/frontend/webEditor/src/serialize/di.config.ts @@ -8,6 +8,9 @@ import { DfdModelFactory } from "./ModelFactory"; import { SaveJsonFileCommand } from "./saveJsonFile"; import { SaveDfdAndDdFileCommand } from "./saveDfdAndDdFile"; import { AnalyzeCommand } from "./analyze"; +import { SaveThreatsTableCommand } from "./saveThreatsTable.ts"; +import { LoadThreatModelingUserFileCommand } from "./loadThreatModelingUserFile.ts"; +import { LoadThreatModelingLinddunFileCommand } from "./loadThreatModelingLinddunFile.ts"; export const serializeModule = new ContainerModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; @@ -15,8 +18,11 @@ export const serializeModule = new ContainerModule((bind, unbind, isBound, rebin configureCommand(context, LoadJsonFileCommand); configureCommand(context, LoadDfdAndDdFileCommand); configureCommand(context, LoadPalladioFileCommand); + configureCommand(context, LoadThreatModelingUserFileCommand); + configureCommand(context, LoadThreatModelingLinddunFileCommand); configureCommand(context, SaveJsonFileCommand); configureCommand(context, SaveDfdAndDdFileCommand); + configureCommand(context, SaveThreatsTableCommand) configureCommand(context, AnalyzeCommand); rebind(TYPES.IModelFactory).to(DfdModelFactory); diff --git a/frontend/webEditor/src/serialize/linddun.json b/frontend/webEditor/src/serialize/linddun.json new file mode 100644 index 00000000..abccb2f4 --- /dev/null +++ b/frontend/webEditor/src/serialize/linddun.json @@ -0,0 +1,450 @@ +{ + "threatKnowledgeName": "LINDDUN", + "threatKnowledgeVersion": "0.4", + "xDecafVersion": 1, + "labels": [ + { + "id": "T-RhN06vgx", + "name": "DataForm", + "intendedFor": "Flow", + "values": [ + { + "id": "L-xUrF9mGH", + "text": "Hashed", + "excludes": [ + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-ISc2fMXE" + } + ], + "additionalInformation": "**Description:** \nData transformed using a one-way cryptographic function, typically for comparison or verification.\n\n**Examples:**\n- Hashed passwords\n- Hashed email addresses for matching\n- Hash-based identifiers\n\n**Elicitation questions:**\n- Is the hashing salted or unsalted?\n- Can the original value be reconstructed (directly or via lookup)?\n- Is hashing used consistently across systems?\n\n[[ChatGPT]]" + }, + { + "id": "L-XT9WKSWb", + "text": "Encrypted", + "excludes": [ + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-ISc2fMXE" + } + ], + "additionalInformation": "#todo There is a difference by who possesses the encryption key (e.g. symmetrical encryption maintains deniability, asymmetrical encryption does not)\n\n**Description:**\nData transformed using cryptography so it is unreadable without a decryption key.\n\n**Examples:**\n- Encrypted customer databases at rest\n- TLS-encrypted data in transit\n- Encrypted backups\n\n**Elicitation questions:**\n- Is the data encrypted at rest, in transit, or both?\n- Who controls the encryption keys?\n- Can the system process the data while it remains encrypted?\n\n[[ChatGPT]]" + }, + { + "id": "L-bQVu96ih", + "text": "Aggregated", + "excludes": [ + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-ISc2fMXE" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-xUrF9mGH" + } + ], + "additionalInformation": "**Description:** \nData combined across multiple records or individuals so that individual-level details are not directly visible.\n\n**Examples:**\n- Average daily step count across all users\n- Monthly sales totals by region\n- Percentage of users who enabled a feature\n\n**Elicitation questions:**\n- Can individual records be reconstructed from this data?\n- What is the minimum group size represented?\n- Is aggregation done before storage or only at reporting time?\n\n[[ChatGPT]]" + }, + { + "id": "L-cubKHVGd", + "text": "Noisy", + "excludes": [], + "additionalInformation": "**Description:** \nData deliberately altered with random noise to reduce accuracy and protect individuals (often used in privacy-preserving techniques).\n\n**Examples:**\n- Differentially private statistics\n- Location data with jitter added\n- Slightly perturbed survey results\n\n**Elicitation questions:**\n- What privacy mechanism introduces the noise?\n- Is the noise reversible or cumulative?\n- What accuracy trade-offs are acceptable?\n\n[[ChatGPT]]" + }, + { + "id": "L-ISc2fMXE", + "text": "Raw", + "excludes": [ + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-XT9WKSWb" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-ISc2fMXE" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-bQVu96ih" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-xUrF9mGH" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-PRhK8ZRm" + } + ], + "additionalInformation": "**Description:** \nData in its original, unprocessed form as collected from the source.\n\n**Examples:**\n- Raw sensor readings\n- Original application logs\n- Unfiltered user input\n\n**Elicitation questions:**\n- Is any transformation applied before storage?\n- How long is raw data retained?\n- Who has access to the raw form?\n\n[[ChatGPT]]" + }, + { + "id": "L-PRhK8ZRm", + "text": "Sanitized", + "excludes": [ + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-ISc2fMXE" + }, + { + "labelTypeId": "T-RhN06vgx", + "labelTypeValueId": "L-xUrF9mGH" + } + ], + "additionalInformation": "**Description:** \nData that has been cleaned or modified to remove or reduce sensitive elements.\n\n**Examples:**\n- Logs with IP addresses removed\n- Datasets with names redacted\n- Masked account numbers\n\n**Elicitation questions:**\n- What fields were removed or masked?\n- Is sanitization irreversible?\n- Is sanitization applied consistently?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-hqzmK4lj", + "name": "VertexLocation", + "intendedFor": "Vertex", + "values": [ + { + "id": "L-Y9Hi8FwJ", + "text": "User", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-BnqZZq2f" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-EU7CXdo5" + } + ], + "additionalInformation": "**Description:** \nData resides on or is processed directly on the user\u2019s device.\n\n**Examples:**\n- Data stored locally in a mobile app\n- On-device analytics or ML inference\n- Browser-based form data before submission\n\n**Elicitation questions:**\n- Does data leave the user\u2019s device?\n- Is data encrypted at rest on the device?\n- What happens if the device is lost or compromised?\n\n[[ChatGPT]]" + }, + { + "id": "L-BnqZZq2f", + "text": "Organization", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-Y9Hi8FwJ" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-EU7CXdo5" + } + ], + "additionalInformation": "**Description:** \nData is stored or processed within systems controlled by the organization.\n\n**Examples:**\n- Internal databases\n- Company-managed cloud infrastructure\n- Internal analytics platforms\n\n**Elicitation questions:**\n- Which internal systems store or process the data?\n- Who within the organization can access it?\n- In which countries or regions is it hosted?\n\n[[ChatGPT]]" + }, + { + "id": "L-EU7CXdo5", + "text": "External", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-Y9Hi8FwJ" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-BnqZZq2f" + } + ], + "additionalInformation": "**Description:** \nData is transferred to or processed by third parties outside the organization\u2019s direct control.\n\n**Examples:**\n- Cloud service providers\n- Payment processors\n- Analytics or marketing vendors\n\n**Elicitation questions:**\n- Which third parties receive this data?\n- What contractual or technical safeguards exist?\n- Is data shared in raw, pseudonymized, or aggregated form?\n- Is cross-border transfer involved?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-hDctW1RZ", + "name": "DataSensitivity", + "intendedFor": "Flow", + "values": [ + { + "id": "L-oHu78JWj", + "text": "Public", + "excludes": [ + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-h1fGHkX7" + } + ], + "additionalInformation": "**Description:** \nData that is intentionally made available to the general public.\n\n**Examples:**\n- Public social media posts\n- Published reports\n- Open government datasets\n\n**Elicitation questions:**\n- Was this data deliberately made public?\n- Are there reuse or licensing limits?\n- Could context make it sensitive anyway?\n\n[[ChatGPT]]" + }, + { + "id": "L-h1fGHkX7", + "text": "PersonalData", + "excludes": [ + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-oHu78JWj" + } + ], + "additionalInformation": "**Description:**\nData relating to an identified or identifiable individual.\n\n**Examples:**\n- Names, emails, IP addresses\n- Behavioral profiles\n- Location histories\n\n**Elicitation questions:**\n- Can this data identify a person directly or indirectly?\n- Is it regulated under privacy laws?\n- What harm could result from misuse?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-4lebOxF1", + "name": "DataIdentifiability", + "intendedFor": "Flow", + "values": [ + { + "id": "L-Vdnn8zIC", + "text": "QuasiIdentifiable", + "excludes": [], + "additionalInformation": "**Description:** \nData that is not directly identifying alone but can identify individuals when combined.\n\n**Examples:**\n- ZIP code + birth date + gender\n- Job title + department\n- Partial location histories\n\n**Elicitation questions:**\n- What other datasets could be combined with this?\n- Has re-identification risk been assessed?\n- Is this data shared externally?\n\n[[ChatGPT]]" + }, + { + "id": "L-4hzWLvca", + "text": "Pseudonym", + "excludes": [], + "additionalInformation": "**Description:** \nData linked to an identifier that does not directly reveal identity but can be re-linked.\n\n**Examples:**\n- User IDs instead of names\n- Device IDs\n- Tokenized identifiers\n\n**Elicitation questions:**\n- Where is the mapping between pseudonym and real identity stored?\n- Who can access the re-identification key?\n- Is the pseudonym stable over time?\n\n[[ChatGPT]]" + }, + { + "id": "L-AziCqUMB", + "text": "UniquelyIdentifiable", + "excludes": [], + "additionalInformation": "**Description:** \nData that uniquely and directly identifies a person.\n\n**Examples:**\n- Full name + address\n- National ID number\n- Biometric identifiers\n\n**Elicitation questions:**\n- Does this data uniquely identify an individual on its own?\n- Is it legally protected or regulated?\n- Is collection strictly necessary?\n\n[[ChatGPT]]" + }, + { + "id": "L-ki3tgyjB", + "text": "Authenticated", + "excludes": [], + "additionalInformation": "**Description:** \nData directly tied to a verified, logged-in identity.\n\n**Examples:**\n- User account profiles\n- Authenticated API usage logs\n- Payment transaction histories\n\n**Elicitation questions:**\n- Does this data require user authentication to generate or access?\n- Is identity verified or merely asserted?\n- Can activity be traced to a specific account?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-Qu9VVPG4", + "name": "DataInterveniability", + "intendedFor": "Flow", + "values": [ + { + "id": "L-u7wWmGo9", + "text": "Accessible", + "excludes": [], + "additionalInformation": "**Description:** \nIndividuals can access their data.\n\n**Examples:**\n- User dashboards\n- Data access request portals\n- Download-your-data features\n\n**Elicitation questions:**\n- How can users access their data?\n- Are there authentication or rate limits?\n- Is access complete or partial?\n\n[[ChatGPT]]" + }, + { + "id": "L-s2Op4rNS", + "text": "Deletable", + "excludes": [], + "additionalInformation": "**Description:** \nIndividuals can request deletion of their data.\n\n**Examples:**\n- Account deletion workflows\n- \u201cRight to be forgotten\u201d processes\n\n**Elicitation questions:**\n- Is deletion permanent or soft-deleted?\n- Are backups also deleted?\n- What legal exceptions apply?\n\n[[ChatGPT]]" + }, + { + "id": "L-7Ix5ydqW", + "text": "None", + "excludes": [ + { + "labelTypeId": "T-Qu9VVPG4", + "labelTypeValueId": "L-0YOIZs6L" + }, + { + "labelTypeId": "T-Qu9VVPG4", + "labelTypeValueId": "L-s2Op4rNS" + }, + { + "labelTypeId": "T-Qu9VVPG4", + "labelTypeValueId": "L-AJf2Su62" + } + ], + "additionalInformation": "# Description\n\n# Examples\n\n# Elicitation Questions" + }, + { + "id": "L-0YOIZs6L", + "text": "ControlViaPreferences", + "excludes": [], + "additionalInformation": "# Description\n\n# Examples\n\n# Elicitation Questions" + }, + { + "id": "L-IYBKXU20", + "text": "OptIn", + "excludes": [], + "additionalInformation": "**Description:** \nData is collected or processed only after explicit consent.\n\n**Examples:**\n- Marketing email subscriptions\n- Location tracking enabled by default off\n\n**Elicitation questions:**\n- What constitutes valid consent?\n- Is consent granular?\n- How is consent recorded?\n\n[[ChatGPT]]" + }, + { + "id": "L-uIQJl0lr", + "text": "OptOut", + "excludes": [], + "additionalInformation": "**Description:** \nData is collected by default but individuals can refuse or stop processing.\n\n**Examples:**\n- Analytics cookies with opt-out\n- Default personalization settings\n\n**Elicitation questions:**\n- How easy is it to opt out?\n- Does opt-out fully stop processing?\n- Are users clearly informed?\n\n[[ChatGPT]]" + }, + { + "id": "L-AJf2Su62", + "text": "RectificationPossible", + "excludes": [], + "additionalInformation": "**Description:** \nIndividuals can correct inaccurate or outdated data.\n\n**Examples:**\n- Profile edit features\n- Address update forms\n\n**Elicitation questions:**\n- Which fields can users edit themselves?\n- How are corrections propagated?\n- Are changes logged?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-3TGOog3X", + "name": "DataPrecision", + "intendedFor": "Flow", + "values": [ + { + "id": "L-JKy1Otrb", + "text": "ExcessiveDataTypes", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description:** \nUnnecessary categories or attributes are collected.\n\n**Examples:**\n- Collecting gender when not needed\n- Storing device fingerprints unnecessarily\n\n**Elicitation questions:**\n- Why is each data type needed?\n- What happens if this field is removed?\n- Who requested its inclusion?\n\n[[ChatGPT]]" + }, + { + "id": "L-uoXCDQCw", + "text": "ExcessiveFrequency", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description:** \nData is collected more often than required.\n\n**Examples:**\n- Continuous location tracking\n- Logging every keystroke\n\n**Elicitation questions:**\n- How often is this data actually used?\n- Can collection be event-based instead?\n- Is sampling sufficient?\n\n[[ChatGPT]]" + }, + { + "id": "L-76VjjaH1", + "text": "ExcessiveVolume", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description**\nThe total quantity of data collected and stored, considering scale across users, time, and systems. Volume affects storage risk, breach impact, system performance, and feasibility of privacy controls.\n\n**Examples**\n- Millions of daily log entries\n- High-resolution telemetry collected continuously\n- Small profile dataset per user but retained for millions of users\n- Large historical dataset kept for analytics\n\n**Elicitation questions**\n- How much data is collected per user, per event, or per time period?\n- What is the total volume currently stored?\n- How fast does the dataset grow?\n- Is all collected data actively used?\n- Could older data be summarized, sampled, or deleted?\n\n[[ChatGPT]]" + }, + { + "id": "L-I78hTu4G", + "text": "StrictlyNecessary", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-76VjjaH1" + }, + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-uoXCDQCw" + }, + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-JKy1Otrb" + }, + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-mRFUMGpc" + } + ], + "additionalInformation": "**Description:** \nOnly the minimum data required for the purpose is collected.\n\n**Examples:**\n- Age range instead of birth date\n- Country instead of exact address\n\n**Elicitation questions:**\n- What decision depends on each field?\n- Can this be collected at lower precision?\n- Has minimization been reviewed?\n\n[[ChatGPT]]" + }, + { + "id": "L-mRFUMGpc", + "text": "ExcessiveRetention", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description**\nThe length of time data is stored before it is deleted, anonymized, or irreversibly aggregated. Retention directly affects privacy risk, breach impact, and regulatory compliance.\n\n**Examples**\n- Authentication logs retained for 30 days for security investigations\n- Customer account data retained for the life of the account plus 90 days\n- Transaction records retained for 7 years to meet legal obligations\n- Telemetry data aggregated after 14 days and raw data deleted\n\n**Elicitation questions**\n- What is the defined retention period for this data?\n- Does retention differ between active systems, archives, and backups?\n- What event triggers deletion (time-based, account closure, user request)?\n- Is data deleted, anonymized, or merely archived at the end of retention?\n- Are there legal or contractual reasons for extended retention?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-IqL9df0D", + "name": "DataIntegrity", + "intendedFor": "Flow", + "values": [ + { + "id": "L-AcyIOqAF", + "text": "Signed", + "excludes": [ + { + "labelTypeId": "T-IqL9df0D", + "labelTypeValueId": "L-bDV34VU2" + } + ], + "additionalInformation": "#todo does Signed make sense like this?\n#todo There is a difference between symmetric and asymmetric Signatures\n\n**Description:** \nData includes a cryptographic signature to verify authenticity and integrity. This also removes deniability.\n\n**Examples:**\n- Digitally signed documents\n- Signed API payloads\n- Code signing certificates\n\n**Elicitation questions:**\n- How is the signature generated and verified?\n- What happens if verification fails?\n- Who controls the signing keys?\n\n[[ChatGPT]]" + }, + { + "id": "L-bDV34VU2", + "text": "Unsigned", + "excludes": [ + { + "labelTypeId": "T-IqL9df0D", + "labelTypeValueId": "L-AcyIOqAF" + } + ], + "additionalInformation": "**Description:** \nData has no cryptographic assurance of origin or integrity.\n\n**Examples:**\n- Plain text files\n- Unsigned logs\n- Unverified data imports\n\n**Elicitation questions:**\n- How do you detect tampering or corruption?\n- Is integrity assumed or enforced elsewhere?\n- Is this acceptable for the data\u2019s purpose?\n\n[[ChatGPT]]" + } + ] + }, + { + "id": "T-1ez1Gjk9", + "name": "Observability", + "intendedFor": null, + "values": [ + { + "id": "L-irbu2qs8", + "text": "FullyVisible", + "excludes": [ + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-M5uQ5X3f" + }, + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-nvKZS1U9" + } + ], + "additionalInformation": "**Description:** \nThe data content itself is visible to observers or systems.\n\n**Examples:**\n- Plaintext messages\n- Visible transaction details\n\n**Elicitation questions:**\n- Who can view the full content?\n- Is visibility role-based?\n- Is access logged?\n\n[[ChatGPT]]" + }, + { + "id": "L-M5uQ5X3f", + "text": "MetadataOnly", + "excludes": [ + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-irbu2qs8" + }, + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-nvKZS1U9" + } + ], + "additionalInformation": "**Description:** \nOnly contextual information is observable, not the content.\n\n**Examples:**\n- Email headers without body\n- Encrypted message metadata\n- Call timestamps and duration\n\n**Elicitation questions:**\n- What metadata is retained?\n- Can metadata alone reveal sensitive patterns?\n- Is metadata minimized?\n\n[[ChatGPT]]" + }, + { + "id": "L-nvKZS1U9", + "text": "NonObservable", + "excludes": [ + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-irbu2qs8" + }, + { + "labelTypeId": "T-1ez1Gjk9", + "labelTypeValueId": "L-M5uQ5X3f" + } + ], + "additionalInformation": "**Description:** \nNeither content nor metadata is accessible.\n\n**Examples:**\n- End-to-end encrypted messages with sealed metadata\n- Secure enclaves\n\n**Elicitation questions:**\n- Who (if anyone) can observe anything?\n- Is this by design or limitation?\n- How is misuse detected?\n\n[[ChatGPT]]" + } + ] + } + ], + "constraints": [ + { + "name": "DataWithExcessiveFrequencyIsProcessed", + "constraint": "data DataPrecision.ExcessiveFrequency neverFlows vertex VertexLocation.Organization,VertexLocation.External" + }, + { + "name": "DataWithExcessiveVolumeIsProcessed", + "constraint": "data DataPrecision.ExcessiveVolume neverFlows vertex VertexLocation.Organization,VertexLocation.External" + }, + { + "name": "IdentifyingThroughQuasiIdentifier", + "constraint": "data DataIdentifiability.QuasiIdentifiable neverFlows vertex VertexLocation.Organization,VertexLocation.External" + }, + { + "name": "IdentifyingThroughUniqueIdentifier", + "constraint": "data DataIdentifiability.UniquelyIdentifiable neverFlows vertex VertexLocation.Organization,VertexLocation.External" + }, + { + "name": "NonRepudiationThroughSignature", + "constraint": "data DataIntegrity.Signed neverFlows vertex VertexLocation.Organization" + } + ] +} diff --git a/frontend/webEditor/src/serialize/loadThreatModelingFile.ts b/frontend/webEditor/src/serialize/loadThreatModelingFile.ts new file mode 100644 index 00000000..854bb8a2 --- /dev/null +++ b/frontend/webEditor/src/serialize/loadThreatModelingFile.ts @@ -0,0 +1,193 @@ +import { + Command, + CommandExecutionContext, + CommandReturn, IActionDispatcher, + ILogger, + ISnapper, + SModelElementImpl, + SModelRootImpl, + SNodeImpl, + TYPES, +} from "sprotty"; +import { Action } from "sprotty-protocol"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry.ts"; +import { ConstraintRegistry } from "../constraint/constraintRegistry.ts"; +import { LoadingIndicator } from "../loadingIndicator/loadingIndicator.ts"; +import { chooseFile } from "./fileChooser.ts"; +import { inject } from "inversify"; +import { ThreatModelingLabelType, ThreatModelingLabelTypeValue } from "../labels/ThreatModelingLabelType.ts"; +import { LabelAssignment, LabelType, LabelTypeValue } from "../labels/LabelType.ts"; +import { Constraint } from "../constraint/Constraint.ts"; +import { getAllElements } from "../labels/assignmentCommand.ts"; +import { ContainsDfdLabels, containsDfdLabels } from "../labels/feature.ts"; +import { snapPortsOfNode } from "../diagram/ports/portSnapper.ts"; +import { DfdOutputPortImpl } from "../diagram/ports/DfdOutputPort.tsx"; +import { ResetLabelingProcessAction } from "../labelingProcess/labelingProcessCommand.ts"; + +// Replaces the type of the `values` of a `LabelType` with a subclass of `LabelTypeValue` +type OverwriteLabelTypeValueType = Omit & { values: S[] } + +export type ThreatModelingFileFormat = { + threatKnowledgeName: string, + threatKnowledgeVersion: string, + labels: OverwriteLabelTypeValueType[], + constraints: Constraint[] +} + +export abstract class LoadThreatModelingFileCommand extends Command { + + private fileContent: ThreatModelingFileFormat | undefined; + + // UNDO / REDO storage + private oldLabelTypes: LabelType[] | undefined; + private oldLabelAssignments: Map = new Map(); + private oldOutputPortBehavior: Map = new Map(); + private newOutputPortBehavior: Map = new Map(); + private oldConstraints: Constraint[] | undefined; + + constructor( + @inject(TYPES.Action) _: Action, + @inject(TYPES.ILogger) private readonly logger: ILogger, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, + @inject(ConstraintRegistry) private readonly constraintRegistry: ConstraintRegistry, + @inject(LoadingIndicator) private readonly loadingIndicator: LoadingIndicator, + @inject(TYPES.ISnapper) private readonly snapper: ISnapper, + @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher, + ) { + super(); + } + + protected async getFileContent(): Promise { + const file = await chooseFile(["application/json"]); + if (!file) return undefined + + return JSON.parse(file.content) as ThreatModelingFileFormat; + } + + async execute(context: CommandExecutionContext): Promise { + this.loadingIndicator.showIndicator("Loading labels and constraints..."); + + const fileContent = await this.getFileContent() + if (!fileContent) return context.root; + + this.logger.info(this, "File loaded successfully.") + this.fileContent = fileContent; + + //Import labels + this.oldLabelTypes = this.labelTypeRegistry.getLabelTypes(); + const newLabelTypes = this.fileContent.labels; + this.labelTypeRegistry.clearLabelTypes(); + this.labelTypeRegistry.setLabelTypes(newLabelTypes); + this.logger.info(this, "Label types loaded successfully"); + + //Remove all old LabelAssignments + const allElements = getAllElements(context.root.children); + + const allDfdLabelElements = allElements + .filter((element) => containsDfdLabels(element)); + allDfdLabelElements.forEach(element => { + if (element.labels.length > 0) { + this.oldLabelAssignments.set(element, element.labels); + element.labels = []; + if (element instanceof SNodeImpl) { + snapPortsOfNode(element, this.snapper); + } + } + }); + this.logger.info(this, "Removed label assignments"); + + //Remove OutputPin Behavior except 'forward' + const allOutputPorts = allElements + .filter((element) => element instanceof DfdOutputPortImpl) + allOutputPorts.forEach(outputPort => { + const outputPortBehavior = outputPort.getBehavior() + + this.oldOutputPortBehavior.set(outputPort, outputPortBehavior); + + //Keep only 'forward' behavior, discard the rest + const match = outputPortBehavior.match(/^forward\s+\S+(?:\|\S+)*/); + const newBehavior = match ? match[0] : ""; + this.newOutputPortBehavior.set(outputPort, newBehavior); + outputPort.setBehavior(newBehavior); + }) + this.logger.info(this, "Updated output port behavior"); + + + //Import constraints + this.oldConstraints = this.constraintRegistry.getConstraintList(); + const newConstraints = this.fileContent.constraints; + this.constraintRegistry.clearConstraints(); + this.constraintRegistry.setConstraintsFromArray(newConstraints); + this.logger.info(this, "Constraints loaded successfully"); + + //Reset labeling process + this.actionDispatcher.dispatch(ResetLabelingProcessAction.create()) + + this.loadingIndicator.hide(); + return context.root; + } + + undo(context: CommandExecutionContext): CommandReturn { + if (!this.oldLabelTypes || !this.oldConstraints) return context.root; + + // LabelTypes and Labels + this.labelTypeRegistry.clearLabelTypes(); + this.labelTypeRegistry.setLabelTypes(this.oldLabelTypes); + this.logger.info(this, "Label types loaded successfully"); + + // LabelAssignments + this.oldLabelAssignments.forEach((labels, element) => { + element.labels = labels; + if (element instanceof SNodeImpl) { + snapPortsOfNode(element, this.snapper); + } + }); + this.logger.info(this, "Label assignments restored"); + + //OutputPin Behavior + this.oldOutputPortBehavior.forEach((behavior, outputPort) => { + outputPort.setBehavior(behavior); + }) + this.logger.info(this, "Updated output port behavior"); + + // Constraints + this.constraintRegistry.clearConstraints(); + this.constraintRegistry.setConstraintsFromArray(this.oldConstraints); + this.logger.info(this, "Constraints loaded successfully"); + + return context.root; + } + + redo(context: CommandExecutionContext): CommandReturn { + const newLabelTypes = this.fileContent?.labels; + const newConstraints = this.fileContent?.constraints; + if (!newLabelTypes || !newConstraints) return context.root; + + // LabelTypes and Labels + this.labelTypeRegistry.clearLabelTypes(); + this.labelTypeRegistry.setLabelTypes(newLabelTypes); + this.logger.info(this, "Label types loaded successfully"); + + // LabelAssignments + this.oldLabelAssignments.forEach((_, element) => { + element.labels = []; + if (element instanceof SNodeImpl) { + snapPortsOfNode(element, this.snapper); + } + }); + this.logger.info(this, "Label assignments restored"); + + //OutputPin Behavior + this.newOutputPortBehavior.forEach((behavior, outputPort) => { + outputPort.setBehavior(behavior); + }) + this.logger.info(this, "Updated output port behavior"); + + // Constraints + this.constraintRegistry.clearConstraints(); + this.constraintRegistry.setConstraintsFromArray(newConstraints); + this.logger.info(this, "Constraints loaded successfully"); + + return context.root; + } +} \ No newline at end of file diff --git a/frontend/webEditor/src/serialize/loadThreatModelingLinddunFile.ts b/frontend/webEditor/src/serialize/loadThreatModelingLinddunFile.ts new file mode 100644 index 00000000..52529b42 --- /dev/null +++ b/frontend/webEditor/src/serialize/loadThreatModelingLinddunFile.ts @@ -0,0 +1,19 @@ +import { Action } from "sprotty-protocol"; +import { LoadThreatModelingFileCommand, ThreatModelingFileFormat } from "./loadThreatModelingFile.ts"; +import LINDDUN from './linddun.json' + +export namespace LoadThreatModelingLinddunFileAction { + export const KIND = "LoadThreatModelingLINDDUNFile"; + + export function create(): Action { + return { kind: KIND }; + } +} + +export class LoadThreatModelingLinddunFileCommand extends LoadThreatModelingFileCommand { + static readonly KIND = LoadThreatModelingLinddunFileAction.KIND; + + override async getFileContent(): Promise { + return LINDDUN as ThreatModelingFileFormat; + } +} \ No newline at end of file diff --git a/frontend/webEditor/src/serialize/loadThreatModelingUserFile.ts b/frontend/webEditor/src/serialize/loadThreatModelingUserFile.ts new file mode 100644 index 00000000..bb8f2387 --- /dev/null +++ b/frontend/webEditor/src/serialize/loadThreatModelingUserFile.ts @@ -0,0 +1,14 @@ +import { LoadThreatModelingFileCommand } from "./loadThreatModelingFile.ts"; +import { Action } from "sprotty-protocol"; + +export namespace LoadThreatModelingUserFileAction { + export const KIND = "loadThreatModelingUserFile"; + + export function create(): Action { + return { kind: KIND }; + } +} + +export class LoadThreatModelingUserFileCommand extends LoadThreatModelingFileCommand { + static readonly KIND = LoadThreatModelingUserFileAction.KIND; +} \ No newline at end of file diff --git a/frontend/webEditor/src/serialize/saveThreatsTable.ts b/frontend/webEditor/src/serialize/saveThreatsTable.ts new file mode 100644 index 00000000..b11c7a3f --- /dev/null +++ b/frontend/webEditor/src/serialize/saveThreatsTable.ts @@ -0,0 +1,110 @@ +import { CommandExecutionContext, TYPES } from "sprotty"; +import { FileData } from "./loadJson"; +import { SaveFileCommand } from "./saveFile"; +import { EditorModeController } from "../settings/editorMode"; +import { inject } from "inversify"; +import { LabelTypeRegistry } from "../labels/LabelTypeRegistry"; +import { Action } from "sprotty-protocol"; +import { FileName } from "../fileName/fileName"; +import { SETTINGS } from "../settings/Settings"; +import { ConstraintRegistry } from "../constraint/constraintRegistry"; +import { LoadingIndicator } from "../loadingIndicator/loadingIndicator"; +import { getAllElements } from "../labels/assignmentCommand.ts"; +import { DfdNodeImpl } from "../diagram/nodes/common.ts"; +import { DfdNodeAnnotation } from "../annotation/DFDNodeAnnotation.ts"; + +const CSV_COLUMN_SEPARATOR = "," +const CSV_LINE_SEPARATOR = "\n" + +export namespace SaveThreatsTableAction { + export const KIND = "saveThreatsTable"; + export function create(): Action { + return { kind: KIND }; + } +} + +export class SaveThreatsTableCommand extends SaveFileCommand { + static readonly KIND = SaveThreatsTableAction.KIND; + + constructor( + @inject(TYPES.Action) _: Action, + @inject(LabelTypeRegistry) LabelTypeRegistry: LabelTypeRegistry, + @inject(ConstraintRegistry) constraintRegistry: ConstraintRegistry, + @inject(SETTINGS.Mode) editorModeController: EditorModeController, + @inject(FileName) private readonly fileName: FileName, + @inject(LoadingIndicator) loadingIndicator: LoadingIndicator, + ) { + super(LabelTypeRegistry, constraintRegistry, editorModeController, loadingIndicator); + } + + getFiles(context: CommandExecutionContext): Promise[]> { + const allDfdNodeElements = getAllElements(context.root.children) + .filter((elem) => elem instanceof DfdNodeImpl); + + const toExport: { nodeId: string; nodeText: string; violatedConstraint: string }[] = []; + for (const dfdNode of allDfdNodeElements) { + for (const annotation of dfdNode.annotations) { + if (!SaveThreatsTableCommand.isViolation(annotation)) { + continue; + } + + toExport.push({ + nodeId: dfdNode.id, + nodeText: dfdNode.text, + violatedConstraint: this.extractViolatedConstraintFromMessage(annotation.message), + }); + } + } + + const fileData: FileData = { + fileName: this.fileName.getName() + ".csv", + content: SaveThreatsTableCommand.toCSV(toExport), + }; + return Promise.resolve([fileData]); + } + + private static isViolation(annotation: DfdNodeAnnotation): boolean { + return annotation.message.includes("violated"); + } + + private extractViolatedConstraintFromMessage(message: string): string { + return message + .replace("Constraint ", "") + .replace(" violated", "") + } + + private static toCSV(array: T[]): string { + let csv = "" + + if (array.length == 0) return csv; + + //Header + for (const headerEntry of Object.keys(array[0])) { + csv += SaveThreatsTableCommand.escapeCSVEntry(headerEntry) + csv += CSV_COLUMN_SEPARATOR + } + csv += CSV_LINE_SEPARATOR + + //Content + for (const row of array) { + for (const entry of Object.values(row)) { + csv += SaveThreatsTableCommand.escapeCSVEntry(entry) + csv += CSV_COLUMN_SEPARATOR + } + csv += CSV_LINE_SEPARATOR + } + + return csv + } + + private static escapeCSVEntry(value: unknown): string { + if (value == null) return ""; + + const str = String(value) + if (/[",\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; + } +}