diff --git a/td.vue/src/components/Graph.vue b/td.vue/src/components/Graph.vue
index 16a435844..b43f2a8c2 100644
--- a/td.vue/src/components/Graph.vue
+++ b/td.vue/src/components/Graph.vue
@@ -82,9 +82,18 @@ export default {
threatSelected(threatId) {
this.$refs.threatEditDialog.editThreat(threatId);
},
- saved() {
+ savedtd() {
const updated = Object.assign({}, this.diagram);
updated.cells = this.graph.toJSON().cells;
+ updated.format = "td";
+ this.$store.dispatch(tmActions.diagramUpdated, updated);
+ this.$store.dispatch(tmActions.save);
+ this.$store.dispatch(tmActions.unmodified);
+ },
+ savedotm() {
+ const updated = Object.assign({}, this.diagram);
+ updated.cells = this.graph.toJSON().cells;
+ updated.format = "otm";
this.$store.dispatch(tmActions.diagramUpdated, updated);
this.$store.dispatch(tmActions.save);
this.$store.dispatch(tmActions.unmodified);
diff --git a/td.vue/src/components/GraphButtons.vue b/td.vue/src/components/GraphButtons.vue
index d53840d10..e53ea0ffd 100644
--- a/td.vue/src/components/GraphButtons.vue
+++ b/td.vue/src/components/GraphButtons.vue
@@ -48,11 +48,10 @@
icon="times"
:text="$t('forms.close')" />
-
+
+ {{$t('forms.savetd')}}
+ {{$t('forms.saveotm')}}
+
@@ -76,8 +75,11 @@ export default {
}
},
methods: {
- save() {
- this.$emit('saved');
+ savetd() {
+ this.$emit('savedtd');
+ },
+ saveotm() {
+ this.$emit('savedotm');
},
async closeDiagram() {
this.$emit('closed');
diff --git a/td.vue/src/i18n/en.js b/td.vue/src/i18n/en.js
index 05f4bf02d..098debf3f 100644
--- a/td.vue/src/i18n/en.js
+++ b/td.vue/src/i18n/en.js
@@ -227,6 +227,8 @@ const eng = {
remove: 'Remove',
report: 'Report',
save: 'Save',
+ savetd: 'Threat Dragon Format',
+ saveotm: 'OTM Format',
saveAs: 'Save As',
saveHtml: 'Save HTML',
saveModel: 'Save Model',
diff --git a/td.vue/src/service/openThreatModel.js b/td.vue/src/service/openThreatModel.js
new file mode 100644
index 000000000..8b0411fe2
--- /dev/null
+++ b/td.vue/src/service/openThreatModel.js
@@ -0,0 +1,375 @@
+const convertTDtoOTM = (data) => {
+ const jsonData = JSON.stringify(data, null, 2);
+
+ var dragonData = JSON.parse(jsonData);
+
+ var otm = new Object();
+ otm.otmVersion = '0.2.0';
+ otm.project = new Object();
+ otm.project.id = dragonData.summary.id;
+ otm.project.name = dragonData.summary.title;
+
+ var diagram = dragonData.detail.diagrams[0];
+
+ otm.trustZones = [];
+ otm.components = [];
+ otm.dataflows = [];
+ diagram.cells.forEach(function(cell) {
+ if (cell.data.type == 'tm.Boundary') {
+ var zone = new Object();
+ zone.id = cell.id;
+ zone.name = cell.data.name;
+ zone.type = cell.shape;
+
+ zone.attributes = [];
+ var attr = new Object();
+ attr.sourceX = cell.source.x;
+ attr.sourceY = cell.source.y;
+ attr.targetX = cell.target.x;
+ attr.targetY = cell.target.y;
+ attr.type = cell.data.type;
+ zone.attributes.push(attr);
+
+ zone.representations = [];
+ var counter = 1;
+ cell.vertices.forEach(function(vert) {
+ var rep = new Object();
+ rep.id = counter;
+ rep.name = 'vertices';
+ rep.position = new Object();
+ rep.position.x = vert.x;
+ rep.position.y = vert.y;
+ rep.size = new Object();
+ rep.size.width = 1;
+ rep.size.height = 1;
+ zone.representations.push(rep);
+ counter += 1;
+ });
+
+ otm.trustZones.push(zone);
+ }
+ if (cell.data.type == 'tm.Store') {
+ var store = new Object();
+ store.id = cell.id;
+ store.name = cell.data.name;
+ store.type = cell.shape;
+
+ store.threats = [];
+
+ if (Object.hasOwn(cell.data, 'threats'))
+ {
+ cell.data.threats.forEach(function(threat) {
+ var tr = new Object();
+ tr.id = store.id + store.threats.length;
+ tr.name = threat.title;
+ tr.description = threat.description;
+ tr.attributes = [];
+ var att = new Object();
+ att.status = threat.status;
+ att.severity = threat.severity;
+ att.mitigation = threat.mitigation;
+ att.type = threat.type;
+ att.modelType = threat.modelType;
+ tr.attributes.push(att);
+
+ store.threats.push(tr);
+ });
+ }
+
+ store.representations = [];
+ var storeRep = new Object();
+ storeRep.id = cell.id;
+ storeRep.name = cell.data.type;
+ storeRep.position = new Object();
+ storeRep.position.x = cell.position.x;
+ storeRep.position.y = cell.position.y;
+ storeRep.size = new Object();
+ storeRep.size.width = cell.size.width;
+ storeRep.size.height = cell.size.height;
+ store.representations.push(storeRep);
+
+ otm.components.push(store);
+ }
+ if (cell.data.type == 'tm.Actor') {
+ var actor = new Object();
+ actor.id = cell.id;
+ actor.name = cell.data.name;
+ actor.type = cell.shape;
+
+ actor.threats = [];
+
+ if (Object.hasOwn(cell, 'threats'))
+ {
+ cell.data.threats.forEach(function(threat) {
+ var tr = new Object();
+ tr.id = actor.id + actor.threats.length;
+ tr.name = threat.title;
+ tr.description = threat.description;
+ tr.attributes = [];
+ var att = new Object();
+ att.status = threat.status;
+ att.severity = threat.severity;
+ att.mitigation = threat.mitigation;
+ att.type = threat.type;
+ att.modelType = threat.modelType;
+ tr.attributes.push(att);
+
+ actor.threats.push(tr);
+ });
+ }
+
+ actor.representations = [];
+ var actorRep = new Object();
+ actorRep.id = cell.id;
+ actorRep.name = cell.data.type;
+ actorRep.position = new Object();
+ actorRep.position.x = cell.position.x;
+ actorRep.position.y = cell.position.y;
+ actorRep.size = new Object();
+ actorRep.size.width = cell.size.width;
+ actorRep.size.height = cell.size.height;
+ actor.representations.push(actorRep);
+
+ otm.components.push(actor);
+ }
+ if (cell.data.type == 'tm.Process') {
+ var process = new Object();
+ process.id = cell.id;
+ process.name = cell.data.name;
+ process.type = cell.shape;
+
+ process.threats = [];
+
+ if (Object.hasOwn(cell.data, 'threats'))
+ {
+ cell.data.threats.forEach(function(threat) {
+ var tr = new Object();
+ tr.id = process.id + process.threats.length;
+ tr.name = threat.title;
+ tr.description = threat.description;
+ tr.attributes = [];
+ var att = new Object();
+ att.status = threat.status;
+ att.severity = threat.severity;
+ att.mitigation = threat.mitigation;
+ att.type = threat.type;
+ att.modelType = threat.modelType;
+ tr.attributes.push(att);
+ process.threats.push(tr);
+ });
+ }
+
+ process.representations = [];
+ var processRep = new Object();
+ processRep.id = cell.id;
+ processRep.name = cell.data.type;
+ processRep.position = new Object();
+ processRep.position.x = cell.position.x;
+ processRep.position.y = cell.position.y;
+ processRep.size = new Object();
+ processRep.size.width = cell.size.width;
+ processRep.size.height = cell.size.height;
+ process.representations.push(processRep);
+
+ otm.components.push(process);
+ }
+ if (cell.data.type == 'tm.Flow') {
+ var flow = new Object();
+ flow.id = cell.id;
+ flow.type = cell.shape;
+ flow.name = cell.data.name;
+ flow.source = cell.source.cell;
+ flow.destination = cell.target.cell;
+
+ flow.attributes = [];
+ var attFlow = new Object();
+ attFlow.verticesX = cell.vertices[0].x;
+ attFlow.verticesY = cell.vertices[0].y;
+ flow.attributes.push(attFlow);
+
+ flow.threats = [];
+
+ if (Object.hasOwn(cell.data, 'threats'))
+ {
+ cell.data.threats.forEach(function(threat) {
+ var tr = new Object();
+ tr.id = flow.id + flow.threats.length;
+ tr.name = threat.title;
+ tr.description = threat.description;
+ tr.attributes = [];
+ var att = new Object();
+ att.status = threat.status;
+ att.severity = threat.severity;
+ att.mitigation = threat.mitigation;
+ att.type = threat.type;
+ att.modelType = threat.modelType;
+ tr.attributes.push(att);
+ flow.threats.push(tr);
+ });
+ }
+
+ flow.representations = [];
+ var flowRep = new Object();
+ flowRep.id = cell.id;
+ flowRep.name = cell.data.type;
+ flow.representations.push(flowRep);
+
+ otm.dataflows.push(flow);
+ }
+ });
+
+ return otm;
+};
+
+const convertOTMtoTD = (jsonModel) => {
+ var dragonModel = new Object();
+ dragonModel.version = '2.0';
+ dragonModel.summary = new Object();
+ dragonModel.summary.title = jsonModel.project.name;
+ dragonModel.summary.owner = '';
+ dragonModel.summary.description = '';
+ dragonModel.summary.id = jsonModel.project.id;
+ dragonModel.detail = new Object();
+ dragonModel.detail.contributors = [];
+ dragonModel.detail.diagrams = [];
+ dragonModel.detail.diagramTop = 0;
+ dragonModel.detail.reviewer = '';
+ dragonModel.detail.threatTop = 0;
+
+ var diagram = new Object();
+ diagram.version = '2.0';
+ diagram.title = 'Main Request Data Flow';
+ diagram.id = 0;
+ diagram.diagramType = 'STRIDE';
+ diagram.cells = [];
+
+ jsonModel.components.forEach(function(comp) {
+ var cell = new Object();
+ cell.shape = 'process';
+ cell.zIndex = 1;
+ cell.id = comp.id;
+ cell.data = new Object();
+ cell.data.name = comp.name;
+ cell.data.type = comp.representations[0].name;
+ cell.position = new Object();
+ cell.position.x = comp.representations[0].position.x;
+ cell.position.y = comp.representations[0].position.y;
+ cell.size = new Object();
+ cell.size.width = comp.representations[0].size.width;
+ cell.size.height = comp.representations[0].size.height;
+ cell.attrs = new Object();
+ cell.attrs.text = new Object();
+ cell.attrs.text.text = comp.name;
+
+ if (Object.hasOwn(comp, 'threats')) {
+ cell.data.threats = [];
+ comp.threats.forEach(function(threat) {
+ var t = new Object();
+ t.title = threat.name;
+ t.id = threat.id;
+ t.description = threat.description;
+ t.status = threat.attributes[0].status;
+ t.severity = threat.attributes[0].severity;
+ t.mitigation = threat.attributes[0].mitigation;
+ t.type = threat.attributes[0].type;
+ t.modelType = threat.attributes[0].modelType;
+ cell.data.threats.push(t);
+ });
+ }
+
+ diagram.cells.push(cell);
+ });
+
+ jsonModel.dataflows.forEach(function(flow) {
+ var cell = new Object();
+ cell.shape = 'flow';
+ cell.zIndex = 10;
+ cell.id = flow.id;
+ cell.data = new Object();
+ cell.data.name = flow.name;
+ cell.connector = 'smooth';
+ if (Object.hasOwn(flow, 'representations')) {
+ cell.data.type = flow.representations[0].name;
+ }
+ cell.width = 200;
+ cell.height = 100;
+ cell.source = new Object();
+ cell.source.cell = flow.source;
+ cell.target = new Object();
+ cell.target.cell = flow.destination;
+ cell.vertices = [];
+ if (Object.hasOwn(flow, 'attributes')) {
+ var vert = new Object();
+ vert.x = flow.attributes[0].verticesX;
+ vert.y = flow.attributes[0].verticesY;
+ cell.vertices.push(vert);
+ }
+ cell.labels = [];
+ var lbl = new Object();
+ lbl.attrs = new Object();
+ lbl.attrs.label = new Object();
+ lbl.attrs.label.text = flow.name;
+ cell.labels.push(lbl);
+
+ if (Object.hasOwn(flow, 'threats')) {
+ cell.data.threats = [];
+ flow.threats.forEach(function(threat) {
+ var t = new Object();
+ t.title = threat.name;
+ t.id = threat.id;
+ t.description = threat.description;
+ t.status = threat.attributes[0].status;
+ t.severity = threat.attributes[0].severity;
+ t.mitigation = threat.attributes[0].mitigation;
+ t.type = threat.attributes[0].type;
+ t.modelType = threat.attributes[0].modelType;
+ cell.data.threats.push(t);
+ });
+ }
+
+ diagram.cells.push(cell);
+ });
+
+ jsonModel.trustZones.forEach(function(zone) {
+ var cell = new Object();
+ cell.shape = 'trust-broundary-curve';
+ cell.zIndex = 10;
+ cell.id = zone.id;
+ cell.data = new Object();
+ cell.data.name = zone.name;
+ cell.connector = 'smooth';
+ cell.width = 200;
+ cell.height = 100;
+ if (Object.hasOwn(zone, 'attributes')) {
+ cell.data.type = zone.attributes[0].type;
+ cell.source = new Object();
+ cell.source.x = zone.attributes[0].sourceX;
+ cell.source.y = zone.attributes[0].sourceY;
+ cell.target = new Object();
+ cell.target.x = zone.attributes[0].targetX;
+ cell.target.y = zone.attributes[0].targetY;
+ }
+ cell.vertices = [];
+ zone.representations.forEach(function(rep) {
+ var vert = new Object();
+ vert.x = rep.position.x;
+ vert.y = rep.position.y;
+ cell.vertices.push(vert);
+ });
+ cell.labels = [];
+ var lbl = new Object();
+ lbl.attrs = new Object();
+ lbl.attrs.label = new Object();
+ lbl.attrs.label.text = zone.name;
+ cell.labels.push(lbl);
+ diagram.cells.push(cell);
+ });
+
+ dragonModel.detail.diagrams.push(diagram);
+ return dragonModel;
+};
+
+export default {
+ convertOTMtoTD,
+ convertTDtoOTM
+};
\ No newline at end of file
diff --git a/td.vue/src/service/save.js b/td.vue/src/service/save.js
index bcb85f929..0301238b9 100644
--- a/td.vue/src/service/save.js
+++ b/td.vue/src/service/save.js
@@ -1,9 +1,34 @@
+import openThreatModel from '../service/openThreatModel.js';
+
/**
* Saves the file locally
* @param {Object} data
* @param {string} fileName
+ * @param {string} format
*/
-const local = (data, fileName) => {
+const local = (data, fileName, format) => {
+ if (format == 'otm'){
+ saveOTM(data, fileName);
+ }
+ else{
+ saveTD(data, fileName);
+ }
+};
+
+function saveOTM (data, fileName) {
+ var otm = openThreatModel.convertTDtoOTM(data);
+ var saveOTM = JSON.stringify(otm);
+
+ const contentType = 'application/json';
+ const otmblob = new Blob([saveOTM], { type: contentType });
+ const a = document.createElement('a');
+ a.href = window.URL.createObjectURL(otmblob);
+ a.download = fileName;
+ a.click();
+ a.remove();
+}
+
+function saveTD (data, fileName) {
// TODO: Split into local browser and electron-specific saving
const contentType = 'application/json';
const jsonData = JSON.stringify(data, null, 2);
@@ -13,7 +38,7 @@ const local = (data, fileName) => {
a.download = fileName;
a.click();
a.remove();
-};
+}
export default {
local
diff --git a/td.vue/src/store/modules/threatmodel.js b/td.vue/src/store/modules/threatmodel.js
index b8909b8ef..1f2697da3 100644
--- a/td.vue/src/store/modules/threatmodel.js
+++ b/td.vue/src/store/modules/threatmodel.js
@@ -119,7 +119,7 @@ const actions = {
try {
if (getProviderType(rootState.provider.selected) === providerTypes.local) {
// save locally for web app when local login
- save.local(state.data, `${state.data.summary.title}.json`);
+ save.local(state.data, `${state.data.summary.title}.json`, state.format);
} else if (getProviderType(rootState.provider.selected) === providerTypes.desktop) {
// desktop version always saves locally
await window.electronAPI.modelSaved(state.data, state.fileName);
@@ -161,6 +161,7 @@ const mutations = {
Vue.set(state, 'selectedDiagram', diagram);
Vue.set(state.data.detail.diagrams, idx, diagram);
Vue.set(state.data, 'version', diagram.version);
+ Vue.set(state, 'format', diagram.format)
console.debug('Threatmodel diagram updated: ' + diagram.id + ' at index: ' + idx);
setThreatModel(state, state.data);
},
diff --git a/td.vue/src/views/ImportModel.vue b/td.vue/src/views/ImportModel.vue
index 76d3dccf4..429376eaa 100644
--- a/td.vue/src/views/ImportModel.vue
+++ b/td.vue/src/views/ImportModel.vue
@@ -58,6 +58,7 @@ import { mapState } from 'vuex';
import isElectron from 'is-electron';
import { getProviderType } from '@/service/provider/providers.js';
+import openThreatModel from '@/service/openThreatModel.js';
import TdFormButton from '@/components/FormButton.vue';
import tmActions from '@/store/actions/threatmodel.js';
import { THREATMODEL_UPDATE } from '@/store/actions/threatmodel.js';
@@ -145,6 +146,13 @@ export default {
// ToDo: need to catch invalid threat model schemas, possibly using npmjs.com/package/ajv
+ // Identify if threat model is in OTM format and if so, convert OTM back to dragon format
+ if (Object.hasOwn(jsonModel, 'otmVersion'))
+ {
+ jsonModel = openThreatModel.convertOTMtoTD(jsonModel);
+ }
+ // End code to convert OTM back to dragon format
+
if (isElectron()) {
// tell the desktop server that the model has changed
window.electronAPI.modelOpened(fileName);
diff --git a/td.vue/tests/unit/components/graph.spec.js b/td.vue/tests/unit/components/graph.spec.js
index 82b1ed6ec..64c201676 100644
--- a/td.vue/tests/unit/components/graph.spec.js
+++ b/td.vue/tests/unit/components/graph.spec.js
@@ -115,8 +115,14 @@ describe('components/GraphButtons.vue', () => {
expect(threatEditStub.methods.editThreat).toHaveBeenCalledWith('asdf');
});
- it('saves the threat model diagram', () => {
- wrapper.vm.saved();
+ it('saves the threat model diagram in td format', () => {
+ wrapper.vm.savedtd();
+ expect(storeMock.dispatch)
+ .toHaveBeenCalledWith(tmActions.diagramUpdated, expect.anything());
+ });
+
+ it('saves the threat model diagram in otm format', () => {
+ wrapper.vm.savedotm();
expect(storeMock.dispatch)
.toHaveBeenCalledWith(tmActions.diagramUpdated, expect.anything());
});
diff --git a/td.vue/tests/unit/components/graphButtons.spec.js b/td.vue/tests/unit/components/graphButtons.spec.js
index 1a7fa35a9..58b0201aa 100644
--- a/td.vue/tests/unit/components/graphButtons.spec.js
+++ b/td.vue/tests/unit/components/graphButtons.spec.js
@@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import TdFormButton from '@/components/FormButton.vue';
+import { BDropdownItemButton } from 'bootstrap-vue';
import TdGraphButtons from '@/components/GraphButtons.vue';
describe('components/GraphButtons.vue', () => {
@@ -35,20 +36,39 @@ describe('components/GraphButtons.vue', () => {
.filter(x => x.attributes('icon') === icon)
.at(0);
- describe('save', () => {
- beforeEach(() => {
- btn = getButtonByIcon('save');
- wrapper.vm.save();
- });
+ const getItemButtonByIcon = (icon) => wrapper.findAllComponents(BDropdownItemButton)
+ .filter(x => x.attributes('icon') === icon)
+ .at(0);
- it('has the save translation text', () => {
- expect(btn.attributes('text')).toEqual('forms.save');
+ describe('savetd', () => {
+ beforeEach(() => {
+ btn = getItemButtonByIcon('savetd');
+ wrapper.vm.savetd();
+ });
+
+ it('has the save translation text', () => {
+ expect(btn.attributes('text')).toEqual('forms.savetd');
+ });
+
+ it('emits the savedtd event', () => {
+ expect(wrapper.emitted().savedtd);
+ });
});
-
- it('emits the saved event', () => {
- expect(wrapper.emitted().saved);
+
+ describe('saveotm', () => {
+ beforeEach(() => {
+ btn = getItemButtonByIcon('saveotm');
+ wrapper.vm.saveotm();
+ });
+
+ it('has the saveotm translation text', () => {
+ expect(btn.attributes('text')).toEqual('forms.saveotm');
+ });
+
+ it('emits the savedotm event', () => {
+ expect(wrapper.emitted().savedotm);
+ });
});
- });
describe('close', () => {
beforeEach(() => {
diff --git a/td.vue/tests/unit/service/openThreatModel.spec.js b/td.vue/tests/unit/service/openThreatModel.spec.js
new file mode 100644
index 000000000..7691ae988
--- /dev/null
+++ b/td.vue/tests/unit/service/openThreatModel.spec.js
@@ -0,0 +1,55 @@
+import openThreatModel from '@/service/openThreatModel.js';
+
+describe('service/openThreatModel.js', () => {
+ var dragonModel = new Object();
+ dragonModel.version = '2.0';
+ dragonModel.summary = new Object();
+ dragonModel.summary.title = 'name';
+ dragonModel.summary.owner = '';
+ dragonModel.summary.description = '';
+ dragonModel.summary.id = 1;
+ dragonModel.detail = new Object();
+ dragonModel.detail.contributors = [];
+ dragonModel.detail.diagrams = [];
+ dragonModel.detail.diagramTop = 0;
+ dragonModel.detail.reviewer = '';
+ dragonModel.detail.threatTop = 0;
+ var diagram = new Object();
+ diagram.version = '2.0';
+ diagram.title = 'Main Request Data Flow';
+ diagram.id = 0;
+ diagram.diagramType = 'STRIDE';
+ diagram.cells = [];
+ dragonModel.detail.diagrams.push(diagram);
+
+ let otmModel;
+ describe('convertTDtoOTM', () => {
+ beforeEach(() => {
+ otmModel = openThreatModel.convertTDtoOTM(dragonModel);
+ });
+
+ it('converts td format to otm', () => {
+ expect(otmModel.otmVersion).toEqual('0.2.0');
+ });
+ });
+
+ var mockOTM = new Object();
+ mockOTM.otmVersion = '0.2.0';
+ mockOTM.project = new Object();
+ mockOTM.project.id = 1;
+ mockOTM.project.name = 'title';
+ mockOTM.trustZones = [];
+ mockOTM.components = [];
+ mockOTM.dataflows = [];
+
+ let tdModel;
+ describe('convertOTMtoTD', () => {
+ beforeEach(() => {
+ tdModel = openThreatModel.convertOTMtoTD(mockOTM);
+ });
+
+ it('converts otm format to td', () => {
+ expect(tdModel.summary.title).toEqual('title');
+ });
+ });
+});
diff --git a/td.vue/tests/unit/service/save.spec.js b/td.vue/tests/unit/service/save.spec.js
index bb60ccaf6..e73a1ac9a 100644
--- a/td.vue/tests/unit/service/save.spec.js
+++ b/td.vue/tests/unit/service/save.spec.js
@@ -3,6 +3,7 @@ import save from '@/service/save.js';
describe('service/save.js', () => {
const data = { foo: 'bar' };
const name = 'test.json';
+ const format = 'td';
describe('local', () => {
@@ -16,7 +17,7 @@ describe('service/save.js', () => {
createObjectURL: jest.fn()
};
document.createElement = jest.fn().mockReturnValue(mockAnchor);
- save.local(data, name);
+ save.local(data, name, format);
});
it('creates the object url', () => {