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', () => {