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/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index 00929203f..e8592bf19 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,38 @@ 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,7 +689,8 @@ export class Relationship extends StixObject { } /** - * Helper function to update the workflow status of the source object of the relationship, + * 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 */ @@ -697,17 +704,6 @@ export class Relationship extends StixObject { 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.update(restAPIService); } } 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/components/stix/stix-list/stix-list.component.html b/src/app/components/stix/stix-list/stix-list.component.html index 575129780..a31273569 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,8 @@ mat-row *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 d96c3e835..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,6 +128,16 @@ } } + tr.element-row.relationship-added, + tr.element-row.relationship-changed { + .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.spec.ts b/src/app/components/stix/stix-list/stix-list.component.spec.ts index 51f41e78a..ba43bf689 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 @@ -136,4 +136,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/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index a96b42936..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', @@ -986,6 +988,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); @@ -996,6 +1012,75 @@ 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 + ); + } + + /** + * 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[], { @@ -1238,6 +1323,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/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/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts index 306302793..4e9ff5584 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -258,6 +258,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { } /** + * GET /api/release-tracks/:id/snapshots/latest?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 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/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 31f4587b6..dece4842c 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 @@ -560,6 +560,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 1c29dd1fd..632a2e1c1 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 @@ -127,6 +127,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 4ed8474d6..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 @@ -46,6 +46,7 @@ import { WorkflowStatus, WorkflowStatusType, } from 'src/app/utils/types'; + import { ALL_OBJECTS_STIX_LIST_CONFIG } from 'src/app/views/stix/all-objects-page/all-objects-page.component'; type ReleaseTrackLaneType = 'candidate' | 'staged' | 'member'; @@ -142,6 +143,11 @@ const VIRTUAL_OBJECT_TYPE_OPTIONS: StixType[] = [ 'x-mitre-matrix', 'x-mitre-tactic', ]; +import { map } from 'rxjs/operators'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, Observable, of } from 'rxjs'; +import { StixObject } from 'src/app/classes/stix'; +import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; @Component({ selector: 'app-release-track-page', @@ -199,7 +205,8 @@ export class ReleaseTrackPageComponent implements OnInit { private dialog: MatDialog, private restApiConnectorService: RestApiConnectorService, private authenticationService: AuthenticationService, - private fb: FormBuilder + private fb: FormBuilder, + private snackbar: MatSnackBar ) { this.configForm = this.fb.group({ autoPromote: [true], @@ -389,8 +396,8 @@ export class ReleaseTrackPageComponent implements OnInit { title: 'Candidates WIP', type: 'candidate', modifier: 'candidates', - items: this.candidates.filter( - item => this.getObjectStatus(item) === WorkflowStatus.WorkInProgress + items: this.candidates.filter(item => + this.isWorkInProgressCandidate(item) ), emptyLabel: 'No work in progress candidates', statusFallback: WorkflowStatus.WorkInProgress, @@ -734,9 +741,68 @@ export class ReleaseTrackPageComponent implements OnInit { this.router.navigate([viewUrl]); } - public onDiff(item: any): void { - // TODO: open diff modal for item - console.log('onDiff', item); + public onDiff(item: ReleaseTrackObjectItem): void { + if (!item?.object_ref) { + this.snackbar.open( + 'Unable to determine which object to compare.', + undefined, + { + duration: 3000, + } + ); + return; + } + + const tier = this.getDiffTier(item); + if (!tier) { + this.snackbar.open( + 'Unable to determine which lane to compare.', + undefined, + { + duration: 3000, + } + ); + return; + } + + const diff = + 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( + '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, + tier === 'staged' ? item.object_modified : undefined, + relationshipAddedAfter + ); + }); } public onReviewAndApprove(item: any): void { @@ -781,10 +847,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; @@ -1516,10 +1624,208 @@ export class ReleaseTrackPageComponent implements OnInit { return modified ? { id: item.object_ref, modified } : item.object_ref; } + 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'; + return item?.object_ref ? 'candidate' : null; + } + + 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, + 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, + }); + } + private getObjectStatus(item: ReleaseTrackObjectItem): WorkflowStatusType { 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, ' '); 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/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; } 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 9ed9de8ca..cac0ed63f 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 @@ -71,6 +73,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: @@ -139,12 +143,18 @@ 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 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',