From 0082edbab68bdf876ebd0f145a942d9cab9f797d Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 31 Dec 2024 11:14:11 -0500 Subject: [PATCH] Automatic synchronization of tactics between parent and sub-techniques (#583) * refactor openStixList function * add styling for disabled list-edit box * disable tactics box for sub-techniques * on parent change, sync parent tactics with sub-technique * clear sub tactics list if parent is removed * move technique save functionality from dialog to class instance; sync parent and sub-technique tactics on save; fixed async pipeline * update changelog * move attack ID patch validation to validation results component * updated LinkById parsing logic to prevent saving the current object twice * fix kill chain name lookup logic * remove uneccessary css * add pr links for 582 and 583 to changelog --- app/src/app/classes/stix/technique.ts | 156 +++++++++++++++++- .../save-dialog/save-dialog.component.html | 9 +- .../save-dialog/save-dialog.component.scss | 3 - .../save-dialog/save-dialog.component.ts | 86 +++------- .../list-edit/list-edit.component.html | 6 +- .../list-edit/list-edit.component.scss | 12 +- .../list-edit/list-edit.component.ts | 93 ++++++++--- .../validation-results.component.html | 5 + .../validation-results.component.ts | 1 + .../stix/stix-page/stix-page.component.ts | 2 +- .../technique-view.component.html | 4 +- app/src/style/layouts/labelled-box.scss | 12 +- docs/changelog.md | 5 +- 13 files changed, 283 insertions(+), 111 deletions(-) diff --git a/app/src/app/classes/stix/technique.ts b/app/src/app/classes/stix/technique.ts index a2b2a76e..04a45270 100644 --- a/app/src/app/classes/stix/technique.ts +++ b/app/src/app/classes/stix/technique.ts @@ -1,13 +1,14 @@ -import { forkJoin, Observable } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { forkJoin, Observable, of } from "rxjs"; +import { concatMap, map, shareReplay, switchMap } from "rxjs/operators"; import { RestApiConnectorService } from "src/app/services/connectors/rest-api/rest-api-connector.service"; import { ValidationData } from "../serializable"; import { StixObject } from "./stix-object"; import { logger } from "../../utils/logger"; +import { Relationship } from "./relationship"; export class Technique extends StixObject { public name: string = ""; - public kill_chain_phases: any = []; + public kill_chain_phases: any[] = []; public domains: string[] = []; public platforms: string[] = []; public detection: string = ""; @@ -42,7 +43,7 @@ export class Technique extends StixObject { } public get tactics(): string[] { return this.kill_chain_phases.map(tactic => tactic.phase_name); } - public set tactics(values) { + public set tactics(values: any[]) { let killChainPhases = []; for (let i in values) { let phaseName = values[i][0]; @@ -296,16 +297,28 @@ export class Technique extends StixObject { super_of: restAPIService.getRelatedTo({targetRef: this.stixID, relationshipType: "subtechnique-of"}) }).pipe( map(relationships => { + // validate technique <-> sub-technique conversion if (this.is_subtechnique && relationships.super_of.data.length > 0) validationResult.errors.push({ "field": "is_subtechnique", "result": "error", "message": "technique with sub-techniques cannot be converted to sub-technique" - }) + }); if (!this.is_subtechnique && relationships.sub_of.data.length > 0) validationResult.errors.push({ "field": "is_subtechnique", "result": "error", "message": "sub-technique with parent cannot be converted to technique" - }) + }); + + // added tactic syncing information + if (!this.is_subtechnique && relationships.super_of.data.length > 0) { + // sub-technique with assigned parent or parent with sub-techniques + validationResult.info.push({ + "field": "tactics", + "result": "info", + "message": "sub-technique tactics will sync with parent technique" + }) + } + return validationResult; }) ) @@ -313,17 +326,144 @@ export class Technique extends StixObject { ); } + private updateParentRelationship(restApiService: RestApiConnectorService): Observable { + if (this.is_subtechnique && this.parentTechnique) { + // retrieve 'subtechnique-of' relationship, if any + return restApiService.getRelatedTo({sourceRef: this.stixID, relationshipType: "subtechnique-of"}).pipe( + switchMap(r => { + let createRelationship = function(source, target): Relationship { + // function to create a new 'subtechnique-of' relationship + // with the given source and target object + let newRelationship = new Relationship(); + newRelationship.relationship_type = 'subtechnique-of'; + newRelationship.set_source_object(source, restApiService); + newRelationship.set_target_object(target, restApiService); + return newRelationship; + }; + + let relationshipUpdates = []; + + if (r.data.length > 0 && r.data[0]) { + // relationship exists, check if parent has changed + let relationship = r.data[0] as Relationship; + if (relationship.target_ref !== this.parentTechnique.stixID) { + // parent technique changed, revoke previous 'subtechnique-of' + // relationship and create a new one + relationship.revoked = true; + relationshipUpdates.push(relationship.save(restApiService)); + const newRelationship = createRelationship(this, this.parentTechnique); + relationshipUpdates.push(newRelationship.save(restApiService)); + } // otherwise parent has not changed, do nothing + } else { + // 'subtechnique-of' relationship does not exist, create a new one + const newRelationship = createRelationship(this, this.parentTechnique); + relationshipUpdates.push(newRelationship.save(restApiService)); + } + + return forkJoin(relationshipUpdates.length ? relationshipUpdates : [of(null)]) + }) + ); + } else { + return of(null); + } + } + + private syncTacticsWithParentOrSubs(restApiService: RestApiConnectorService): Observable { + // case: sub-technique with assigned parent technique + if (this.is_subtechnique && this.parentTechnique) { + // sync this sub-technique's tactics with its parent + return restApiService.getRelatedTo({sourceRef: this.stixID, relationshipType: 'subtechnique-of'}).pipe( + switchMap(r => { + if (r.data.length > 0) { + let relationship = r.data[0] as Relationship; + return restApiService.getTechnique(relationship.target_ref, null, "latest").pipe( + switchMap(parentData => { + let parent: Technique = parentData?.[0]; + if (parent && !this.killChainPhasesSynced(parent.kill_chain_phases, this.kill_chain_phases)) { + // this sub-technique's tactics are not synced with its parent + let parentTactics = parent.kill_chain_phases.map(kcp => [kcp.phase_name, this.killChainMap[kcp.kill_chain_name]]) + this.tactics = parentTactics; + // the saving of this update occurs in the save() function and is not needed here + } + return of(null); + }) + ) + } + return of(null); + }) + ); + } + + // case: sub-technique without assigned parent + else if (this.is_subtechnique && !this.parentTechnique) return of(null); + + // case: parent technique, need to check if parent has sub-techniques + else { + // get any related "subtechnique-of" relationships, where the parent is this object (target_ref) + return restApiService.getRelatedTo({targetRef: this.stixID, relationshipType: 'subtechnique-of'}).pipe( + switchMap(r => { + // case: parent technique with sub-techniques + if (r.data.length > 0) { + // sync all sub-techniques' tactics with this object's tactics + const subtechniqueUpdates = r.data.map(sr => { + let subRelationship = sr as Relationship; + // get latest sub-technique object from relationship (source_ref) + return restApiService.getTechnique(subRelationship.source_ref, null, "latest").pipe( + switchMap(subData => { + let subtechnique: Technique = subData?.[0]; + if (subtechnique && !this.killChainPhasesSynced(subtechnique.kill_chain_phases, this.kill_chain_phases)) { + // sub-technique tactics are not synced with this parent + let parentTactics = this.kill_chain_phases.map(kcp => { + let killChainName = Object.keys(this.killChainMap).find(key => this.killChainMap[key] === kcp.kill_chain_name); + return [kcp.phase_name, killChainName] + }) + subtechnique.tactics = parentTactics; + return restApiService.postTechnique(subtechnique); // NOTE: do not use subtechnique.save(restApiService) + } + // tactics already synced + return of(null); + }) + ) + }) + return forkJoin(subtechniqueUpdates); + } + // case: parent technique with no sub-techniques + return of(null); + }) + ) + } + } + + private killChainPhasesSynced(tacticsA: any[], tacticsB: any[]) { + if (tacticsA.length !== tacticsB.length) return false; + + // sort kcps to ensure a consistent order for comparison + const sortedA = [...tacticsA].sort((a, b) => a.phase_name.localeCompare(b.phase_name)); + const sortedB = [...tacticsB].sort((a, b) => a.phase_name.localeCompare(b.phase_name)); + + return sortedA.every((kcpA, i) => { + let kcpB = sortedB[i]; + return kcpA.phase_name == kcpB.phase_name && kcpA.kill_chain_name == kcpB.kill_chain_name; + }) + } + /** * Save the current state of the STIX object in the database. Update the current object from the response * @param restAPIService [RestApiConnectorService] the service to perform the POST/PUT through * @returns {Observable} of the post */ - public save(restAPIService: RestApiConnectorService): Observable { - let postObservable = restAPIService.postTechnique(this); + public save(restApiService: RestApiConnectorService): Observable { + const postObservable = this.updateParentRelationship(restApiService).pipe( + concatMap(() => this.syncTacticsWithParentOrSubs(restApiService)), + concatMap(() => restApiService.postTechnique(this)), + shareReplay(1) // share the result and ensure only the last POST result is emitted + ); + let subscription = postObservable.subscribe({ next: (result) => { this.deserialize(result.serialize()); }, complete: () => { subscription.unsubscribe(); } }); + return postObservable; } diff --git a/app/src/app/components/save-dialog/save-dialog.component.html b/app/src/app/components/save-dialog/save-dialog.component.html index c9843c94..db3177a7 100644 --- a/app/src/app/components/save-dialog/save-dialog.component.html +++ b/app/src/app/components/save-dialog/save-dialog.component.html @@ -3,14 +3,7 @@

Validation

- - - - warning -
ATT&CK ID changed
-
knowledge base patches will be determined in next step
-
-
+
diff --git a/app/src/app/components/save-dialog/save-dialog.component.scss b/app/src/app/components/save-dialog/save-dialog.component.scss index 7086cea0..5548aeeb 100644 --- a/app/src/app/components/save-dialog/save-dialog.component.scss +++ b/app/src/app/components/save-dialog/save-dialog.component.scss @@ -36,9 +36,6 @@ margin-bottom: 0px; } } - .validation-item.warning { - color: color(warn); - } .column + .column { padding-left: 16px; .dark & { border-left: 1px solid border-color(dark); } diff --git a/app/src/app/components/save-dialog/save-dialog.component.ts b/app/src/app/components/save-dialog/save-dialog.component.ts index 256d9ad5..cba54714 100644 --- a/app/src/app/components/save-dialog/save-dialog.component.ts +++ b/app/src/app/components/save-dialog/save-dialog.component.ts @@ -1,13 +1,10 @@ import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { forkJoin, of } from 'rxjs'; +import { forkJoin } from 'rxjs'; import { ValidationData } from 'src/app/classes/serializable'; import { StixObject, workflowStates } from 'src/app/classes/stix/stix-object'; import { VersionNumber } from 'src/app/classes/version-number'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { Relationship } from '../../classes/stix/relationship'; -import { Technique } from '../../classes/stix/technique'; -import { map } from 'rxjs/operators'; @Component({ selector: 'app-save-dialog', @@ -60,32 +57,43 @@ export class SaveDialogComponent implements OnInit { let objSubscription = this.restApiService.getAllObjects({deserialize: true}).subscribe({ next: (results) => { // find objects with a link to the previous ID - let objLink = `(LinkById: ${this.config.patchID})`; + let objLink = `(LinkById: ${this.config.patchId})`; results.data.forEach(x => { - if ((x.description && x.description.indexOf(objLink) !== -1) || ("detection" in x && x.detection && x.detection.indexOf(objLink) !== -1)) { + if ((x.description?.indexOf(objLink) !== -1) || + ("detection" in x && x.detection?.indexOf(objLink) !== -1)) { this.patch_objects.push(x); } }); - this.patch_objects.push(this.config.object); + + // check if the object iself needs to be patched + if ((this.config.object.description?.indexOf(objLink) !== -1) || + ("detection" in this.config.object && (this.config.object.detection as string)?.indexOf(objLink) !== -1)) { + this.patchObject(this.config.object); // calls patchObject() directly to avoid saving the object twice + } + this.stage = 2; }, complete: () => objSubscription.unsubscribe() }) } + private patchObject(obj: any): void { + // replace LinkById references with the new ATT&CK ID + let regex = new RegExp(`\\(LinkById: (${this.config.patchId})\\)`, "gmu"); + obj.description = obj.description.replace(regex, `(LinkById: ${this.config.object.attackID})`); + if (obj.hasOwnProperty("detection") && obj.detection) { + obj.detection = obj.detection.replace(regex, `(LinkById: ${this.config.object.attackID})`); + } + } + /** * Apply LinkById patches and save the object */ public patch() { let saves = []; for (let obj of this.patch_objects) { - // replace LinkById references with the new ATT&CK ID - let regex = new RegExp(`\\(LinkById: (${this.config.patchID})\\)`, "gmu"); - obj.description = obj.description.replace(regex, `(LinkById: ${this.config.object.attackID})`); - if (obj.hasOwnProperty("detection") && obj.detection) { - obj.detection = obj.detection.replace(regex, `(LinkById: ${this.config.object.attackID})`); - } - saves.push(obj.save(this.restApiService)); + this.patchObject(obj); + if (obj.stixID !== this.config.object.stixID) saves.push(obj.save(this.restApiService)); } this.stage = 3; // enter loading stage until patching is complete let saveSubscription = forkJoin(saves).subscribe({ @@ -101,7 +109,7 @@ export class SaveDialogComponent implements OnInit { */ public saveCurrentVersion() { this.config.object.workflow = this.newState ? {state: this.newState } : undefined; - if (this.config.patchID) this.parse_patches(); + if (this.config.patchId) this.parse_patches(); else this.save(); } @@ -111,7 +119,7 @@ export class SaveDialogComponent implements OnInit { public saveNextMinorVersion() { this.config.object.version = new VersionNumber(this.nextMinorVersion); this.config.object.workflow = this.newState ? {state: this.newState } : undefined; - if (this.config.patchID) this.parse_patches(); + if (this.config.patchId) this.parse_patches(); else this.save(); } @@ -121,52 +129,12 @@ export class SaveDialogComponent implements OnInit { public saveNextMajorVersion() { this.config.object.version = new VersionNumber(this.nextMajorVersion); this.config.object.workflow = this.newState ? {state: this.newState } : undefined; - if (this.config.patchID) this.parse_patches(); + if (this.config.patchId) this.parse_patches(); else this.save(); } private saveObject() { - return this.config.object.save(this.restApiService).pipe( // save this object - map(result => { - if (result.attackType !== 'technique') return result; - let technique = this.config.object as Technique; - - if (technique.is_subtechnique && technique.parentTechnique) { - // retrieve 'subtechnique-of' relationship, if any - const sub$ = this.restApiService.getRelatedTo({sourceRef: technique.stixID, relationshipType: "subtechnique-of"}) - const sub = sub$.subscribe({ - next: (r) => { - let createRelationship = function(source, target, restApiService): Relationship { - // create a new 'subtechnique-of' relationship with the given source and target object - let newRelationship = new Relationship(); - newRelationship.relationship_type = 'subtechnique-of'; - newRelationship.set_source_object(source, restApiService); - newRelationship.set_target_object(target, restApiService); - return newRelationship; - } - - if (r.data.length > 0 && r.data[0]) { - // relationship exists, check if parent has changed - let relationship = r.data[0] as Relationship; - if (relationship.target_ref !== technique.parentTechnique.stixID) { - // parent technique has changed, revoke previous 'subtechnique-of' relationship and create a new one - relationship.revoked = true; - relationship.save(this.restApiService); - const newRelationship = createRelationship(technique, technique.parentTechnique, this.restApiService); - newRelationship.save(this.restApiService); - } // otherwise parent has not changed, do nothing - } else { - // 'subtechnique-of' relationship does not exist, create a new one - const newRelationship = createRelationship(technique, technique.parentTechnique, this.restApiService); - newRelationship.save(this.restApiService); - } - }, - complete: () => sub.unsubscribe() - }); - } - return of(result); - }) - ); + return this.config.object.save(this.restApiService); // save this object } /** @@ -189,6 +157,6 @@ export class SaveDialogComponent implements OnInit { export interface SaveDialogConfig { object: StixObject; - patchID?: string; // previous object ID to patch in LinkByID tags + patchId?: string; // previous object ID to patch in LinkByID tags versionAlreadyIncremented: boolean; } diff --git a/app/src/app/components/stix/list-property/list-edit/list-edit.component.html b/app/src/app/components/stix/list-property/list-edit/list-edit.component.html index 81042ee4..e29d812e 100644 --- a/app/src/app/components/stix/list-property/list-edit/list-edit.component.html +++ b/app/src/app/components/stix/list-property/list-edit/list-edit.component.html @@ -37,15 +37,15 @@ [class.disabled]="config.disabled || !dataLoaded" (click)="openStixList()" (keydown)="openStixList()" - [matTooltip]="config.disabled ? 'a valid domain must be selected first' : null"> + [matTooltip]="config.disabled ? (config.object.is_subtechnique ? 'sub-technique tactics are automatically synced with its parent tactics' : 'a valid domain must be selected first') : null">
{{value}} - + - + open_in_new
diff --git a/app/src/app/components/stix/list-property/list-edit/list-edit.component.scss b/app/src/app/components/stix/list-property/list-edit/list-edit.component.scss index d7727a6a..5a55d527 100644 --- a/app/src/app/components/stix/list-property/list-edit/list-edit.component.scss +++ b/app/src/app/components/stix/list-property/list-edit/list-edit.component.scss @@ -26,7 +26,17 @@ input { font-size: 14px; } // stix list object selection dialog - .icon { float: right; } + .icon { + float: right; + &.disabled { + .dark & { + color: on-color-deemphasis(light) !important; + } + .light & { + color: on-color-deemphasis(dark) !important; + } + } + } .stix-select { &:not(.disabled) { cursor: pointer; } } diff --git a/app/src/app/components/stix/list-property/list-edit/list-edit.component.ts b/app/src/app/components/stix/list-property/list-edit/list-edit.component.ts index 6449e7ce..52d2e368 100644 --- a/app/src/app/components/stix/list-property/list-edit/list-edit.component.ts +++ b/app/src/app/components/stix/list-property/list-edit/list-edit.component.ts @@ -303,40 +303,32 @@ export class ListEditComponent implements OnInit, AfterContentChecked { /** Open stix list selection window */ public openStixList() { if (this.config.disabled) return; // cannot open stix list if field is disabled - if (this.config.label === 'parent technique' && !this.dataLoaded) return; - let selectableObjects = this.allObjects; - if (this.config.field !== 'parentTechnique') { - // filter tactic objects by domain - let tactics = this.allObjects as Tactic[]; - selectableObjects = tactics.filter(tactic => this.tacticInDomain(tactic)); - } + if (this.config.field == 'parentTechnique') this.openParentTechniqueList(); + if (this.config.field == 'tactics') this.openTacticList(); + } - let dialogRef = this.dialog.open(AddDialogComponent, { - maxWidth: "70em", - maxHeight: "70em", - data: { - selectableObjects: selectableObjects, - select: this.select, - type: this.type, - buttonLabel: "OK" - } - }); + /** Open tactic list */ + public openTacticList(): void { + // get list of tactics in current domain + let tactics = this.allObjects as Tactic[]; + let selectableObjects = tactics.filter(tactic => this.tacticInDomain(tactic)); + + // open dialog window for user selection + let dialogRef = this.openDialogComponent(selectableObjects); - let selectCopy = this.config.field != 'parentTechnique' ? new SelectionModel(true, this.select.selected) : new SelectionModel(false, this.select.selected); + // copy user selection + let selectCopy = new SelectionModel(true, this.select.selected); let subscription = dialogRef.afterClosed().subscribe({ next: (result) => { - if (result && this.config.field != 'parentTechnique') { + if (result) { + // update object tactic list let tacticShortnames = this.select.selected.map(id => this.stixIDToShortname(id)); this.config.object[this.config.field] = tacticShortnames; // reset tactic selection state this.tacticState = []; - let allObjects = this.allObjects as Tactic[]; - let tactic_selection = this.select.selected.map(tacticID => allObjects.find(tactic => tactic.stixID == tacticID)); + let tactic_selection = this.select.selected.map(tacticID => tactics.find(tactic => tactic.stixID == tacticID)); tactic_selection.forEach(tactic => this.tacticState.push(tactic)); - } else if (result && this.config.field == 'parentTechnique') { - let allObjects = this.allObjects as Technique[]; - this.config.object[this.config.field] = this.select.selected.length > 0 ? allObjects.find(t => t.stixID === this.select.selected[0]) : null; } else { // user cancel this.select = selectCopy; // reset selection } @@ -344,4 +336,57 @@ export class ListEditComponent implements OnInit, AfterContentChecked { complete: () => { subscription.unsubscribe(); } }); } + + /** Open parent technique list */ + private killChainMap = { + "mitre-attack": "enterprise-attack", + "mitre-mobile-attack": "mobile-attack", + "mitre-ics-attack": "ics-attack" + } + public openParentTechniqueList(): void { + // check if parent techniques have been loaded, if not, do nothing + if (this.config.label === 'parent technique' && !this.dataLoaded) return; + // otherwise, open the dialog window for user selection + let dialogRef = this.openDialogComponent(this.allObjects); + + // copy the user selection + let selectCopy = new SelectionModel(false, this.select.selected); + let subscription = dialogRef.afterClosed().subscribe({ + next: (result) => { + if (result) { // update parent technique + let allObjects = this.allObjects as Technique[]; + let selection = this.select.selected.length > 0 ? allObjects.find(t => t.stixID === this.select.selected[0]) : null; + this.config.object[this.config.field] = selection; + if (selection) { + // sync sub-technique tactics with parent + let parentTactics = selection.kill_chain_phases.map(kcp => { + return [kcp.phase_name, this.killChainMap[kcp.kill_chain_name]] + }) + this.config.object['tactics'] = parentTactics; + // once saved, any irrelevant tactic-specific fields will be removed from the sub-technique + } else { + // parent removed, clear tactics list + this.config.object['tactics'] = []; + } + } else { // user cancelled + this.select = selectCopy; // reset selection + } + }, + complete: () => { subscription.unsubscribe(); } + }); + } + + /** Open the AddDialogComponent with the given list of selectable objects */ + public openDialogComponent(selectableObjects) { + return this.dialog.open(AddDialogComponent, { + maxWidth: "70em", + maxHeight: "70em", + data: { + selectableObjects: selectableObjects, + select: this.select, + type: this.type, + buttonLabel: "OK" + } + }); + } } \ No newline at end of file diff --git a/app/src/app/components/validation-results/validation-results.component.html b/app/src/app/components/validation-results/validation-results.component.html index f78175a3..6d9abeaa 100644 --- a/app/src/app/components/validation-results/validation-results.component.html +++ b/app/src/app/components/validation-results/validation-results.component.html @@ -11,6 +11,11 @@ warning
{{warning.message}}
+ + warning +
ATT&CK ID changed
+
knowledge base patches will be determined in next step
+
info
{{info.message}}
diff --git a/app/src/app/components/validation-results/validation-results.component.ts b/app/src/app/components/validation-results/validation-results.component.ts index 1cf24244..0e3a090a 100644 --- a/app/src/app/components/validation-results/validation-results.component.ts +++ b/app/src/app/components/validation-results/validation-results.component.ts @@ -9,6 +9,7 @@ import { ValidationData } from 'src/app/classes/serializable'; }) export class ValidationResultsComponent implements OnInit { @Input() validation: ValidationData; + @Input() patchId: Boolean; constructor() { // intentionally left blank diff --git a/app/src/app/views/stix/stix-page/stix-page.component.ts b/app/src/app/views/stix/stix-page/stix-page.component.ts index 4e7b58fd..740b9e8e 100644 --- a/app/src/app/views/stix/stix-page/stix-page.component.ts +++ b/app/src/app/views/stix/stix-page/stix-page.component.ts @@ -74,7 +74,7 @@ export class StixPageComponent implements OnInit, OnDestroy { data: { object: this.objects[0], // patch LinkByIds on other objects if this object's ATT&CK ID has changed - patchID: this.objects[0].supportsAttackID && this.objectID && this.objectID != this.objects[0].attackID ? this.objectID : undefined, + patchId: this.objects[0].supportsAttackID && this.objectID && this.objectID != this.objects[0].attackID ? this.objectID : undefined, versionAlreadyIncremented: versionChanged }, autoFocus: false, // prevent auto focus on form field diff --git a/app/src/app/views/stix/technique/technique-view/technique-view.component.html b/app/src/app/views/stix/technique/technique-view/technique-view.component.html index 5231bc80..7a4abcc3 100644 --- a/app/src/app/views/stix/technique/technique-view/technique-view.component.html +++ b/app/src/app/views/stix/technique/technique-view/technique-view.component.html @@ -20,7 +20,7 @@
- +
sub-technique? @@ -164,7 +164,7 @@ object: technique, field: 'tactics', editType: 'stixList', - disabled: technique.domains.length == 0 + disabled: technique.domains.length == 0 || technique.is_subtechnique }">