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..74550408 --- /dev/null +++ b/frontend/webEditor/src/labelingProcess/labelingProcessUi.ts @@ -0,0 +1,194 @@ +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"; +import { SaveJsonFileAction } from "../serialize/saveJsonFile.ts"; + +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', async () => { + await this.actionDispatcher.dispatch(SaveJsonFileAction.create()) + await this.actionDispatcher.dispatchAll([ + AnalyzeAction.create(), + SelectConstraintsAction.create(this.constraintRegistry.getConstraintList().map((c) => c.name)), + ]) + await 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..d68c73d7 --- /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' +} + +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..921fe0db --- /dev/null +++ b/frontend/webEditor/src/serialize/linddun.json @@ -0,0 +1,497 @@ +{ + "threatKnowledgeName": "LINDDUN", + "threatKnowledgeVersion": "1", + "xDecafVersion": 1, + "labels": [ + { + "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-jdroojuo" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-oHu78JWj" + } + ], + "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[[ChatGPT]]" + }, + { + "id": "L-BnqZZq2f", + "text": "Organization", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-Y9Hi8FwJ" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-jdroojuo" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-oHu78JWj" + } + ], + "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\n[[ChatGPT]]" + }, + { + "id": "L-oHu78JWj", + "text": "Public", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-Y9Hi8FwJ" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-BnqZZq2f" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-jdroojuo" + } + ], + "additionalInformation": "**Description**\n- Endpoints that are accessible by anyone. No technical safeguards prevent or limit that access to data.\n\n**Examples**\n- Searching user by phone number can be used to discover/confirm someone else's phone number.\n- The original publication of a company's foundation reveals the home addresses of the founders.\n- Query interfaces to search for people working in specific area, may reveal their location of employment.\n- Posting private messages to a person through a social network in a public forum that may be seen and monitored by many.\n- The lack of encryption enables an external adversary to read the personal data being exchanged with the system.\n\n**Elicitation Questions**\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "id": "L-jdroojuo", + "text": "ThirdParty", + "excludes": [ + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-Y9Hi8FwJ" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-BnqZZq2f" + }, + { + "labelTypeId": "T-hqzmK4lj", + "labelTypeValueId": "L-oHu78JWj" + } + ], + "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[[ChatGPT]]" + } + ] + }, + { + "id": "T-hDctW1RZ", + "name": "DataSensitivity", + "intendedFor": "Flow", + "values": [ + { + "id": "L-h1fGHkX7", + "text": "PersonalData", + "excludes": [ + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-lovys0HK" + } + ], + "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": "L-lovys0HK", + "text": "PublicData", + "excludes": [ + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-h1fGHkX7" + }, + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-KQpPJy98" + } + ], + "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-KQpPJy98", + "text": "PersonalDataAboutOtherPeople", + "excludes": [ + { + "labelTypeId": "T-hDctW1RZ", + "labelTypeValueId": "L-lovys0HK" + } + ], + "additionalInformation": "**Description**\n\n**Examples**\n\n**Elicitation Questions**" + } + ] + }, + { + "id": "T-Qu9VVPG4", + "name": "DataInterveniability", + "intendedFor": "Flow", + "values": [ + { + "id": "L-u7wWmGo9", + "text": "Accessible", + "excludes": [], + "additionalInformation": "**Description:** \nThe lack of control in accessing the personal data that is being processed.\n\n**Examples:**\n- The data subject is unable to access their personal data held and processed by the service.\n- The sensor data from a wearable is transmitted to a lifestyle tracking app, but the user cannot access statistics and information derived from their data.\n- It is not possible for a data subject to request access, neither directly through the system nor indirectly through a helpdesk.\n\n**Elicitation questions:**\n- Do data subjects lack the ability to access the personal data being collected, processed, stored, or disclosed about them?\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "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**\nThe lack of control of a data subject to set preferences or consent in how personal data is processed (or collected/stored).\n\n**Examples**\n- The data subject is unable to set appropriate preferences on which data is shared and why.\n- The data subject is unable to set their consent preferences for the processing of personal data.\n\n**Elicitation Questions**\n- Does the system enable the data subject to configure which personal data is processed and for what purposes?\n- Can the data subject alter their preferences afterwards?\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "id": "L-AJf2Su62", + "text": "RectificationPossible", + "excludes": [], + "additionalInformation": "**Description:** \nThe control (or lack thereof) in rectifying incorrect data or removing personal data that is no longer relevant.\n\n**Examples:**\n- The data subject is unable to correct their personal data.\n- When a data subject deletes their social media account, the account is disabled, but the actual data is not erased.\n- A data subject is unable to update their home address after relocating.\n\n**Elicitation questions:**\n- Do data subjects have the ability to correct or delete their personal data?\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "id": "L-W2Cqnyye", + "text": "Awareness", + "excludes": [], + "additionalInformation": "**Description**\n(Un)awareness of processing focuses on data subjects (not) being aware about the collection, processing, storage, or disclosure of their personal data. The user does not need to be the data subject.\n\n**Examples**\n- Data subjects are not aware of the identities of the third parties with whom their data will be shared.\n- The privacy notice provided to the data subject was not presented in clear and plain language.\n- Data subjects are unaware that traffic cameras collect not only number plates but also facial images.\n- The data subject is unaware that their personal data is collected from third parties to construct more detailed user profiles for targeted advertising (e.g., Facebook).\n- The data subject is unaware the data may also be used for marketing purposes.\n- The data subject was not informed why or for how long their personal data is processed.\n- A user posting a picture on social media may not be aware that others in the picture are automatically tagged with a facial recognition system.\n- Using a DNA testing service can entail sharing medical information about family members.\n- A service invites a user to share their address book to find contacts on a service.\n\n**Elicitation Questions**\n- Are data subjects insufficiently informed about the processing of personal data, including the purposes and methods of the processing involved?\n- If a user shares personal data of others, is it clear what, why, and how that data is further processed?\n\n[[LINDDUN (Sion 2025)]]" + } + ] + }, + { + "id": "T-3TGOog3X", + "name": "DataPrecision", + "intendedFor": "Flow", + "values": [ + { + "id": "L-JKy1Otrb", + "text": "ExcessiveDataTypes", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description:** \nThe system acquires more sensitive or \ufb01negrained data than strictly necessary for its functionality.\n\n**Examples:**\n- Tracking a patient\u2019s weight is pertinent for dieting apps but not for a contact tracing application.\n- A smart meter shares realtime measurements rather than the aggregated consumption.\n- A camera application on a smartphone does not necessarily need to record the picture\u2019s location.\n\n**Elicitation questions:**\n- Is the data more sensitive than strictly necessary?\n- Is the data more \ufb01ne-grained than strictly necessary?\n- Does the data encoding include additional (meta)data?\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "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 system acquires more data than strictly needed for its functionality.\n\n**Examples**\n- Small profile dataset per user but retained for millions of users\n- Large historical dataset kept for analytics\n\n**Elicitation questions**\n- Is the amount of collected data necessary for the correct functioning of the system?\n- Are there more data subjects involved than necessary?\n\n[[LINDDUN (Sion 2025)]]" + }, + { + "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": "L-sZLmyixT", + "text": "ExcessiveEnrichment", + "excludes": [ + { + "labelTypeId": "T-3TGOog3X", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description**\nPersonal data is further treated, analyzed, and enriched in a way that is not necessary to achieve the functionality of the system.\n\n**Examples**\n- A camera application on a smartphone does not need to perform face-based recognition or emotion detection.\n- Analyzing a user's blog posts for language proficiency is unnecessary for blogging functionality.\n- User profiles accumulate unnecessary details over time, tracking a broad range of actions or service usage that is not essential for the provided functionality.\n- Sentiment analysis can be applied to faces in pictures to derive the emotional state of the individual in the photo.\n\n**Elicitation Questions**\n- Is the data enrichment/analysis necessary for the system's functionality?\n\n[[LINDDUN (Sion 2025)]]" + } + ] + }, + { + "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-OEnmvpc0", + "name": "DataIdentifiers", + "intendedFor": "Flow", + "values": [ + { + "id": "L-mSnT5dRo", + "text": "QuasiIdentifier", + "excludes": [], + "additionalInformation": "**Description:** \nData that contains combinations of attributes that are unique to a data subject.\n\n**Examples:**\n- Browser fingerprinting entails the combination of various properties to create a unique identifier, serving as a pseudonym for the user.\n- A combination of various attributes (city, birth date, language preference, etc.) could be adequate to identify individuals.\n\n**Elicitation questions:**\n- Is there free-form user provided data that is received or processed by the system?\n- Is data collected that may reveal the identifying information?" + }, + { + "id": "L-KMjL5GNo", + "text": "DirectIdentifier", + "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\n[[ChatGPT]]" + }, + { + "id": "L-fJBfXb28", + "text": "RevealingAttributes", + "excludes": [], + "additionalInformation": "**Description**\nA number of revealing attributes are included in the data which support the identification of the data subject.\n\n**Examples**\n- When an individual shares detailed data (such as location, employer, device type, etc.) in a feedback form, the provided information may be revealing enough to uniquely identify that person.\n\n**Elicitation Questions**\n- Is there free-form user provided data that is received or processed by the system?\n- Is data collected that may reveal the identifying information?" + } + ] + }, + { + "id": "T-VRJKLL6a", + "name": "DataObservability", + "intendedFor": null, + "values": [ + { + "id": "L-irbu2qs8", + "text": "FullyVisible", + "excludes": [ + { + "labelTypeId": "T-VRJKLL6a", + "labelTypeValueId": "L-M5uQ5X3f" + }, + { + "labelTypeId": "T-VRJKLL6a", + "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-VRJKLL6a", + "labelTypeValueId": "L-irbu2qs8" + }, + { + "labelTypeId": "T-VRJKLL6a", + "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-VRJKLL6a", + "labelTypeValueId": "L-irbu2qs8" + }, + { + "labelTypeId": "T-VRJKLL6a", + "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]]" + } + ] + }, + { + "id": "T-06mBkaWM", + "name": "Data", + "intendedFor": "Flow", + "values": [ + { + "id": "L-kX4fJEps", + "text": "HasSideEffects", + "excludes": [ + { + "labelTypeId": "T-06mBkaWM", + "labelTypeValueId": "L-I78hTu4G" + } + ], + "additionalInformation": "**Description**\nAction side-effects can cause an action to be attributable to an individual.\n\n**Examples**\n- Read notifications serve as evidence that the user has opened/read the message.\n- An individual's browser history may be used to substantiate claims about their online activities.\n- Actions can be logged as evidence.\n\n**Elicitation Questions**\n- Do (passive) interactions with the system (e.g., receiving a message) have side-effects (e.g., trigger transmissions, logging)?" + }, + { + "id": "L-OFziM7dm", + "text": "ContainsHiddenData", + "excludes": [], + "additionalInformation": "**Description**\nEmbedded or hidden data can be used to reveal additional information.\n\n**Examples**\n- Data watermarked with hidden artifacts (uniquely linked to a person) can be used to track the person revealing or disclosing the data afterwards.\n- Remote resources (e.g. image in email) are automatically loaded to track the user opening it.\n- Secrets shared with one person, or modified for each recipient can serve as evidence of the disclosure of that data by that person.\n\n**Elicitation Questions**\n- Are there embedded data or hidden patterns in the data or transmissions?" + } + ] + } + ], + "constraints": [ + { + "name": "DataWithExcessiveFrequencyIsProcessed", + "constraint": "data DataPrecision.ExcessiveFrequency neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "DataWithExcessiveVolumeIsProcessed", + "constraint": "data DataPrecision.ExcessiveVolume neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "NonRepudiationThroughSignature", + "constraint": "data DataIntegrity.Signed neverFlows vertex VertexLocation.Organization" + }, + { + "name": "DataWithExcessiveTypesIsProcessed", + "constraint": "data DataPrecision.ExcessiveDataTypes neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "DataIsEnriched", + "constraint": "data DataPrecision.ExcessiveEnrichment neverFlows VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "DataIsStoredForLongerThanNeeded", + "constraint": "data DataPrecision.ExcessiveRetention neverFlows VertexLocation.Organization" + }, + { + "name": "PersonalDataCollectionCannotBeControlled", + "constraint": "data !DataInterveniability.ControlViaPreferences neverFlows vertex VertexLocation.Organization" + }, + { + "name": "PersonalDataCannotBeAccessed", + "constraint": "data !DataInterveniability.Accessible neverFlows vertex VertexLocation.Organization" + }, + { + "name": "PersonalDataCannotBeRectified", + "constraint": "data !DataInterveniability.RectificationPossible neverFlows vertex VertexLocation.Organization" + }, + { + "name": "InsufficientTransparency", + "constraint": "data !DataInterveniability.Awareness neverFlows vertex VertexLocation.Organization" + }, + { + "name": "StoringofIdentifiedData", + "constraint": "data DataIdentifiers.DirectIdentifier,DataIdentifiers.QuasiIdentifier neverFlows vertex type STORE" + }, + { + "name": "LinkingThroughUniqueIdentifier", + "constraint": "data DataIdentifiers.DirectIdentifier neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "DataIsDisclosedToOtherParties", + "constraint": "neverFlows vertex VertexLocation.ThirdParty,VertexLocation.Public" + }, + { + "name": "LinkingThroughQuasiIdentifier", + "constraint": "data DataIdentifiers.QuasiIdentifier neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "LinkingThroughDataOfDifferentIndividuals", + "constraint": "data DataSensitivity.PersonalDataAboutOtherPeople neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "ProfilingAnIndividual", + "constraint": "data DataSensitivity.PersonalData neverFlows vertex VertexLocation.Organization,VertexLocation.ThirdParty" + }, + { + "name": "PersonalDataIsPublishedMoreBroadlyThanNecessary", + "constraint": "data DataSensitivity.PersonalData neverFlows VertexLocation.Public" + }, + { + "name": "DataHasSideEffects", + "constraint": "data Data.HasSideEffects neverFlows" + }, + { + "name": "NonRepudiationThroughHiddenData", + "constraint": "data Data.ContainsHiddenData neverFlows vertex VertexLocation.Organization" + }, + { + "name": "ImplicitDataDisclosure", + "constraint": "data Data.HasSideEffects neverFlows vertex type STORE" + }, + { + "name": "ProcessingOfIdentifiedData", + "constraint": "data DataIdentifiers.DirectIdentifier neverFlows vertex VertexLocation.Organization" + }, + { + "name": "ProcessingOfIdentifiedMetadata", + "constraint": "data DataIdentifiers.DirectIdentifier data DataObservability.MetadataOnly neverFlows vertex VertexLocation.Organization" + }, + { + "name": "ProcessingOfQuasiIdentifiedData", + "constraint": "data DataIdentifiers.QuasiIdentifier neverFlows vertex VertexLocation.Organization" + }, + { + "name": "ProcessingOfRevealingAttributes", + "constraint": "data DataIdentifiers.RevealingAttributes neverFlows vertex VertexLocation.Organization" + } + ] +} \ No newline at end of file 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; + } +}