Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/classes/release-tracks/tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { SnapshotTier } from './enums';

export type ReleaseTrackObjectTier =
SnapshotTier.Candidate | SnapshotTier.Staged;
| SnapshotTier.Candidate

Check failure on line 5 in src/app/classes/release-tracks/tiers.ts

View workflow job for this annotation

GitHub Actions / static-checks

Replace `|·SnapshotTier.Candidate⏎·` with `SnapshotTier.Candidate`
| SnapshotTier.Staged;

export interface TierEntryModifiedByUser {
id?: string;
Expand Down
50 changes: 23 additions & 27 deletions src/app/classes/stix/relationship.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,6 +14,7 @@
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';
Expand Down Expand Up @@ -486,7 +487,7 @@
*/
public validate(
restAPIService: RestApiConnectorService,
tempWorkflowState?: WorkflowStatusType

Check warning on line 490 in src/app/classes/stix/relationship.ts

View workflow job for this annotation

GitHub Actions / static-checks

'tempWorkflowState' is defined but never used. Allowed unused args must match /^_/u
): Observable<ValidationData> {
return this.base_validate(restAPIService).pipe(
map(result => {
Expand Down Expand Up @@ -619,33 +620,38 @@
* @returns {Observable} of the post
*/
public save(
restAPIService: RestApiConnectorService
restAPIService: RestApiConnectorService,
_releaseTracksService?: ReleaseTracksConnectorService
): Observable<Relationship> {
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)
);
})
);
}

/**
Expand Down Expand Up @@ -683,7 +689,8 @@
}

/**
* 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
*/
Expand All @@ -697,17 +704,6 @@
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,24 @@

<ng-content select="[card-footer-action]"></ng-content>

<button
*ngIf="showDiff"
mat-stroked-button
type="button"
(click)="onDiff()">
<mat-icon>difference</mat-icon>
Diff
</button>
<ng-container *ngIf="showDiff">
<button
*ngIf="!diffDisabled; else disabledDiff"
mat-stroked-button
type="button"
(click)="onDiff()">
<mat-icon>difference</mat-icon>
Diff
</button>
<ng-template #disabledDiff>
<span [matTooltip]="diffDisabledMessage" tabindex="0">
<button mat-stroked-button type="button" disabled>
<mat-icon>difference</mat-icon>
Diff
</button>
</span>
</ng-template>
</ng-container>
</div>
</div>
</mat-card-actions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReleaseTrackObjectItem>();
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/stix/stix-list/stix-list.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
"></tr>
Expand Down
10 changes: 10 additions & 0 deletions src/app/components/stix/stix-list/stix-list.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/app/components/stix/stix-list/stix-list.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
97 changes: 93 additions & 4 deletions src/app/components/stix/stix-list/stix-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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[],
{
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/app/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ export const WORKFLOW_STATUS_RANK: Record<WorkflowStatusType, number> = {
* Collection/release changelog categories
*/
export type ChangelogCategory =
'additions' | 'changes' | 'minor_changes' | 'revocations' | 'deprecations';
| 'additions'
| 'changes'
| 'minor_changes'
| 'revocations'
| 'deprecations';

export interface ReleaseTrackStatus {
trackId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,8 @@ <h4>{{ getVirtualObjectTitle(item) }}</h4>
[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)">
Expand Down
Loading
Loading