From d1165d9ef583c54d3b9c7daae37ba7d99b8e76f2 Mon Sep 17 00:00:00 2001 From: adpare Date: Thu, 4 Jun 2026 16:01:34 -0400 Subject: [PATCH 01/11] feat: add diff view to the release dashboard page --- .../release-track-page.component.html | 2 +- .../release-track-page.component.ts | 217 +++++++++++++++++- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index 7c81dd2c4..184962dc6 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -191,7 +191,7 @@

Members ({{ members.length }})

[showDiff]="type !== 'member'" [showModifiedMeta]="false" (viewObject)="onView($event.object_ref)" - (diffObject)="onDiff($event.object_ref)"> + (diffObject)="onDiff($event)"> { + if (!current) { + this.snackbar.open( + 'Unable to load the current object version.', + undefined, + { + duration: 3000, + } + ); + return; + } + + if (expectedBaseline && !prior) { + this.snackbar.open( + 'Unable to load the comparison baseline version.', + undefined, + { + duration: 3000, + } + ); + return; + } + + this.openDiffDialog(current, prior); + }); } public onReviewAndApprove(item: any): void { @@ -403,6 +460,156 @@ export class ReleaseTrackPageComponent implements OnInit { return modified ? { id: item.object_ref, modified } : item.object_ref; } + private getDiffTier( + item: ReleaseTrackObjectItem + ): 'candidate' | 'staged' | null { + return this.getReleaseTrackTier(item); + } + + private findStagedEntry(objectRef: string): ReleaseTrackObjectItem | null { + return ( + this.releaseTrack?.staged?.find(item => item.object_ref === objectRef) ?? + null + ); + } + + private findMemberEntry(objectRef: string): ReleaseTrackObjectItem | null { + return ( + this.releaseTrack?.members?.find(item => item.object_ref === objectRef) ?? + null + ); + } + + private resolveCandidateDiffObjects(item: ReleaseTrackObjectItem): Observable<{ + current: StixObject | null; + prior: StixObject | null; + expectedBaseline: boolean; + }> { + const stagedEntry = this.findStagedEntry(item.object_ref); + const memberEntry = stagedEntry ? null : this.findMemberEntry(item.object_ref); + const baselineEntry = stagedEntry ?? memberEntry; + + return forkJoin({ + current: this.fetchObjectVersion(item.object_ref), + prior: baselineEntry + ? this.fetchObjectVersion( + baselineEntry.object_ref, + baselineEntry.object_modified + ) + : of(null), + }).pipe( + map(({ current, prior }) => ({ + current, + prior, + expectedBaseline: !!baselineEntry, + })) + ); + } + + private resolveStagedDiffObjects(item: ReleaseTrackObjectItem): Observable<{ + current: StixObject | null; + prior: StixObject | null; + expectedBaseline: boolean; + }> { + const memberEntry = this.findMemberEntry(item.object_ref); + + return forkJoin({ + current: this.fetchObjectVersion(item.object_ref, item.object_modified), + prior: memberEntry + ? this.fetchObjectVersion( + memberEntry.object_ref, + memberEntry.object_modified + ) + : of(null), + }).pipe( + map(({ current, prior }) => ({ + current, + prior, + expectedBaseline: !!memberEntry, + })) + ); + } + + private fetchObjectVersion( + objectRef: string, + modified?: Date | string + ): Observable { + const stixType = objectRef.split('--')[0] as StixType; + const attackType = StixTypeToAttackType[stixType]; + + let requestObject: Observable; + switch (attackType) { + case 'technique': + requestObject = this.restApiConnectorService.getTechnique(objectRef, modified); + break; + case 'tactic': + requestObject = this.restApiConnectorService.getTactic(objectRef, modified); + break; + case 'group': + requestObject = this.restApiConnectorService.getGroup(objectRef, modified); + break; + case 'campaign': + requestObject = this.restApiConnectorService.getCampaign(objectRef, modified); + break; + case 'asset': + requestObject = this.restApiConnectorService.getAsset(objectRef, modified); + break; + case 'software': + requestObject = this.restApiConnectorService.getSoftware(objectRef, modified); + break; + case 'mitigation': + requestObject = this.restApiConnectorService.getMitigation( + objectRef, + modified + ); + break; + case 'matrix': + requestObject = this.restApiConnectorService.getMatrix(objectRef, modified); + break; + case 'data-source': + requestObject = this.restApiConnectorService.getDataSource( + objectRef, + modified + ); + break; + case 'data-component': + requestObject = this.restApiConnectorService.getDataComponent( + objectRef, + modified + ); + break; + case 'detection-strategy': + requestObject = this.restApiConnectorService.getDetectionStrategy( + objectRef, + modified + ); + break; + case 'analytic': + requestObject = this.restApiConnectorService.getAnalytic(objectRef, modified); + break; + default: + return of(null); + } + + return requestObject.pipe( + take(1), + map(results => results[0] ?? null) + ); + } + + private openDiffDialog(current: StixObject, prior: StixObject | null): void { + this.dialog.open(StixDialogComponent, { + data: { + object: [current, prior], + mode: 'diff', + editable: false, + sidebarControl: 'disable', + }, + maxHeight: '75vh', + autoFocus: false, + }); + } + private getReleaseTrackTier(item: any): 'candidate' | 'staged' | null { if (item?.release_track_tier) return item.release_track_tier; if (item?.object_staged_at || item?.object_staged_by) return 'staged'; From b2e5e6dc724e926b563e4b91470b206b8be28cf0 Mon Sep 17 00:00:00 2001 From: adpare Date: Fri, 24 Jul 2026 15:28:15 -0400 Subject: [PATCH 02/11] feat: display new relationships added --- .../stix/stix-list/stix-list.component.html | 1 + .../stix/stix-list/stix-list.component.scss | 9 +++ .../stix/stix-list/stix-list.component.ts | 61 ++++++++++++++++ .../release-track-page.component.ts | 69 +++++++++++++++---- .../stix/asset-view/asset-view.component.html | 2 + .../campaign-view.component.html | 6 ++ .../data-component-view.component.html | 2 + .../data-source-view.component.html | 2 + .../detection-strategy-view.component.html | 2 + .../stix/group-view/group-view.component.html | 6 ++ .../mitigation-view.component.html | 2 + .../software-view.component.html | 6 ++ .../stix/stix-dialog/stix-dialog.component.ts | 2 + src/app/views/stix/stix-view-page.ts | 4 ++ .../technique-view.component.html | 16 +++++ 15 files changed, 177 insertions(+), 13 deletions(-) diff --git a/src/app/components/stix/stix-list/stix-list.component.html b/src/app/components/stix/stix-list/stix-list.component.html index 575129780..506f73c0b 100644 --- a/src/app/components/stix/stix-list/stix-list.component.html +++ b/src/app/components/stix/stix-list/stix-list.component.html @@ -369,6 +369,7 @@ mat-row *matRowDef="let element; columns: tableColumns_controls" class="element-row" + [class.relationship-added]="isNewRelationship(element)" [class.expanded]=" isCollectionType() && expandedElement === element "> diff --git a/src/app/components/stix/stix-list/stix-list.component.scss b/src/app/components/stix/stix-list/stix-list.component.scss index d96c3e835..14200b43b 100644 --- a/src/app/components/stix/stix-list/stix-list.component.scss +++ b/src/app/components/stix/stix-list/stix-list.component.scss @@ -128,6 +128,15 @@ } } + tr.element-row.relationship-added { + .light & td { + background-color: #c0f8c0 !important; + } + .dark & td { + background-color: #3abc4c56 !important; + } + } + .mat-mdc-cell + .mat-cell, .mat-mdc-header-cell + .mat-header-cell { padding-left: 12px; diff --git a/src/app/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index 8813e6ea6..ff52ce995 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -993,6 +993,20 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { let subscription: Subscription | undefined; subscription = this.data$.subscribe({ next: data => { + if ( + this.config.type === 'relationship' && + this.config.relationshipCreatedBefore + ) { + // For a staged diff, hide relationships created after that timestamp so they do not + // appear in the staged view. Keep the pagination count in sync with + // the rows hidden client-side. + const relationshipCount = data.data.length; + data.data = this.filterRelationshipsCreatedBefore( + data.data, + this.config.relationshipCreatedBefore + ); + data.pagination.total -= relationshipCount - data.data.length; + } data.data = this.filterExcludedAttackTypes(data.data); this.totalObjectCount = data.pagination.total; this.emitDetectsHasData(data.data.length > 0); @@ -1003,6 +1017,49 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Relationships created after `createdBefore` did not exist at + * the staged object's timestamp, so they are hidden. An invalid cutoff, or + * an unreadable relationship creation date, leaves that relationship visible + * to avoid treating missing date data as proof that it is new. + */ + private filterRelationshipsCreatedBefore( + relationships: StixObject[], + createdBefore: Date | string + ): StixObject[] { + const cutoff = new Date(createdBefore).getTime(); + if (!Number.isFinite(cutoff)) return relationships; + + return relationships.filter(relationship => { + const created = new Date(relationship.created).getTime(); + return !Number.isFinite(created) || created <= cutoff; + }); + } + + /** + * Determines whether a relationship should receive the candidate-diff + * styling. The candidate table remains the current relationship list; + * a row is new only when its creation time is later than the staged object's + * `relationshipAddedAfter` baseline. Non-relationship tables and invalid or + * missing timestamps are never marked as new. + */ + public isNewRelationship(relationship: StixObject): boolean { + if ( + this.config.type !== 'relationship' || + !this.config.relationshipAddedAfter + ) { + return false; + } + + const baseline = new Date(this.config.relationshipAddedAfter).getTime(); + const created = new Date(relationship.created).getTime(); + return ( + Number.isFinite(baseline) && + Number.isFinite(created) && + created > baseline + ); + } + private filterLocalObjects( objects: StixObject[], { @@ -1240,6 +1297,10 @@ export interface StixListConfig { targetType?: AttackType; /** relationship type to get, use with type=='relationship' */ relationshipType?: string; + /** Hide relationships created after this timestamp. */ + relationshipCreatedBefore?: Date | string; + /** Mark relationships created after this timestamp as new. */ + relationshipAddedAfter?: Date | string; /** force the list to show only this type */ type?: AttackType | 'collection-created' | 'collection-imported'; diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index b926f711c..3b7f48121 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -711,7 +711,10 @@ export class ReleaseTrackPageComponent implements OnInit { tier === 'candidate' ? this.resolveCandidateDiffObjects(item) : this.resolveStagedDiffObjects(item); - + const relationshipAddedAfter = + tier === 'candidate' + ? this.findStagedEntry(item.object_ref)?.object_modified + : undefined; diff.pipe(take(1)).subscribe(({ current, prior, expectedBaseline }) => { if (!current) { this.snackbar.open( @@ -735,7 +738,12 @@ export class ReleaseTrackPageComponent implements OnInit { return; } - this.openDiffDialog(current, prior); + this.openDiffDialog( + current, + prior, + tier === 'staged' ? item.object_modified : undefined, + relationshipAddedAfter + ); }); } @@ -1525,13 +1533,17 @@ export class ReleaseTrackPageComponent implements OnInit { ); } - private resolveCandidateDiffObjects(item: ReleaseTrackObjectItem): Observable<{ + private resolveCandidateDiffObjects( + item: ReleaseTrackObjectItem + ): Observable<{ current: StixObject | null; prior: StixObject | null; expectedBaseline: boolean; }> { const stagedEntry = this.findStagedEntry(item.object_ref); - const memberEntry = stagedEntry ? null : this.findMemberEntry(item.object_ref); + const memberEntry = stagedEntry + ? null + : this.findMemberEntry(item.object_ref); const baselineEntry = stagedEntry ?? memberEntry; return forkJoin({ @@ -1585,22 +1597,40 @@ export class ReleaseTrackPageComponent implements OnInit { let requestObject: Observable; switch (attackType) { case 'technique': - requestObject = this.restApiConnectorService.getTechnique(objectRef, modified); + requestObject = this.restApiConnectorService.getTechnique( + objectRef, + modified + ); break; case 'tactic': - requestObject = this.restApiConnectorService.getTactic(objectRef, modified); + requestObject = this.restApiConnectorService.getTactic( + objectRef, + modified + ); break; case 'group': - requestObject = this.restApiConnectorService.getGroup(objectRef, modified); + requestObject = this.restApiConnectorService.getGroup( + objectRef, + modified + ); break; case 'campaign': - requestObject = this.restApiConnectorService.getCampaign(objectRef, modified); + requestObject = this.restApiConnectorService.getCampaign( + objectRef, + modified + ); break; case 'asset': - requestObject = this.restApiConnectorService.getAsset(objectRef, modified); + requestObject = this.restApiConnectorService.getAsset( + objectRef, + modified + ); break; case 'software': - requestObject = this.restApiConnectorService.getSoftware(objectRef, modified); + requestObject = this.restApiConnectorService.getSoftware( + objectRef, + modified + ); break; case 'mitigation': requestObject = this.restApiConnectorService.getMitigation( @@ -1609,7 +1639,10 @@ export class ReleaseTrackPageComponent implements OnInit { ); break; case 'matrix': - requestObject = this.restApiConnectorService.getMatrix(objectRef, modified); + requestObject = this.restApiConnectorService.getMatrix( + objectRef, + modified + ); break; case 'data-source': requestObject = this.restApiConnectorService.getDataSource( @@ -1630,7 +1663,10 @@ export class ReleaseTrackPageComponent implements OnInit { ); break; case 'analytic': - requestObject = this.restApiConnectorService.getAnalytic(objectRef, modified); + requestObject = this.restApiConnectorService.getAnalytic( + objectRef, + modified + ); break; default: return of(null); @@ -1642,13 +1678,20 @@ export class ReleaseTrackPageComponent implements OnInit { ); } - private openDiffDialog(current: StixObject, prior: StixObject | null): void { + private openDiffDialog( + current: StixObject, + prior: StixObject | null, + relationshipCreatedBefore?: Date | string, + relationshipAddedAfter?: Date | string + ): void { this.dialog.open(StixDialogComponent, { data: { object: [current, prior], mode: 'diff', editable: false, sidebarControl: 'disable', + relationshipCreatedBefore, + relationshipAddedAfter, }, maxHeight: '75vh', autoFocus: false, diff --git a/src/app/views/stix/asset-view/asset-view.component.html b/src/app/views/stix/asset-view/asset-view.component.html index 105e50ae0..aaad6aa76 100644 --- a/src/app/views/stix/asset-view/asset-view.component.html +++ b/src/app/views/stix/asset-view/asset-view.component.html @@ -180,6 +180,8 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: asset.stixID, sourceType: 'technique', relationshipType: 'targets', diff --git a/src/app/views/stix/campaign-view/campaign-view.component.html b/src/app/views/stix/campaign-view/campaign-view.component.html index 8f6b49d1a..794b89567 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.html +++ b/src/app/views/stix/campaign-view/campaign-view.component.html @@ -157,6 +157,8 @@

Groups

#groupsList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'group', relationshipType: 'attributed-to', @@ -191,6 +193,8 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'technique', relationshipType: 'uses', @@ -225,6 +229,8 @@

Software Used

#softwareList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'software', relationshipType: 'uses', diff --git a/src/app/views/stix/data-component-view/data-component-view.component.html b/src/app/views/stix/data-component-view/data-component-view.component.html index 51a5c1a45..e6d611eb0 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.html +++ b/src/app/views/stix/data-component-view/data-component-view.component.html @@ -112,6 +112,8 @@

Techniques Detected

(detectsHasData)="showTechniques = $event" [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: dataComponent.stixID, relationshipType: 'detects', clickBehavior: 'dialog', diff --git a/src/app/views/stix/data-source-view/data-source-view.component.html b/src/app/views/stix/data-source-view/data-source-view.component.html index cab73a855..165d64644 100644 --- a/src/app/views/stix/data-source-view/data-source-view.component.html +++ b/src/app/views/stix/data-source-view/data-source-view.component.html @@ -165,6 +165,8 @@

Techniques Detected

[config]="{ stixObjects: techniquesDetected, type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, showDeprecatedFilter: true, clickBehavior: 'dialog', allowEdits: false, diff --git a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html index 08afc3dca..c5d807752 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html @@ -133,6 +133,8 @@

Techniques

#detectsList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: detectionStrategy.stixID, relationshipType: 'detects', clickBehavior: 'dialog', diff --git a/src/app/views/stix/group-view/group-view.component.html b/src/app/views/stix/group-view/group-view.component.html index eab33e8ac..f178c51b4 100644 --- a/src/app/views/stix/group-view/group-view.component.html +++ b/src/app/views/stix/group-view/group-view.component.html @@ -130,6 +130,8 @@

Campaigns

#campaignList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: group.stixID, sourceType: 'campaign', relationshipType: 'attributed-to', @@ -164,6 +166,8 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: group.stixID, targetType: 'technique', relationshipType: 'uses', @@ -198,6 +202,8 @@

Software Used

#softwareList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: group.stixID, targetType: 'software', relationshipType: 'uses', diff --git a/src/app/views/stix/mitigation-view/mitigation-view.component.html b/src/app/views/stix/mitigation-view/mitigation-view.component.html index 47f4affed..f1557bbdc 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.html +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.html @@ -127,6 +127,8 @@

Techniques Addressed

#mitigatesList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: mitigation.stixID, relationshipType: 'mitigates', clickBehavior: 'dialog', diff --git a/src/app/views/stix/software-view/software-view.component.html b/src/app/views/stix/software-view/software-view.component.html index d21a5ac38..5629eeee8 100644 --- a/src/app/views/stix/software-view/software-view.component.html +++ b/src/app/views/stix/software-view/software-view.component.html @@ -168,6 +168,8 @@

Techniques Used

#techniquesList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: software.stixID, targetType: 'technique', relationshipType: 'uses', @@ -202,6 +204,8 @@

Associated Groups

#groupList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: software.stixID, sourceType: 'group', relationshipType: 'uses', @@ -236,6 +240,8 @@

Campaigns

#campaignList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: software.stixID, sourceType: 'campaign', relationshipType: 'uses', diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.ts index 8af4e803f..4c127a4c8 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.ts @@ -71,6 +71,8 @@ export class StixDialogComponent implements OnInit { sourceType: this._config.sourceType ? this._config.sourceType : null, targetType: this._config.targetType ? this._config.targetType : null, showRelationships: this.showRelationships, + relationshipCreatedBefore: this._config.relationshipCreatedBefore, + relationshipAddedAfter: this._config.relationshipAddedAfter, editable: this._config.editable && this.authenticationService.canEdit(), is_new: this._config.is_new ? true : false, sidebarControl: diff --git a/src/app/views/stix/stix-view-page.ts b/src/app/views/stix/stix-view-page.ts index 1a7c5a282..e38061faa 100644 --- a/src/app/views/stix/stix-view-page.ts +++ b/src/app/views/stix/stix-view-page.ts @@ -44,6 +44,10 @@ export interface StixViewConfig { targetType?: string; // the relationship target type (only relevant when creating a new relationship) /* if true or omitted, show relationships with the object on the page. If false, omit the relationships */ showRelationships?: boolean; + /** Hide relationships created after this timestamp. */ + relationshipCreatedBefore?: Date | string; + /** Mark relationships created after this timestamp as new. */ + relationshipAddedAfter?: Date | string; /* is the current page editable? * if true or omitted, include edit elements on the page such as buttons to add a relationship * if false, hide such elements diff --git a/src/app/views/stix/technique-view/technique-view.component.html b/src/app/views/stix/technique-view/technique-view.component.html index b5b879cb2..a3a44a72d 100644 --- a/src/app/views/stix/technique-view/technique-view.component.html +++ b/src/app/views/stix/technique-view/technique-view.component.html @@ -374,6 +374,8 @@

Sub-techniques

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'subtechnique-of', allowEdits: true, clickBehavior: 'none', @@ -396,6 +398,8 @@

[config]="{ type: 'relationship', targetRef: technique.parentTechnique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'subtechnique-of', clickBehavior: 'none', allowEdits: true, @@ -432,6 +436,8 @@

Campaigns

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceType: 'campaign', relationshipType: 'uses', clickBehavior: 'dialog', @@ -465,6 +471,8 @@

Mitigations

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'mitigates', clickBehavior: 'dialog', allowEdits: true, @@ -497,6 +505,8 @@

Procedure Examples

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'uses', clickBehavior: 'dialog', allowEdits: true, @@ -525,6 +535,8 @@

Data Sources

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'detects', sourceType: 'data-component', clickBehavior: 'dialog', @@ -560,6 +572,8 @@

Detection Strategies

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'detects', sourceType: 'detection-strategy', clickBehavior: 'dialog', @@ -593,6 +607,8 @@

Assets

[config]="{ type: 'relationship', sourceRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetType: 'asset', relationshipType: 'targets', clickBehavior: 'dialog', From 3f8ccf814186e25a420c7606c9cda53ae7509dea Mon Sep 17 00:00:00 2001 From: adpare Date: Sun, 26 Jul 2026 12:29:28 -0400 Subject: [PATCH 03/11] fix: update identation and update color of modified relationships --- src/app/classes/release-tracks/tiers.ts | 3 +- .../stix/stix-list/stix-list.component.html | 1 + .../stix/stix-list/stix-list.component.scss | 3 +- .../stix/stix-list/stix-list.component.ts | 36 ++++++++++++++++--- src/app/utils/types.ts | 6 +++- .../relationship-view.component.ts | 8 ++--- 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index 4d68d01c4..778c67aa0 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -2,7 +2,8 @@ import { WorkflowStatusType } from 'src/app/utils/types'; import { SnapshotTier } from './enums'; export type ReleaseTrackObjectTier = - SnapshotTier.Candidate | SnapshotTier.Staged; + | SnapshotTier.Candidate + | SnapshotTier.Staged; export interface TierEntryModifiedByUser { id?: string; diff --git a/src/app/components/stix/stix-list/stix-list.component.html b/src/app/components/stix/stix-list/stix-list.component.html index 506f73c0b..a31273569 100644 --- a/src/app/components/stix/stix-list/stix-list.component.html +++ b/src/app/components/stix/stix-list/stix-list.component.html @@ -370,6 +370,7 @@ *matRowDef="let element; columns: tableColumns_controls" class="element-row" [class.relationship-added]="isNewRelationship(element)" + [class.relationship-changed]="isChangedRelationship(element)" [class.expanded]=" isCollectionType() && expandedElement === element "> diff --git a/src/app/components/stix/stix-list/stix-list.component.scss b/src/app/components/stix/stix-list/stix-list.component.scss index 14200b43b..d866e9e03 100644 --- a/src/app/components/stix/stix-list/stix-list.component.scss +++ b/src/app/components/stix/stix-list/stix-list.component.scss @@ -128,7 +128,8 @@ } } - tr.element-row.relationship-added { + tr.element-row.relationship-added, + tr.element-row.relationship-changed { .light & td { background-color: #c0f8c0 !important; } diff --git a/src/app/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index a8f2f887f..26e7e64ef 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -387,10 +387,12 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.config.sourceRef ? sticky_allowed : false, ['relationship-name'] ); - if (!( - this.config.relationshipType && - this.config.relationshipType == 'subtechnique-of' - )) + if ( + !( + this.config.relationshipType && + this.config.relationshipType == 'subtechnique-of' + ) + ) this.addColumn( 'description', 'description', @@ -1053,6 +1055,32 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { ); } + /** + * Determines whether an existing relationship changed after the staged + * baseline. Candidate relationships are fetched at their current version, + * so this highlights changes that cannot be represented accurately in the + * staged table without relationship version history. + */ + public isChangedRelationship(relationship: StixObject): boolean { + if ( + this.config.type !== 'relationship' || + !this.config.relationshipAddedAfter + ) { + return false; + } + + const baseline = new Date(this.config.relationshipAddedAfter).getTime(); + const created = new Date(relationship.created).getTime(); + const modified = new Date(relationship.modified).getTime(); + return ( + Number.isFinite(baseline) && + Number.isFinite(created) && + Number.isFinite(modified) && + created <= baseline && + modified > baseline + ); + } + private filterLocalObjects( objects: StixObject[], { diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 68a34ac0f..89aa535e6 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -101,7 +101,11 @@ export const WORKFLOW_STATUS_RANK: Record = { * Collection/release changelog categories */ export type ChangelogCategory = - 'additions' | 'changes' | 'minor_changes' | 'revocations' | 'deprecations'; + | 'additions' + | 'changes' + | 'minor_changes' + | 'revocations' + | 'deprecations'; export interface ReleaseTrackStatus { trackId: string; diff --git a/src/app/views/stix/relationship-view/relationship-view.component.ts b/src/app/views/stix/relationship-view/relationship-view.component.ts index fe588de8f..d94638960 100644 --- a/src/app/views/stix/relationship-view/relationship-view.component.ts +++ b/src/app/views/stix/relationship-view/relationship-view.component.ts @@ -240,11 +240,9 @@ export class RelationshipViewComponent extends StixViewPage implements OnInit { * @param {any} obj the raw STIX object */ public navigateTo(obj: any): void { - if (!( - obj?.stix?.type && - obj?.stix?.id && - StixTypeToAttackType[obj.stix.type] - )) { + if ( + !(obj?.stix?.type && obj?.stix?.id && StixTypeToAttackType[obj.stix.type]) + ) { console.warn('Invalid object passed to navigateTo:', obj); return; } From d940476f05c92a21c37733a88de16448b5d3f394 Mon Sep 17 00:00:00 2001 From: adpare Date: Sun, 26 Jul 2026 12:30:33 -0400 Subject: [PATCH 04/11] chore: update path of release snapshot --- .../connectors/rest-api/release-tracks.service.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts index 44c2e0523..eef9352cc 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -200,7 +200,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { } /** - * GET /api/release-tracks/:id + * GET /api/release-tracks/:id/snapshots/latest * Get latest snapshot for a track. * @param id Release track id * @param options Query options forwarded to endpoint @@ -211,7 +211,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { options?: ReleaseTrackSnapshotOptions ): Observable { const params = this.buildHttpParams(options); - const url = `${this.apiUrl}/release-tracks/${id}`; + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/latest`; return this.http.get(url, { params }).pipe( tap(result => logger.log(`retrieved latest snapshot for track ${id}`, result) @@ -223,15 +223,15 @@ export class ReleaseTracksConnectorService extends ApiConnector { } /** - * GET /api/release-tracks/:id - * Build snapshot history from the latest snapshot and its version_history. + * GET /api/release-tracks/:id/snapshots + * List snapshot history for a track. * @param id Release track id * @returns Observable */ public listSnapshots( id: string ): Observable { - const url = `${this.apiUrl}/release-tracks/${id}`; + const url = `${this.apiUrl}/release-tracks/${id}/snapshots`; return this.http.get(url).pipe( tap(result => logger.log(`retrieved snapshots for track ${id}`, result)), map(result => this.normalizeSnapshotHistory(result)), @@ -243,7 +243,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { } /** - * GET /api/release-tracks/:id?format=:format + * GET /api/release-tracks/:id/snapshots/latest?format=:format * Retrieve the latest snapshot in an export format without deserializing it. * @param id Release track id * @param format Export format @@ -256,7 +256,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { options?: Omit ): Observable { const params = this.buildHttpParams({ ...options, format }); - const url = `${this.apiUrl}/release-tracks/${id}`; + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/latest`; return this.http.get(url, { params }).pipe( tap(result => logger.log( From 8869aa5031fbe6d2c667042ce89023ccbdb1b09b Mon Sep 17 00:00:00 2001 From: adpare Date: Mon, 27 Jul 2026 22:23:02 -0400 Subject: [PATCH 05/11] fix: update object tier accurately when relationships are editted/added --- src/app/classes/stix/relationship.ts | 58 ++++++++-------- .../name-property/name-property.component.ts | 68 ++++++++++++------- .../stix-list/stix-list.component.spec.ts | 20 ++++++ .../release-tracks.integration.spec.ts | 4 +- .../stix/stix-dialog/stix-dialog.component.ts | 15 +++- 5 files changed, 108 insertions(+), 57 deletions(-) diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index 00929203f..3f101de10 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -1,5 +1,5 @@ -import { Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { concat, defer, Observable, of } from 'rxjs'; +import { last, map, switchMap } from 'rxjs/operators'; import { Asset, Campaign, @@ -14,6 +14,7 @@ import { Technique, } from 'src/app/classes/stix'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; @@ -619,33 +620,44 @@ export class Relationship extends StixObject { * @returns {Observable} of the post */ public save( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + _releaseTracksService?: ReleaseTracksConnectorService ): Observable { if (!this.workflow) { // Initialize the workflow object if it doesn't exist this.workflow = { state: WorkflowStatus.WorkInProgress }; } this.workflow.state = WorkflowStatus.WorkInProgress; - const postObservable = restAPIService.postRelationship(this); - const subscription = postObservable.subscribe({ - next: result => { + return restAPIService.postRelationship(this).pipe( + switchMap(result => { this.deserialize(result.serialize()); const source_object = this.getObject( this.source_object.stix.type, this.source_object ); - this.updateSourceTargetObject(restAPIService, source_object); const target_object = this.getObject( this.target_object.stix.type, this.target_object ); - this.updateSourceTargetObject(restAPIService, target_object); - }, - complete: () => { - subscription.unsubscribe(); - }, - }); - return postObservable; + return concat( + defer(() => + this.updateSourceTargetObject( + restAPIService, + source_object + ) + ), + defer(() => + this.updateSourceTargetObject( + restAPIService, + target_object + ) + ) + ).pipe( + last(), + map(() => result) + ); + }) + ); } /** @@ -683,31 +695,21 @@ export class Relationship extends StixObject { } /** - * Helper function to update the workflow status of the source object of the relationship, + * Saves a related object as WIP. The backend member-sync service updates its + * release-track entry in response to this object save. * @param restAPIService the rest api service * @param object the relationship source object */ public updateSourceTargetObject( restAPIService: RestApiConnectorService, object: StixObject - ) { + ): Observable { // Check if the workflow object exists if (!object.workflow) { // Initialize the workflow object if it doesn't exist object.workflow = { state: WorkflowStatus.WorkInProgress }; } object.workflow.state = WorkflowStatus.WorkInProgress; - object.update(restAPIService).subscribe({ - next: response => { - console.log('Object updated successfully:', response); - window.location.reload(); - }, - error: error => { - console.error('Error updating object:', error); - }, - complete: () => { - console.log('Complete'); - }, - }); + return object.save(restAPIService); } } diff --git a/src/app/components/stix/name-property/name-property.component.ts b/src/app/components/stix/name-property/name-property.component.ts index 58fd2db78..5bfbc01bd 100644 --- a/src/app/components/stix/name-property/name-property.component.ts +++ b/src/app/components/stix/name-property/name-property.component.ts @@ -13,7 +13,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatMenuTrigger } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { forkJoin, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { SnapshotTier } from 'src/app/classes/release-tracks'; import type { ReleaseTrackObjectTier, @@ -247,30 +247,50 @@ export class NamePropertyComponent implements OnChanges, OnInit { } this.loadingTracks = true; - const releaseTracks = this.getWorkspaceTracks(); - if (!releaseTracks.length) { - this.trackStatuses = []; - this.loadingTracks = false; - return; - } - - forkJoin( - releaseTracks.map(track => - this.releaseTracksService - .getLatestSnapshot(track.trackId, { include: 'all' }) - .pipe( - map(snapshot => this.toTrackStatus(track, snapshot)), - catchError(err => { - logger.error( - 'Failed to load release track snapshot for workflow status menu', - err - ); - return of(this.toTrackStatus(track, null)); - }) - ) - ) - ) + // If object track data is missing, look through the latest track snapshots to find where this object appears. + const workspaceTracks = this.getWorkspaceTracks(); + const tracks_all = workspaceTracks.length + ? of(workspaceTracks) + : this.releaseTracksService.listReleaseTracks().pipe( + map(result => + (result?.data || []) + .map(track => this.toWorkspaceTrack(track)) + .filter( + (track): track is ReleaseTrackStatus => track !== null + ) + ), + catchError(err => { + logger.error( + 'Failed to list release tracks for workflow status menu', + err + ); + return of([] as ReleaseTrackStatus[]); + }) + ); + + tracks_all .pipe( + // Read each latest snapshot, then retain only tracks containing this object. + switchMap(tracks => + tracks.length + ? forkJoin( + tracks.map(track => + this.releaseTracksService + .getLatestSnapshot(track.trackId, { include: 'all' }) + .pipe( + map(snapshot => this.toTrackStatus(track, snapshot)), + catchError(err => { + logger.error( + 'Failed to load release track snapshot for workflow status menu', + err + ); + return of(this.toTrackStatus(track, null)); + }) + ) + ) + ) + : of([]) + ), map(rows => rows.filter((row): row is ReleaseTrackStatus => row !== null) ) diff --git a/src/app/components/stix/stix-list/stix-list.component.spec.ts b/src/app/components/stix/stix-list/stix-list.component.spec.ts index 40ed14d59..c614e5415 100644 --- a/src/app/components/stix/stix-list/stix-list.component.spec.ts +++ b/src/app/components/stix/stix-list/stix-list.component.spec.ts @@ -137,4 +137,24 @@ describe('StixListComponent', () => { expect(result).toEqual([domainObject]); }); + + it('identifies an existing relationship modified after the candidate baseline', () => { + component.config = { + type: 'relationship', + relationshipAddedAfter: '2026-07-02T00:00:00.000Z', + } as any; + + expect( + (component as any).isChangedRelationship({ + created: new Date('2026-07-01T00:00:00.000Z'), + modified: new Date('2026-07-03T00:00:00.000Z'), + }) + ).toBe(true); + expect( + (component as any).isChangedRelationship({ + created: new Date('2026-07-03T00:00:00.000Z'), + modified: new Date('2026-07-03T00:00:00.000Z'), + }) + ).toBe(false); + }); }); diff --git a/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts index ce5a3518b..2638c70d9 100644 --- a/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts +++ b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts @@ -95,7 +95,7 @@ describe('Release Tracks API integration (real server)', () => { } }); - it('GET /release-tracks/:id -> getLatestSnapshot should return snapshot when tracks exist', async () => { + it('GET /release-tracks/:id/snapshots/latest should return a snapshot when tracks exist', async () => { if (!serverAvailable) { console.warn( 'Skipping getLatestSnapshot test because server is unreachable' @@ -109,7 +109,7 @@ describe('Release Tracks API integration (real server)', () => { return; } const res = await fetch( - `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}`, + `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}/snapshots/latest`, { headers: commonHeaders } ); expect(res.ok).toBe(true); diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.ts index a488cd3d8..596d7622d 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.ts @@ -17,6 +17,7 @@ import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dia import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { EditorService } from 'src/app/services/editor/editor.service'; import { StixViewConfig } from '../stix-view-page'; import { StixTypeToClass } from 'src/app/utils/class-mappings'; @@ -33,6 +34,7 @@ export class StixDialogComponent implements OnInit { public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public _config: StixViewConfig, public restApiService: RestApiConnectorService, + private releaseTracksService: ReleaseTracksConnectorService, public editorService: EditorService, private authenticationService: AuthenticationService, private dialog: MatDialog @@ -141,12 +143,19 @@ export class StixDialogComponent implements OnInit { const object = Array.isArray(this.config.object) ? this.config.object[0] : this.config.object; - const subscription = object.save(this.restApiService).subscribe({ + const save: Observable = + object instanceof Relationship + ? object.save( + this.restApiService, + this.releaseTracksService + ).pipe(map(() => undefined)) + : object.save(this.restApiService).pipe(map(() => undefined)); + const subscription = save.subscribe({ next: result => { this.editorService.onEditingStopped.emit(); this._config.is_new = false; - if (object.attackType == 'relationship') - this.updateRelationshipObjects(object as Relationship); // update source/target object versions + if (object instanceof Relationship) + this.updateRelationshipObjects(object); // update source/target object versions if (this.prevObject) this.revertToPreviousObject(); else if (object.attackType == 'data-component') { // view data component on save From df60b36c82ce1ab6eabc06926ef3458dc8aabf58 Mon Sep 17 00:00:00 2001 From: adpare Date: Mon, 27 Jul 2026 22:37:46 -0400 Subject: [PATCH 06/11] chore: update identation --- src/app/classes/stix/relationship.ts | 10 ++-------- .../stix/name-property/name-property.component.ts | 4 +--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index 3f101de10..cfb0bd3b4 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -641,16 +641,10 @@ export class Relationship extends StixObject { ); return concat( defer(() => - this.updateSourceTargetObject( - restAPIService, - source_object - ) + this.updateSourceTargetObject(restAPIService, source_object) ), defer(() => - this.updateSourceTargetObject( - restAPIService, - target_object - ) + this.updateSourceTargetObject(restAPIService, target_object) ) ).pipe( last(), diff --git a/src/app/components/stix/name-property/name-property.component.ts b/src/app/components/stix/name-property/name-property.component.ts index 5bfbc01bd..ccfda8871 100644 --- a/src/app/components/stix/name-property/name-property.component.ts +++ b/src/app/components/stix/name-property/name-property.component.ts @@ -255,9 +255,7 @@ export class NamePropertyComponent implements OnChanges, OnInit { map(result => (result?.data || []) .map(track => this.toWorkspaceTrack(track)) - .filter( - (track): track is ReleaseTrackStatus => track !== null - ) + .filter((track): track is ReleaseTrackStatus => track !== null) ), catchError(err => { logger.error( From 1114297b0450223887054bd6159eb122997fa2de Mon Sep 17 00:00:00 2001 From: adpare Date: Mon, 27 Jul 2026 22:42:29 -0400 Subject: [PATCH 07/11] chore: update identation --- src/app/views/stix/stix-dialog/stix-dialog.component.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.ts index 596d7622d..cac0ed63f 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.ts @@ -145,10 +145,9 @@ export class StixDialogComponent implements OnInit { : this.config.object; const save: Observable = object instanceof Relationship - ? object.save( - this.restApiService, - this.releaseTracksService - ).pipe(map(() => undefined)) + ? object + .save(this.restApiService, this.releaseTracksService) + .pipe(map(() => undefined)) : object.save(this.restApiService).pipe(map(() => undefined)); const subscription = save.subscribe({ next: result => { From 14b937f9b3288c8a7c36e4b48ba3eed66dc542eb Mon Sep 17 00:00:00 2001 From: adpare Date: Tue, 28 Jul 2026 00:16:49 -0400 Subject: [PATCH 08/11] fix: disable diff when object exists in candidates and staged --- .../release-track-object-card.component.html | 26 ++++++++---- .../release-track-object-card.component.ts | 2 + .../release-track-page.component.html | 2 + .../release-track-page.component.spec.ts | 27 ++++++++++++ .../release-track-page.component.ts | 42 +++++++++++++++++++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.html b/src/app/components/release-track-object-card/release-track-object-card.component.html index 50cd9631b..c2985d30d 100644 --- a/src/app/components/release-track-object-card/release-track-object-card.component.html +++ b/src/app/components/release-track-object-card/release-track-object-card.component.html @@ -54,14 +54,24 @@ - + + + + + + + + diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.ts b/src/app/components/release-track-object-card/release-track-object-card.component.ts index 45517e40c..9f70506db 100644 --- a/src/app/components/release-track-object-card/release-track-object-card.component.ts +++ b/src/app/components/release-track-object-card/release-track-object-card.component.ts @@ -47,6 +47,8 @@ export class ReleaseTrackObjectCardComponent { @Input() laneStatus: WorkflowStatusType | null = null; @Input() showDescription = true; @Input() showDiff = true; + @Input() diffDisabled = false; + @Input() diffDisabledMessage = ''; @Input() showModifiedMeta = true; @Output() viewObject = new EventEmitter(); diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index f8f2471d7..acea62c0a 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -558,6 +558,8 @@

{{ getVirtualObjectTitle(item) }}

[laneStatus]="getLaneStatus(item, lane)" [showDescription]="shouldShowLaneDescription(lane)" [showDiff]="!canReviewAndApprove(item, lane)" + [diffDisabled]="!shouldShowDiff(item)" + [diffDisabledMessage]="getDiffUnavailableMessage(item) || ''" [showModifiedMeta]="true" (viewObject)="onView($event.object_ref)" (diffObject)="onDiff($event)"> diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index c1ccb37f1..08fa8da6f 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -126,6 +126,33 @@ describe('ReleaseTrackPageComponent', () => { expect(component).toBeTruthy(); }); + it('should only show a diff for the latest pin when an object is both staged and a candidate', () => { + const staged = { + object_ref: 'attack-pattern--shared', + object_modified: '2026-01-01T00:00:00.000Z', + } as any; + const candidate = { + object_ref: 'attack-pattern--shared', + object_modified: '2026-02-01T00:00:00.000Z', + } as any; + const unrelatedCandidate = { + object_ref: 'attack-pattern--candidate-only', + object_modified: '2026-01-01T00:00:00.000Z', + } as any; + component.releaseTrack = { + staged: [staged], + candidates: [candidate, unrelatedCandidate], + } as any; + + expect(component.shouldShowDiff(staged)).toBe(false); + expect(component.shouldShowDiff(candidate)).toBe(true); + expect(component.shouldShowDiff(unrelatedCandidate)).toBe(true); + expect(component.getDiffUnavailableMessage(staged)).toBe( + 'A newer revision of this object is available in the release track. View its diff instead.' + ); + expect(component.getDiffUnavailableMessage(candidate)).toBeNull(); + }); + it('should delete the release track after confirmation', () => { mockDialog.open.mockReturnValue({ afterClosed: () => of(true), diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index 3da3045dc..99fd6d04a 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -843,10 +843,52 @@ export class ReleaseTrackPageComponent implements OnInit { return this.autoPromotionEnabled && !lane.isReleasedMembers; } + /** + * When different revisions of an object occupy both workflow tiers, only the + * newest pin can present a non-misleading relationship diff. + */ + public shouldShowDiff(item: ReleaseTrackObjectItem): boolean { + if (!item?.object_ref) return true; + + const staged = (this.releaseTrack?.staged || []).filter( + entry => entry.object_ref === item.object_ref + ); + const candidates = (this.releaseTrack?.candidates || []).filter( + entry => entry.object_ref === item.object_ref + ); + + if (!staged.length || !candidates.length) return true; + + const itemModified = this.getModifiedTimestamp(item.object_modified); + const pins = [...staged, ...candidates].map(entry => + this.getModifiedTimestamp(entry.object_modified) + ); + if ( + !Number.isFinite(itemModified) || + pins.some(time => !Number.isFinite(time)) + ) { + return true; + } + + return itemModified === Math.max(...pins); + } + + public getDiffUnavailableMessage( + item: ReleaseTrackObjectItem + ): string | null { + return this.shouldShowDiff(item) + ? null + : 'A newer revision of this object is available in the release track. View its diff instead.'; + } + public toggleReleasedMembers(): void { this.showReleasedMembers = !this.showReleasedMembers; } + private getModifiedTimestamp(value?: Date | string): number { + return value ? new Date(value).getTime() : Number.NaN; + } + public onEditDescription(): void { this.descriptionDraft = this.releaseTrack?.description ?? ''; this.isEditingDescription = true; From 3e165413721005c23998b4b7cfa735c4a2fe7987 Mon Sep 17 00:00:00 2001 From: adpare Date: Tue, 28 Jul 2026 23:56:54 -0400 Subject: [PATCH 09/11] chore: code cleanup --- src/app/classes/stix/relationship.ts | 2 +- .../components/stix/name-property/name-property.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index cfb0bd3b4..cd237bac0 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -697,7 +697,7 @@ export class Relationship extends StixObject { public updateSourceTargetObject( restAPIService: RestApiConnectorService, object: StixObject - ): Observable { + ) { // Check if the workflow object exists if (!object.workflow) { // Initialize the workflow object if it doesn't exist diff --git a/src/app/components/stix/name-property/name-property.component.ts b/src/app/components/stix/name-property/name-property.component.ts index d9ce70f0b..c1038a105 100644 --- a/src/app/components/stix/name-property/name-property.component.ts +++ b/src/app/components/stix/name-property/name-property.component.ts @@ -13,7 +13,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatMenuTrigger } from '@angular/material/menu'; import { MatSnackBar } from '@angular/material/snack-bar'; import { forkJoin, of } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { ExportFormat, SnapshotTier } from 'src/app/classes/release-tracks'; import type { ReleaseTrackObjectTier, From 8f08cc4ab53a30174e109e164c031afaa4e6e9d9 Mon Sep 17 00:00:00 2001 From: adpare Date: Wed, 29 Jul 2026 00:21:50 -0400 Subject: [PATCH 10/11] feat: add modified-in-place objects to the release track --- src/app/classes/stix/relationship.ts | 6 +++--- .../release-track-page/release-track-page.component.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index cd237bac0..e8592bf19 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -689,8 +689,8 @@ export class Relationship extends StixObject { } /** - * Saves a related object as WIP. The backend member-sync service updates its - * release-track entry in response to this object save. + * Updates a related object in place as WIP. The backend release-track + * change-capture service updates its track entry in response to this PUT. * @param restAPIService the rest api service * @param object the relationship source object */ @@ -704,6 +704,6 @@ export class Relationship extends StixObject { object.workflow = { state: WorkflowStatus.WorkInProgress }; } object.workflow.state = WorkflowStatus.WorkInProgress; - return object.save(restAPIService); + return object.update(restAPIService); } } diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index 02b7691bd..8f6f4b054 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -397,7 +397,7 @@ export class ReleaseTrackPageComponent implements OnInit { type: 'candidate', modifier: 'candidates', items: this.candidates.filter( - item => this.getObjectStatus(item) === WorkflowStatus.WorkInProgress + item => this.isWorkInProgressCandidate(item) ), emptyLabel: 'No work in progress candidates', statusFallback: WorkflowStatus.WorkInProgress, @@ -1819,6 +1819,13 @@ export class ReleaseTrackPageComponent implements OnInit { return item.object_status || WorkflowStatus.WorkInProgress; } + private isWorkInProgressCandidate(item: ReleaseTrackObjectItem): boolean { + return ( + this.getObjectStatus(item) === WorkflowStatus.WorkInProgress || + String(item.object_status) === 'modified-in-place' + ); + } + public formatConfigOption(value: any): string { if (value === null || value === undefined || value === '') return 'not set'; return String(value).replace(/[_-]+/g, ' '); From b91afa4586a8e8a62b50930b59b09f373beece93 Mon Sep 17 00:00:00 2001 From: adpare Date: Wed, 29 Jul 2026 00:23:23 -0400 Subject: [PATCH 11/11] chore: update identation --- .../release-track-page/release-track-page.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index 8f6f4b054..83c1736b6 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -396,8 +396,8 @@ export class ReleaseTrackPageComponent implements OnInit { title: 'Candidates WIP', type: 'candidate', modifier: 'candidates', - items: this.candidates.filter( - item => this.isWorkInProgressCandidate(item) + items: this.candidates.filter(item => + this.isWorkInProgressCandidate(item) ), emptyLabel: 'No work in progress candidates', statusFallback: WorkflowStatus.WorkInProgress,