From f230a5a39b1bcd91ca2816cff98bdaa39fe48c91 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:22:36 -0400 Subject: [PATCH 1/4] feat(admin): add ADM-backed allowed values management Add a guided Add New Property workflow and scope-first value checklists driven by the backend ADM catalog. Preserve other object-type settings and existing selections, refresh enabled dropdown choices, surface legacy warnings, and document the administration workflow. --- docs/usage.md | 58 +++ src/app/app-routing.module.ts | 10 + src/app/app.module.ts | 4 + .../navigation/navigation.component.spec.ts | 11 +- .../list-edit/list-edit.component.html | 4 +- .../list-edit/list-edit.component.spec.ts | 120 +++++- .../list-edit/list-edit.component.ts | 70 ++-- .../string-property.component.html | 12 +- .../string-property.component.ts | 12 +- .../subtype-dialog.component.html | 3 +- .../subtype-dialog.component.ts | 28 +- .../rest-api/rest-api-connector.service.ts | 128 ++++++- .../allowed-value-dialog.component.html | 293 +++++++++++++++ .../allowed-value-dialog.component.spec.ts | 294 +++++++++++++++ .../allowed-value-dialog.component.ts | 350 ++++++++++++++++++ .../allowed-values.component.html | 129 +++++++ .../allowed-values.component.ts | 185 +++++++++ .../dashboard-page.component.html | 8 + .../dashboard-page.component.spec.ts | 5 + .../dashboard-page.component.ts | 11 +- 20 files changed, 1638 insertions(+), 97 deletions(-) create mode 100644 src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.html create mode 100644 src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.spec.ts create mode 100644 src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.ts create mode 100644 src/app/views/dashboard-page/allowed-values/allowed-values.component.html create mode 100644 src/app/views/dashboard-page/allowed-values/allowed-values.component.ts diff --git a/docs/usage.md b/docs/usage.md index bc651ee6e..bc404d6c9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -206,6 +206,64 @@ The Workbench will attribute edits to you when you edit existing objects or crea Edits you make in the knowledge base are attributed to your _organization identity_, which is unique to your Workbench instance. The organization identity can be edited from the admin page accessible from the application homepage; when you first open the application you will be prompted to edit the organization identity to ensure the placeholder identity is not used. Changes to your organization identity will automatically update objects in the knowledge base, but attribution within exported collections will not be automatically affected. +### Managing Allowed Values + +Administrators can open **Dashboard → Admin → Allowed Values**, immediately +below **Validation Bypasses** in the sidebar. The searchable, sortable table +contains **one row per property and domain**, not one row per value or object +type. It starts with 15 configured rules, fitting on the default 25-row page. +Each row shows supported object types, a value count, and a compact preview. +The page identifies the backend ADM version supplying the permitted choices. + +**Add New Property** opens a guided workflow: + +1. Choose a supported property. +2. Choose an ADM-valid domain and one object type. +3. Select permitted values from a searchable checklist. +4. Review the configuration and choose **Create property**. + +If that property/domain is already configured, choose **Open existing editor** +instead of creating a duplicate. New scopes are available only where the +installed backend ADM accepts the property for that domain and object type; +this does not create arbitrary STIX schema fields. + +**Edit values** uses the same checklist, with one object-type selector above it +instead of repeated applicability controls for every value: + +- Check an approved value to enable it; uncheck it to keep it disabled. +- **Remove** clears that value's configuration for the selected object type. +- Switch object type to edit another scope. Other types' settings are retained. +- **Save all values** applies the draft atomically; **Cancel** discards it. + +Enum fields have no freeform value input. For the data-source/component field, +enter the two names separately and choose **Validate and add**. ADM must accept +the combined value before it enters the draft. Unvalidated input blocks saving +until it is approved or cleared. + +Saving an empty set leaves the configured rule available for later editing. + +Supported properties remain the original configurable fields: platforms for +analytics, techniques, software, data sources and assets; technique tactic/impact +types, permissions and data-source/component names; collection layers; asset +sectors and related-asset sectors; and identity classes and sectors. Available +domains and permissible values are derived from the backend ADM Zod schemas. + +Changes persist across restarts. Open or reopen an editor to load the latest +enabled choices. An existing unavailable selection remains visible but is not a +newly selectable option. Remove list/subtype selections using their chip's +remove control; once removed, they cannot be reselected unless an administrator +re-enables or adds the value. + +Allowed Values configuration is always ADM-validated, including disabled +options. General validation settings and **ADM Validation Bypasses** cannot +permit non-compliant configuration values. The frontend uses the backend's +catalog, so a different frontend ADM package version cannot broaden choices. + +Legacy configuration values that no longer comply with ADM are marked +**unavailable** and excluded from dropdowns. They remain stored until the rule +is saved; the editor explains that saving removes those unavailable settings. +Existing ATT&CK objects are never rewritten by this configuration workflow. + ### Quality Control Workflows The ATT&CK Workbench provides optional quality control workflows to assist in the creation of ATT&CK data. Objects are marked with a "workflow status," reflecting their place in the quality control pipeline: diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index f426ceb08..9f255dc87 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,6 +8,7 @@ import { OrgSettingsPageComponent } from './views/dashboard-page/org-settings-pa import { UserAccountsPageComponent } from './views/dashboard-page/user-accounts-page/user-accounts-page.component'; import { DefaultMarkingDefinitionsComponent } from './views/dashboard-page/default-marking-definitions/default-marking-definitions.component'; import { ValidationBypassesComponent } from './views/dashboard-page/validation-bypasses/validation-bypasses.component'; +import { AllowedValuesComponent } from './views/dashboard-page/allowed-values/allowed-values.component'; import { ProfilePageComponent } from './views/profile-page/profile-page.component'; import { AuthorizationGuard } from './services/helpers/authorization.guard'; import { Role } from './classes/authn/role'; @@ -161,6 +162,15 @@ export const routes: Routes = [ }, component: ValidationBypassesComponent, }, + { + path: 'allowed-values', + data: { + breadcrumb: 'allowed values', + title: 'Allowed Values', + roles: [Role.ADMIN], + }, + component: AllowedValuesComponent, + }, ], }, { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1672373bf..a015ebe77 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -169,6 +169,8 @@ import { UserAccountsPageComponent } from './views/dashboard-page/user-accounts- import { DefaultMarkingDefinitionsComponent } from './views/dashboard-page/default-marking-definitions/default-marking-definitions.component'; import { ValidationBypassRuleDialogComponent } from './views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component'; import { ValidationBypassesComponent } from './views/dashboard-page/validation-bypasses/validation-bypasses.component'; +import { AllowedValuesComponent } from './views/dashboard-page/allowed-values/allowed-values.component'; +import { AllowedValueDialogComponent } from './views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component'; import { ProfilePageComponent } from './views/profile-page/profile-page.component'; import { ReferenceManagerComponent } from './views/reference-manager/reference-manager.component'; @@ -320,6 +322,8 @@ export function initConfig(appConfigService: AppConfigService) { DefaultMarkingDefinitionsComponent, ValidationBypassesComponent, ValidationBypassRuleDialogComponent, + AllowedValuesComponent, + AllowedValueDialogComponent, ProfilePageComponent, ReferenceManagerComponent, StixDialogComponent, diff --git a/src/app/components/navigation/navigation.component.spec.ts b/src/app/components/navigation/navigation.component.spec.ts index 53e2e8d86..b9865fe3c 100644 --- a/src/app/components/navigation/navigation.component.spec.ts +++ b/src/app/components/navigation/navigation.component.spec.ts @@ -177,11 +177,12 @@ describe('NavigationComponent', () => { exact: true, }) ); - expect(component.dashboardAdminItems.map(item => item.label)).toEqual([ - 'Organization Settings', - 'User Accounts', - 'Default Marking Definitions', - 'Validation Bypasses', + expect(component.dashboardAdminItems.map(item => item.path)).toEqual([ + '/dashboard/org-settings', + '/dashboard/user-accounts', + '/dashboard/default-marking-definitions', + '/dashboard/validation-bypasses', + '/dashboard/allowed-values', ]); }); diff --git a/src/app/components/stix/list-property/list-edit/list-edit.component.html b/src/app/components/stix/list-property/list-edit/list-edit.component.html index 858b1c80a..2a2888fa6 100644 --- a/src/app/components/stix/list-property/list-edit/list-edit.component.html +++ b/src/app/components/stix/list-property/list-edit/list-edit.component.html @@ -58,10 +58,12 @@ + @let enabledValues = getEnabledValues(); {{ item }} diff --git a/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts b/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts index 67253eac1..757917393 100644 --- a/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts +++ b/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts @@ -1,35 +1,131 @@ +import { CommonModule } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + MatChipsModule, + MatChip, + MatChipRemove, +} from '@angular/material/chips'; +import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { of } from 'rxjs'; import { ListEditComponent } from './list-edit.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { Technique } from 'src/app/classes/stix/technique'; describe('ListEditComponent', () => { let component: ListEditComponent; let fixture: ComponentFixture; + let enabled: string[]; + let object: Technique; beforeEach(async () => { - const mockRestApiConnector = createMockRestApiConnector({}); - + enabled = ['Linux']; await TestBed.configureTestingModule({ declarations: [ListEditComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + NoopAnimationsModule, + MatSelectModule, + MatChipsModule, + ], schemas: [NO_ERRORS_SCHEMA], providers: [ - provideHttpClient(), - { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: RestApiConnectorService, + useValue: { + getAllAllowedValues: () => + of([ + { + objectType: 'technique', + properties: [ + { + propertyName: 'x_mitre_platforms', + domains: [ + { + domainName: 'enterprise-attack', + allowedValues: enabled, + }, + ], + }, + ], + }, + ]), + }, + }, ], }).compileComponents(); - }); - - beforeEach(() => { fixture = TestBed.createComponent(ListEditComponent); component = fixture.componentInstance; - component.config = { mode: 'edit', object: {} as any, field: 'platforms' }; + object = new Technique(); + object.domains = ['enterprise-attack']; + object.platforms = ['Legacy']; + component.config = { + mode: 'edit', + editType: 'select', + object, + field: 'platforms', + }; + }); + + afterEach(() => fixture.destroy()); + + async function openSelect(): Promise { + fixture.debugElement + .query(By.directive(MatSelect)) + .componentInstance.open(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return Array.from(document.querySelectorAll('mat-option')); + } + + async function closeSelect(): Promise { + fixture.debugElement + .query(By.directive(MatSelect)) + .componentInstance.close(); + fixture.detectChanges(); + await fixture.whenStable(); + } + + it('preserves unavailable selections while adding enabled choices, then stops offering removed selections', async () => { + fixture.detectChanges(); + const options = await openSelect(); + const legacy = options.find( + option => option.textContent.trim() === 'Legacy' + ); + expect(legacy.getAttribute('aria-disabled')).toBe('true'); + expect(legacy.getAttribute('aria-selected')).toBe('true'); + options.find(option => option.textContent.trim() === 'Linux').click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(object.platforms).toEqual(['Legacy', 'Linux']); + await closeSelect(); + fixture.debugElement + .queryAll(By.directive(MatChip)) + .find(chip => chip.nativeElement.textContent.includes('Legacy')) + .query(By.directive(MatChipRemove)) + .nativeElement.click(); + fixture.detectChanges(); + expect( + (await openSelect()).map(option => option.textContent.trim()) + ).toEqual(['Linux']); + expect(object.platforms).toEqual(['Linux']); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('keeps existing data visible when its supported tuple has no enabled values', async () => { + enabled = []; + fixture.detectChanges(); + const options = await openSelect(); + expect(options.map(option => option.textContent.trim())).toEqual([ + 'Legacy', + ]); + expect(options[0].getAttribute('aria-disabled')).toBe('true'); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + expect(object.platforms).toEqual(['Legacy']); }); }); diff --git a/src/app/components/stix/list-property/list-edit/list-edit.component.ts b/src/app/components/stix/list-property/list-edit/list-edit.component.ts index ab96806d7..e31d24630 100644 --- a/src/app/components/stix/list-property/list-edit/list-edit.component.ts +++ b/src/app/components/stix/list-property/list-edit/list-edit.component.ts @@ -288,51 +288,37 @@ export class ListEditComponent implements OnInit, AfterContentChecked { return this.config.object[this.config.field]; } - /** Get allowed values for this field */ - public getAllowedValues(): Set { - if (this.config.field == 'domains') return new Set(this.domains); - if (!this.dataLoaded) { - this.selectControl.disable(); - return null; + /** Include unavailable selections for display without offering them again. */ + public getAllowedValues(values = this.getEnabledValues()): Set { + values = new Set(values); + for (const selected of this.selectControl.value || []) values.add(selected); + const disabled = + !!this.config.disabled || + (!this.dataLoaded && this.config.field !== 'domains'); + if (disabled && this.selectControl.enabled) { + this.selectControl.disable({ emitEvent: false }); + } else if (!disabled && this.selectControl.disabled) { + this.selectControl.enable({ emitEvent: false }); } + return values; + } - // filter values + public getEnabledValues(): Set { + if (this.config.field === 'domains') return new Set(this.domains); const values = new Set(); - const property = this.allAllowedValues.properties.find(p => { - return p.propertyName == this.allowedValuesPropertyName(); - }); - if (!property) { - // property not found - this.selectControl.disable(); - return null; - } - - if ('domains' in this.config.object) { - const object = this.config.object as any; - property.domains.forEach(domain => { - if (object.domains.includes(domain.domainName)) { - domain.allowedValues.forEach(values.add, values); - } - }); - } else { - // domains not specified on object - property.domains.forEach(domain => { - domain.allowedValues.forEach(values.add, values); - }); - } - - // check for existing data - if (this.selectControl.value) { - this.selectControl.value.forEach(values.add, values); - } - - if (!values.size) { - // disable field and reset selection - this.selectControl.disable(); - this.selectControl.reset(); - this.config.object[this.config.field] = []; - } else { - this.selectControl.enable(); // re-enable field + if (!this.dataLoaded) return values; + const property = this.allAllowedValues?.properties?.find( + p => p.propertyName === this.allowedValuesPropertyName() + ); + const object = this.config.object; + for (const domain of property?.domains || []) { + if ( + !('domains' in object) || + (Array.isArray(object.domains) && + object.domains.includes(domain.domainName)) + ) { + domain.allowedValues.forEach(value => values.add(value)); + } } return values; } diff --git a/src/app/components/stix/string-property/string-property.component.html b/src/app/components/stix/string-property/string-property.component.html index 1dfa26577..4cfca8295 100644 --- a/src/app/components/stix/string-property/string-property.component.html +++ b/src/app/components/stix/string-property/string-property.component.html @@ -32,10 +32,14 @@ - @for (item of getOptions(); track item) { - {{ - item - }} + @let enabledOptions = getEnabledOptions(); + @for (item of getOptions(enabledOptions); track item) { + {{ item }} } diff --git a/src/app/components/stix/string-property/string-property.component.ts b/src/app/components/stix/string-property/string-property.component.ts index 79f334e8a..d2619270e 100644 --- a/src/app/components/stix/string-property/string-property.component.ts +++ b/src/app/components/stix/string-property/string-property.component.ts @@ -80,7 +80,14 @@ export class StringPropertyComponent implements OnInit, OnChanges { } } - public getOptions(): Set { + public getOptions(enabledOptions = this.getEnabledOptions()): Set { + const options = new Set(enabledOptions); + const current = this.selectControl?.value; + if (current) options.add(current); + return options; + } + + public getEnabledOptions(): Set { const options = new Set(); if (this.loading) return options; const allowedValuesProperty = @@ -95,9 +102,6 @@ export class StringPropertyComponent implements OnInit, OnChanges { d.allowedValues.forEach(options.add, options); } }); - if (this.config.object[this.config.field]) { - options.add(this.config.object[this.config.field]); - } } return options; } diff --git a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.html b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.html index e48376f9f..eeb5d5a9d 100644 --- a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.html +++ b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.html @@ -71,9 +71,10 @@

{{ isNew ? 'Add' : 'Edit' }} {{ config.tooltip }}

{{ item }} diff --git a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.ts b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.ts index 0a943723c..3b633437d 100644 --- a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.ts +++ b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.ts @@ -71,8 +71,9 @@ export class SubtypeDialogComponent implements OnInit, OnDestroy { // create a form control for each 'select' field selections.forEach(fieldName => { - if (!this.data[fieldName]) this.data[fieldName] = []; - this.selectControls[fieldName] = new FormControl(this.data[fieldName]); + this.selectControls[fieldName] = new FormControl( + this.data[fieldName] || [] + ); }); // load allowed values for 'select' fields @@ -85,19 +86,19 @@ export class SubtypeDialogComponent implements OnInit, OnDestroy { ); selections.forEach(field => { const values = new Set(); - const property = allAllowedValues.properties.find( + const property = allAllowedValues?.properties?.find( p => p.propertyName == field ); if ('domains' in this.config.object) { const obj = this.config.object as any; - property.domains.forEach(domain => { + property?.domains?.forEach(domain => { if (obj.domains.includes(domain.domainName)) { domain.allowedValues.forEach(values.add, values); } }); } else { // domains not specified on object - property.domains.forEach(domain => { + property?.domains?.forEach(domain => { domain.allowedValues.forEach(values.add, values); }); } @@ -134,12 +135,25 @@ export class SubtypeDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); } + public getOptions(fieldName: string): string[] { + return [ + ...new Set([ + ...(this.allowedValues[fieldName] || []), + ...(this.selectControls[fieldName].value || []), + ]), + ]; + } + /** Handles onSelectionChange event to add or remove the * user's selection from a multi-select field */ public change(event: MatOptionSelectionChange, fieldName: string): void { if (!event.isUserInput) return; - if (event.source.selected) this.data[fieldName].push(event.source.value); - else this.remove(fieldName, event.source.value); + if (event.source.selected) { + this.data[fieldName] = [ + ...(this.data[fieldName] || []), + event.source.value, + ]; + } else this.remove(fieldName, event.source.value); } /** Remove value from multi-select field */ diff --git a/src/app/services/connectors/rest-api/rest-api-connector.service.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.ts index d96ffb1b1..08bfc9dcf 100644 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.ts +++ b/src/app/services/connectors/rest-api/rest-api-connector.service.ts @@ -84,6 +84,45 @@ export interface ValidationBypassRule { __v?: number; } +export interface AllowedValueOption { + value: string; + enabled: boolean; + objectTypes: string[]; +} + +export interface AllowedValueRule { + propertyName: string; + domainName: string; + objectTypes: string[]; + values: AllowedValueOption[]; + invalidValues: (AllowedValueOption & { reason: string })[]; +} + +export interface AllowedValueDefinition { + propertyName: string; + domainName: string; + objectTypes: string[]; + valueType: 'enum' | 'formatted'; + choices: { value: string; objectTypes: string[] }[]; + description: string; +} + +export interface AllowedValueCatalog { + admVersion: string; + rules: AllowedValueDefinition[]; +} + +export interface AllowedValues { + objectType: string; + properties: { + propertyName: string; + domains: { + domainName: string; + allowedValues: string[]; + }[]; + }[]; +} + export interface MitreIdentityWrites { enabled: boolean; } @@ -2481,30 +2520,79 @@ export class RestApiConnectorService extends ApiConnector { /** * Get all allowed values - * @returns {Observable} all allowed values + * @returns enabled choices, grouped by supported object type, property, and domain */ - private allowedValues; - public getAllAllowedValues(): Observable { - if (this.allowedValues) { - return of(this.allowedValues); - } + public getAllAllowedValues(): Observable { + return this.http + .get(`${this.apiUrl}/config/allowed-values`) + .pipe( + tap(() => logger.log('retrieved allowed values')), + catchError(this.handleError_continue([])) + ); + } + + public getAllowedValueRules(): Observable { + return this.http + .get(`${this.apiUrl}/config/allowed-values/rules`) + .pipe(catchError(this.handleError_raise())); + } + + public getAllowedValueCatalog(): Observable { + return this.http + .get(`${this.apiUrl}/config/allowed-values/catalog`) + .pipe(catchError(this.handleError_raise())); + } - const data$ = this.http - .get(`${this.apiUrl}/config/allowed-values`) + public postAllowedValueRule( + propertyName: string, + domainName: string, + values: AllowedValueOption[] + ): Observable { + return this.http + .post(`${this.apiUrl}/config/allowed-values/rules`, { + propertyName, + domainName, + values, + }) .pipe( - tap(_ => logger.log('retrieved allowed values')), - map(result => result as any), - catchError(this.handleError_continue([])) + tap(this.handleSuccess('allowed values created')), + catchError(this.handleError_raise()) + ); + } + + public validateAllowedValue( + propertyName: string, + domainName: string, + objectTypes: string[], + value: string + ): Observable<{ value: string }> { + return this.http + .post<{ value: string }>( + `${this.apiUrl}/config/allowed-values/validate`, + { + propertyName, + domainName, + objectTypes, + value, + } + ) + .pipe(catchError(this.handleError_raise<{ value: string }>())); + } + + public putAllowedValueRule( + propertyName: string, + domainName: string, + values: AllowedValueOption[] + ): Observable { + return this.http + .put( + `${this.apiUrl}/config/allowed-values/rules/${encodeURIComponent(propertyName)}/${encodeURIComponent(domainName)}`, + { values } + ) + .pipe( + tap(this.handleSuccess('allowed values updated')), + catchError(this.handleError_raise()) ); - const subscription = data$.subscribe({ - next: data => { - this.allowedValues = data; - }, - complete: () => { - subscription.unsubscribe(); - }, - }); - return data$; } /** diff --git a/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.html b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.html new file mode 100644 index 000000000..e84714d51 --- /dev/null +++ b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.html @@ -0,0 +1,293 @@ +

+ {{ rule ? 'Edit Allowed Values' : 'Add New Property' }} +

+ +

+ Choices are validated by backend ADM {{ data.catalog.admVersion }}. Existing + objects are not changed. +

+ + +

+ {{ propertyName }} · {{ domainName }} +

+ + + + object type + + {{ type }} + + Changes for other object types are retained. + + +
+ + + + Property + + property + + {{ property }} + + +
+ +
+
+ + Scope +

Choose one domain and object type supported by the backend ADM.

+
+ + domain + + {{ domain.domainName }} + + + + object type + + {{ type }} + + +
+

+ This property and domain are already configured. Open the editor for + the selected object type instead of creating a duplicate. +

+
+ + + + + +
+
+ + Values +

+ {{ propertyName }} · {{ domainName }} · {{ objectType }} +

+ +
+ + +
+
+ + Review/save +

+ {{ propertyName }} · {{ domainName }} · + {{ objectType }} +

+

+ Only {{ objectType }} will be configured. Other object types start + empty. +

+
    +
  • + {{ option.value }} — {{ option.enabled ? 'enabled' : 'disabled' }} +
  • +
+

+ This creates an empty configuration. No values will be offered for + this property and domain. +

+
+ + +
+
+
+
+ + +

{{ definition?.description }}

+

+ Check to enable a value; uncheck to keep it disabled. Remove clears the + setting for {{ objectType }} only. +

+ + search approved choices + + +
+
+ {{ value }} + {{ + state(value) === undefined + ? 'not configured' + : state(value) + ? 'enabled' + : 'disabled' + }} + +
+
+

No matching approved choices.

+ +

Add a data source component

+

+ Enter the data source and component separately. The backend ADM must + approve the combined value before it can be added. +

+
+ + data source + + Enter a data source + + + data component + + Enter a data component + + + +
+

+ Validate and add this input, or clear it before saving or changing + object type. +

+
+
+
+
+ + + + diff --git a/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.spec.ts b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.spec.ts new file mode 100644 index 000000000..8817b903b --- /dev/null +++ b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.spec.ts @@ -0,0 +1,294 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { FormBuilder } from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { Mock } from 'vitest'; +import { + AllowedValueCatalog, + AllowedValueRule, + RestApiConnectorService, +} from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { AllowedValueDialogComponent } from './allowed-value-dialog.component'; + +// Exercise draft transactions independently of Material rendering. +describe('AllowedValueDialogComponent', () => { + let rule: AllowedValueRule; + let catalog: AllowedValueCatalog; + let response: Subject; + let validation: Subject<{ value: string }>; + let putRule: Mock; + let postRule: Mock; + let validateValue: Mock; + let close: Mock; + let component: AllowedValueDialogComponent; + + function createDialog(editing = true): AllowedValueDialogComponent { + // The transaction uses only these dependency methods. + const dialogRef = { + close, + disableClose: false, + } as MatDialogRef; + const api = { + putAllowedValueRule: putRule, + postAllowedValueRule: postRule, + validateAllowedValue: validateValue, + } as unknown as RestApiConnectorService; + return new AllowedValueDialogComponent( + { catalog, rules: [rule], rule: editing ? rule : undefined }, + dialogRef, + new FormBuilder(), + api + ); + } + + beforeEach(() => { + rule = { + propertyName: 'x_mitre_platforms', + domainName: 'enterprise-attack', + objectTypes: ['technique', 'software'], + values: [ + { value: 'Linux', enabled: true, objectTypes: ['technique'] }, + { value: 'Linux', enabled: false, objectTypes: ['software'] }, + ], + invalidValues: [], + }; + catalog = { + admVersion: '4.11.7', + rules: [ + { + propertyName: rule.propertyName, + domainName: rule.domainName, + objectTypes: rule.objectTypes, + valueType: 'enum', + description: 'Platforms supported by the ADM.', + choices: [ + { value: 'Linux', objectTypes: rule.objectTypes }, + { value: 'Windows', objectTypes: ['technique'] }, + ], + }, + { + propertyName: 'x_mitre_data_sources', + domainName: 'enterprise-attack', + objectTypes: ['technique'], + valueType: 'formatted', + description: 'Data source and component.', + choices: [], + }, + ], + }; + response = new Subject(); + validation = new Subject<{ value: string }>(); + putRule = vi.fn(() => response.asObservable()); + postRule = vi.fn(() => response.asObservable()); + validateValue = vi.fn(() => validation.asObservable()); + close = vi.fn(); + component = createDialog(); + }); + + it('preserves draft settings across object types and serializes disjoint states', () => { + component.setEnabled('Windows', true); + component.selectObjectType('software'); + expect(component.state('Linux')).toBe(false); + component.removeValue('Linux'); + component.setEnabled('Linux', true); + component.selectObjectType('technique'); + expect(component.state('Windows')).toBe(true); + component.setEnabled('Linux', false); + component.confirm(); + + expect(putRule).toHaveBeenCalledWith(rule.propertyName, rule.domainName, [ + { value: 'Linux', enabled: false, objectTypes: ['technique'] }, + { value: 'Windows', enabled: true, objectTypes: ['technique'] }, + { value: 'Linux', enabled: true, objectTypes: ['software'] }, + ]); + expect(rule.values[0].enabled).toBe(true); + expect(rule.values[1].enabled).toBe(false); + }); + + it('discards changes on cancel without changing the original configuration', () => { + const original = structuredClone(rule); + component.removeValue('Linux'); + component.setEnabled('Windows', true); + component.selectObjectType('software'); + component.setEnabled('Linux', true); + component.cancel(); + + expect(rule).toEqual(original); + expect(putRule).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledWith(); + }); + + it('rejects enum choices outside the selected ADM scope, including legacy invalid settings', () => { + rule.invalidValues = [ + { + value: 'Unsupported', + enabled: true, + objectTypes: ['software'], + reason: 'Not an ADM platform', + }, + ]; + component = createDialog(); + component.selectObjectType('software'); + component.setEnabled('Unsupported', true); + component.setEnabled('Windows', true); + expect(component.visibleChoices).toEqual(['Linux']); + component.confirm(); + expect(putRule).toHaveBeenCalledWith( + rule.propertyName, + rule.domainName, + rule.values + ); + expect(rule.invalidValues).toHaveLength(1); + }); + + it('keeps other object types when removing a shared setting', () => { + rule.values = [ + { value: 'Linux', enabled: true, objectTypes: ['technique', 'software'] }, + ]; + component = createDialog(); + component.removeValue('Linux'); + component.confirm(); + expect(putRule).toHaveBeenCalledWith(rule.propertyName, rule.domainName, [ + { value: 'Linux', enabled: true, objectTypes: ['software'] }, + ]); + }); + + it('keeps a failed draft and blocks concurrent saves and edits until retry', () => { + component.setEnabled('Windows', true); + component.confirm(); + component.confirm(); + component.removeValue('Windows'); + component.cancel(); + expect(putRule).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + response.error( + new HttpErrorResponse({ status: 409, error: { message: 'Conflict' } }) + ); + expect(component.error).toBe('Conflict'); + expect(component.saving).toBe(false); + expect(component.state('Windows')).toBe(true); + + response = new Subject(); + component.confirm(); + const saved = { ...rule, values: component.values }; + response.next(saved); + expect(putRule).toHaveBeenCalledTimes(2); + expect(close).toHaveBeenCalledWith(saved); + }); + + it('opens an existing group at the chosen type instead of creating a duplicate', () => { + component = createDialog(false); + component.selectProperty(rule.propertyName); + component.selectDomain(rule.domainName); + component.selectObjectType('software'); + component.confirm(); + expect(postRule).not.toHaveBeenCalled(); + component.openExisting(); + expect(component.objectType).toBe('software'); + expect(component.state('Linux')).toBe(false); + component.setEnabled('Linux', true); + component.confirm(); + expect(putRule).toHaveBeenCalledWith(rule.propertyName, rule.domainName, [ + { value: 'Linux', enabled: true, objectTypes: ['technique', 'software'] }, + ]); + }); + + it('blocks pending and rejected formatted input, then creates only after backend approval', () => { + component = createDialog(false); + component.selectProperty('x_mitre_data_sources'); + component.selectDomain('enterprise-attack'); + component.selectObjectType('technique'); + component.formatted.setValue({ source: 'Invalid', component: 'Component' }); + component.confirm(); + expect(postRule).not.toHaveBeenCalled(); + component.addFormatted(); + component.confirm(); + component.addFormatted(); + expect(validateValue).toHaveBeenCalledTimes(1); + expect(postRule).not.toHaveBeenCalled(); + validation.error( + new HttpErrorResponse({ + status: 400, + error: { message: 'Invalid data source' }, + }) + ); + expect(component.error).toBe('Invalid data source'); + expect(component.values).toEqual([]); + component.confirm(); + expect(postRule).not.toHaveBeenCalled(); + + validation = new Subject<{ value: string }>(); + component.formatted.setValue({ + source: ' Process ', + component: ' Process Creation ', + }); + component.addFormatted(); + expect(validateValue).toHaveBeenLastCalledWith( + 'x_mitre_data_sources', + 'enterprise-attack', + ['technique'], + 'Process: Process Creation' + ); + validation.next({ value: 'Process: Process Creation' }); + component.confirm(); + const values = [ + { + value: 'Process: Process Creation', + enabled: true, + objectTypes: ['technique'], + }, + ]; + expect(postRule).toHaveBeenCalledWith( + 'x_mitre_data_sources', + 'enterprise-attack', + values + ); + expect(close).not.toHaveBeenCalled(); + const saved = { + propertyName: 'x_mitre_data_sources', + domainName: 'enterprise-attack', + objectTypes: ['technique'], + values, + invalidValues: [], + }; + response.next(saved); + expect(close).toHaveBeenCalledWith(saved); + }); + + it('creates a new catalog scope with only the chosen object type', () => { + catalog.rules.push({ ...catalog.rules[0], domainName: 'ics-attack' }); + component = createDialog(false); + component.selectProperty(rule.propertyName); + component.selectDomain('ics-attack'); + component.selectObjectType('technique'); + component.setEnabled('Windows', true); + component.selectObjectType('software'); + component.setEnabled('Linux', true); + component.selectObjectType('technique'); + expect(component.state('Windows')).toBe(true); + component.selectObjectType('software'); + component.confirm(); + expect(postRule).toHaveBeenCalledWith(rule.propertyName, 'ics-attack', [ + { value: 'Linux', enabled: true, objectTypes: ['software'] }, + ]); + }); + + it('allows empty configured groups and never falls back when a catalog scope is missing', () => { + component.removeValue('Linux'); + component.selectObjectType('software'); + component.removeValue('Linux'); + component.confirm(); + expect(putRule).toHaveBeenCalledWith( + rule.propertyName, + rule.domainName, + [] + ); + putRule.mockClear(); + catalog.rules = []; + component = createDialog(); + component.setEnabled('Linux', true); + component.confirm(); + expect(component.canSave).toBe(false); + expect(putRule).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.ts b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.ts new file mode 100644 index 000000000..1522ae507 --- /dev/null +++ b/src/app/views/dashboard-page/allowed-values/allowed-value-dialog/allowed-value-dialog.component.ts @@ -0,0 +1,350 @@ +import { Component, Inject, OnDestroy } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { FormBuilder, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { finalize, take, takeUntil } from 'rxjs/operators'; +import { + AllowedValueCatalog, + AllowedValueDefinition, + AllowedValueOption, + AllowedValueRule, + RestApiConnectorService, +} from 'src/app/services/connectors/rest-api/rest-api-connector.service'; + +@Component({ + selector: 'app-allowed-value-dialog', + templateUrl: './allowed-value-dialog.component.html', + styles: [ + ` + .rule-identity { + overflow-wrap: anywhere; + } + .scope-fields, + .formatted-fields, + .step-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + } + .scope-fields mat-form-field, + .formatted-fields mat-form-field { + flex: 1; + } + .search { + width: 100%; + } + .option-list { + max-height: 19em; + overflow-y: auto; + } + .option-row { + display: flex; + gap: 12px; + align-items: center; + } + .option-row mat-checkbox { + flex: 1; + overflow-wrap: anywhere; + } + .option-state { + font-size: 0.85em; + } + .draft-error { + color: var(--mat-sys-error, #b00020); + } + .legacy-warning { + border-left: 3px solid currentColor; + padding-left: 12px; + } + .review-values { + max-height: 18em; + overflow-y: auto; + } + `, + ], + standalone: false, +}) +export class AllowedValueDialogComponent implements OnDestroy { + public rule?: AllowedValueRule; + public propertyName = ''; + public domainName = ''; + public objectType = ''; + public search = ''; + public saving = false; + public validating = false; + public error = ''; + public formatted = this.formBuilder.nonNullable.group({ + source: ['', [Validators.required, Validators.pattern(/\S/)]], + component: ['', [Validators.required, Validators.pattern(/\S/)]], + }); + private draft = new Map>(); + private approved = new Map>(); + private destroyed = new Subject(); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { + catalog: AllowedValueCatalog; + rules: AllowedValueRule[]; + rule?: AllowedValueRule; + }, + public dialogRef: MatDialogRef, + private formBuilder: FormBuilder, + private api: RestApiConnectorService + ) { + if (data.rule) this.loadRule(data.rule); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + + public get busy(): boolean { + return this.saving || this.validating; + } + + public get properties(): string[] { + return [...new Set(this.data.catalog.rules.map(rule => rule.propertyName))]; + } + + public get domains(): AllowedValueDefinition[] { + return this.data.catalog.rules.filter( + rule => rule.propertyName === this.propertyName + ); + } + + public get definition(): AllowedValueDefinition | undefined { + return this.domains.find(rule => rule.domainName === this.domainName); + } + + public get existingRule(): AllowedValueRule | undefined { + return this.data.rules.find( + rule => + rule.propertyName === this.propertyName && + rule.domainName === this.domainName + ); + } + + public get scopeReady(): boolean { + return !!this.definition?.objectTypes.includes(this.objectType); + } + + public get pendingInput(): boolean { + const { source, component } = this.formatted.getRawValue(); + return !!(source || component); + } + + public get canSave(): boolean { + return ( + this.scopeReady && + !this.busy && + !this.pendingInput && + (!!this.rule || !this.existingRule) + ); + } + + public selectProperty(propertyName: string): void { + if (this.busy || this.rule) return; + this.propertyName = propertyName; + this.selectDomain(''); + } + + public selectDomain(domainName: string): void { + if (this.busy || this.rule) return; + this.domainName = domainName; + this.objectType = ''; + this.resetDraft(); + } + + public selectObjectType(objectType: string): void { + if ( + this.busy || + this.pendingInput || + !this.definition?.objectTypes.includes(objectType) + ) + return; + this.objectType = objectType; + this.search = ''; + } + + public openExisting(): void { + if (this.busy || !this.existingRule || !this.scopeReady) return; + this.loadRule(this.existingRule, this.objectType); + } + + private resetDraft(): void { + this.draft.clear(); + this.approved.clear(); + this.search = ''; + this.error = ''; + this.formatted.reset(); + for (const choice of this.definition?.choices || []) { + for (const type of choice.objectTypes) this.approve(type, choice.value); + } + } + + private loadRule(rule: AllowedValueRule, objectType?: string): void { + this.rule = rule; + this.propertyName = rule.propertyName; + this.domainName = rule.domainName; + this.resetDraft(); + this.objectType = objectType || this.definition?.objectTypes[0] || ''; + // The server separates invalid legacy settings; only compliant values enter the draft. + for (const option of rule.values) { + for (const type of option.objectTypes) { + if (this.definition?.valueType === 'formatted') + this.approve(type, option.value); + if (this.approved.get(type)?.has(option.value)) + this.setDraft(type, option.value, option.enabled); + } + } + } + + private approve(type: string, value: string): void { + if (!this.approved.has(type)) this.approved.set(type, new Set()); + this.approved.get(type)!.add(value); + } + + private setDraft(type: string, value: string, enabled: boolean): void { + if (!this.draft.has(type)) this.draft.set(type, new Map()); + this.draft.get(type)!.set(value, enabled); + } + + public get visibleChoices(): string[] { + const query = this.search.trim().toLowerCase(); + return [...(this.approved.get(this.objectType) || [])] + .filter(value => value.toLowerCase().includes(query)) + .sort((a, b) => a.localeCompare(b)); + } + + public state(value: string): boolean | undefined { + return this.draft.get(this.objectType)?.get(value); + } + + public setEnabled(value: string, enabled: boolean): void { + if ( + this.busy || + !this.scopeReady || + !this.approved.get(this.objectType)?.has(value) + ) + return; + this.setDraft(this.objectType, value, enabled); + } + + public removeValue(value: string): void { + if (!this.busy) this.draft.get(this.objectType)?.delete(value); + } + + public clearInput(): void { + if (!this.busy) this.formatted.reset(); + } + + public addFormatted(): void { + if ( + this.busy || + !this.scopeReady || + this.definition?.valueType !== 'formatted' + ) + return; + if (this.formatted.invalid) { + this.formatted.markAllAsTouched(); + return; + } + const { source, component } = this.formatted.getRawValue(); + const type = this.objectType; + this.validating = true; + this.error = ''; + this.dialogRef.disableClose = true; + this.formatted.disable(); + this.api + .validateAllowedValue( + this.propertyName, + this.domainName, + [type], + `${source.trim()}: ${component.trim()}` + ) + .pipe( + take(1), + takeUntil(this.destroyed), + finalize(() => { + this.validating = false; + this.dialogRef.disableClose = false; + this.formatted.enable(); + }) + ) + .subscribe({ + next: ({ value }) => { + this.approve(type, value); + this.setDraft(type, value, true); + this.formatted.reset(); + this.search = ''; + }, + error: (error: HttpErrorResponse) => this.showError(error), + }); + } + + public get values(): AllowedValueOption[] { + const values: AllowedValueOption[] = []; + for (const [type, options] of this.draft) { + // Creation configures exactly the chosen object type. Editing retains all other types. + if (!this.rule && type !== this.objectType) continue; + for (const [value, enabled] of options) { + const existing = values.find( + option => option.value === value && option.enabled === enabled + ); + if (existing) existing.objectTypes.push(type); + else values.push({ value, enabled, objectTypes: [type] }); + } + } + return values; + } + + public cancel(): void { + if (!this.busy) this.dialogRef.close(); + } + + public confirm(): void { + if (!this.canSave) return; + this.saving = true; + this.error = ''; + this.dialogRef.disableClose = true; + const request = this.rule + ? this.api.putAllowedValueRule( + this.propertyName, + this.domainName, + this.values + ) + : this.api.postAllowedValueRule( + this.propertyName, + this.domainName, + this.values + ); + request + .pipe( + take(1), + takeUntil(this.destroyed), + finalize(() => { + this.saving = false; + this.dialogRef.disableClose = false; + }) + ) + .subscribe({ + next: rule => this.dialogRef.close(rule), + error: (error: HttpErrorResponse) => this.showError(error), + }); + } + + private showError(error: HttpErrorResponse): void { + this.error = + typeof error.error?.details === 'string' + ? error.error.details + : typeof error.error?.message === 'string' + ? error.error.message + : typeof error.error === 'string' + ? error.error + : error.message; + } +} diff --git a/src/app/views/dashboard-page/allowed-values/allowed-values.component.html b/src/app/views/dashboard-page/allowed-values/allowed-values.component.html new file mode 100644 index 000000000..ab562b217 --- /dev/null +++ b/src/app/views/dashboard-page/allowed-values/allowed-values.component.html @@ -0,0 +1,129 @@ +
+
+

Allowed Values

+ +
+

+ Configure values by property and domain, then choose one object type at a + time. Only choices approved by the backend ADM can be configured; validation + bypasses do not apply. Disabling or removing values does not change existing + objects. +

+

+ Backend ADM {{ catalog.admVersion }} · + {{ dataSource.data.length }} configured property/domain groups +

+
+ {{ error }} + +
+ +
+
+
+ + search + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
property{{ row.propertyName }}domain{{ row.domainName }} + object types + + {{ row.objectTypes.join(', ') }} + value set + {{ row.valueCount }} + {{ row.valueCount === 1 ? 'value' : 'values' }} + + · {{ row.disabledCount }} disabled options +

+ {{ row.invalidValues.length }} unavailable legacy settings + excluded from dropdowns. Open the editor to review. +

+
+ + {{ option.value }}{{ option.enabled ? '' : ' (disabled)' }} + + + +{{ row.values.length - row.preview.length }} more options + +
+
actions + +
+ {{ + dataSource.filter + ? 'No matching allowed value rules.' + : 'No configured properties. Use Add New Property to begin.' + }} +
+
+ +
+
diff --git a/src/app/views/dashboard-page/allowed-values/allowed-values.component.ts b/src/app/views/dashboard-page/allowed-values/allowed-values.component.ts new file mode 100644 index 000000000..64442c776 --- /dev/null +++ b/src/app/views/dashboard-page/allowed-values/allowed-values.component.ts @@ -0,0 +1,185 @@ +import { + AfterViewInit, + Component, + OnInit, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { MatDialog } from '@angular/material/dialog'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { forkJoin } from 'rxjs'; +import { finalize, take } from 'rxjs/operators'; +import { + AllowedValueCatalog, + AllowedValueOption, + AllowedValueRule, + RestApiConnectorService, +} from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { AllowedValueDialogComponent } from './allowed-value-dialog/allowed-value-dialog.component'; + +interface AllowedValueRow extends AllowedValueRule { + valueCount: number; + disabledCount: number; + preview: AllowedValueOption[]; +} + +@Component({ + selector: 'app-allowed-values', + templateUrl: './allowed-values.component.html', + styleUrls: ['../validation-bypasses/validation-bypasses.component.scss'], + styles: [ + ` + .allowed-values-page .value-summary { + min-width: 15em; + max-width: 28em; + padding-top: 12px; + padding-bottom: 12px; + } + .allowed-values-page .value-preview { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + } + .allowed-values-page .value-option { + border: 1px solid currentColor; + border-radius: 12px; + padding: 2px 8px; + max-width: 100%; + overflow-wrap: anywhere; + } + .allowed-values-page .value-option.disabled { + border-style: dashed; + } + .allowed-values-page .remaining-values { + align-self: center; + } + `, + ], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class AllowedValuesComponent implements OnInit, AfterViewInit { + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + public dataSource = new MatTableDataSource([]); + public columnsToDisplay = [ + 'propertyName', + 'domainName', + 'objectTypes', + 'values', + 'actions', + ]; + public loading = false; + public loaded = false; + public error = ''; + public catalog?: AllowedValueCatalog; + + constructor( + private api: RestApiConnectorService, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.dataSource.filterPredicate = (row, query) => + [ + row.propertyName, + row.domainName, + ...row.objectTypes, + ...row.values.map( + option => `${option.value} ${option.enabled ? 'enabled' : 'disabled'}` + ), + ] + .join(' ') + .toLowerCase() + .includes(query); + this.dataSource.sortingDataAccessor = (row, column) => { + if (column === 'objectTypes') return row.objectTypes.join(', '); + if (column === 'values') return row.valueCount; + return row[column]; + }; + this.loadRules(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + public loadRules(): void { + this.loading = true; + this.error = ''; + this.loaded = false; + this.catalog = undefined; + forkJoin({ + rules: this.api.getAllowedValueRules(), + catalog: this.api.getAllowedValueCatalog(), + }) + .pipe( + take(1), + finalize(() => (this.loading = false)) + ) + .subscribe({ + next: ({ rules, catalog }) => { + this.catalog = catalog; + this.dataSource.data = rules.map(rule => this.toRow(rule)); + this.loaded = true; + }, + error: (error: HttpErrorResponse) => { + this.error = + typeof error.error?.message === 'string' + ? error.error.message + : typeof error.error === 'string' + ? error.error + : error.message; + }, + }); + } + + public applySearch(query: string): void { + this.dataSource.filter = query.trim().toLowerCase(); + this.paginator?.firstPage(); + } + + public editValues(rule?: AllowedValueRule): void { + if (!this.catalog || this.loading || this.error) return; + this.dialog + .open(AllowedValueDialogComponent, { + width: '58em', + maxWidth: '95vw', + data: { rule, rules: this.dataSource.data, catalog: this.catalog }, + autoFocus: false, + }) + .afterClosed() + .pipe(take(1)) + .subscribe((updated?: AllowedValueRule) => { + if (!updated) return; + const existing = this.dataSource.data.some( + row => + row.propertyName === updated.propertyName && + row.domainName === updated.domainName + ); + this.dataSource.data = existing + ? this.dataSource.data.map(row => + row.propertyName === updated.propertyName && + row.domainName === updated.domainName + ? this.toRow(updated) + : row + ) + : [...this.dataSource.data, this.toRow(updated)]; + }); + } + + private toRow(rule: AllowedValueRule): AllowedValueRow { + return { + ...rule, + valueCount: new Set(rule.values.map(option => option.value)).size, + disabledCount: rule.values.filter(option => !option.enabled).length, + preview: rule.values.slice(0, 3), + }; + } +} diff --git a/src/app/views/dashboard-page/dashboard-page.component.html b/src/app/views/dashboard-page/dashboard-page.component.html index 6a09e4a22..7c4584bed 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.html +++ b/src/app/views/dashboard-page/dashboard-page.component.html @@ -21,6 +21,14 @@

Knowledge Base Overview

routerLink="validation-bypasses"> ADM Validation Bypasses + + + View in Object Library + + + + + +
+ + Use revision + + + +
+
+ + +
+
+
+ Revision + +
+ +
+
+
+ +
+ + +
+ + + +
+

+ {{ revision.name }} + {{ revision.attackId }} +

+
+
Source track
+
+ {{ source.trackName }} + {{ source.trackId }} + Release {{ source.version }} +
+
Conflict reason
+
{{ source.reason }}
+
+
+ + +
+

+ This object type has no read-only details dialog. View JSON to inspect + the exact revision. +

+

+ Loading exact revision {{ revision.modified }}… +

+ +
+
{{ revision.json }}
+
+
+
+ +

+ Creating a new draft with the selected revision… +

+ + + + + diff --git a/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.scss b/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.scss new file mode 100644 index 000000000..c3d3cbe3d --- /dev/null +++ b/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.scss @@ -0,0 +1,224 @@ +@use '../../../style/colors'; + +.snapshot-quarantine-dialog { + width: 100%; + min-width: 0; + + .mat-mdc-dialog-content { + line-height: 1.5; + } + + .snapshot-context { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 20px; + } + + code, + time, + h3 { + overflow-wrap: anywhere; + } + + code, + time { + font-size: 0.9em; + } + + .review-notice { + border-left: 3px solid colors.color(secondary); + padding: 12px 16px; + margin: 20px 0; + } + + .conflict-group { + margin-top: 24px; + + header { + margin-bottom: 12px; + } + + h3 { + font-size: 1.05em; + font-weight: 600; + margin: 0 0 4px; + } + + header > code, + .revision-count { + display: block; + } + } + + .revision-count { + font-size: 0.85em; + margin-top: 4px; + } + + .revision-list { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + } + + .revision-card { + border: 1px solid; + border-radius: 6px; + padding: 12px; + min-width: 0; + } + + .revision-label { + font-weight: 600; + } + + .revision-details { + margin-top: 8px; + } + + .revision-name { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; + } + + .revision-source { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 6px 12px; + font-size: 0.9em; + margin: 12px 0 0; + + dt { + font-weight: 600; + } + + dd { + margin: 0; + overflow-wrap: anywhere; + } + + dd > span, + dd > code { + display: block; + } + } + + .comparison-actions, + .revision-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + margin-top: 12px; + } + + .revision-loading, + .details-unavailable { + font-size: 0.85em; + } + + .revision-error { + border-left: 3px solid colors.color(error); + padding-left: 12px; + overflow-wrap: anywhere; + font-size: 0.9em; + } + + .revision-json pre { + box-sizing: border-box; + max-height: 360px; + max-width: 100%; + overflow: auto; + padding: 12px; + margin: 12px 0 0; + border: 1px solid; + border-radius: 4px; + font-size: 12px; + line-height: 1.5; + tab-size: 2; + + code { + font-size: inherit; + white-space: pre; + overflow-wrap: normal; + } + } + + .resolution-actions { + margin-top: 12px; + } + + .resolution-error { + border-left: 3px solid colors.color(error); + padding: 12px 16px; + font-weight: 600; + } + + .light & { + .revision-card { + border-color: colors.border-color(light); + } + + .revision-card.selected { + border-color: colors.color(secondary); + } + + .review-notice, + .revision-json pre { + background: colors.color-alternate(light); + border-color: colors.border-color(light); + } + + .snapshot-context > span, + .revision-count { + color: colors.on-color-deemphasis(light); + } + } + + .dark & { + .revision-card { + border-color: colors.border-color(dark); + } + + .revision-card.selected, + .review-notice { + border-color: colors.color(mitre-light-blue); + } + + .review-notice, + .revision-json pre { + background: colors.color-alternate(dark); + border-color: colors.border-color(dark); + } + + .snapshot-context > span, + .revision-count { + color: colors.on-color-deemphasis(dark); + } + } + + @media (max-width: 760px) { + .revision-list { + grid-template-columns: minmax(0, 1fr); + } + } + + @media (max-width: 600px) { + .revision-source { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + + dd { + margin-bottom: 8px; + } + } + + .resolution-actions button { + width: 100%; + } + } +} diff --git a/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.ts b/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.ts new file mode 100644 index 000000000..243aed131 --- /dev/null +++ b/src/app/components/snapshot-quarantine-dialog/snapshot-quarantine-dialog.component.ts @@ -0,0 +1,313 @@ +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { Component, Inject, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatRadioModule } from '@angular/material/radio'; +import { RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + QuarantineEntry, + TierEntryDisplayFields, +} from 'src/app/classes/release-tracks/tiers'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { Software, StixObject } from 'src/app/classes/stix'; +import { AttackTypeToClass } from 'src/app/utils/class-mappings'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; +import { AttackType } from 'src/app/utils/types'; +import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; +import { environment } from 'src/environments/environment'; + +type SnapshotQuarantineEntry = Omit & + TierEntryDisplayFields & { object_modified: string | Date }; + +export interface SnapshotQuarantineDialogData { + trackId: string; + modified: string; + title: string; + entries: SnapshotQuarantineEntry[]; + canResolve: boolean; +} + +interface QuarantineSource { + trackId: string; + trackName: string; + version?: string; + reason: string; +} + +interface RevisionResponse { + stix: Record & { + id: string; + type: string; + modified: string; + }; + workspace?: Record; +} + +interface QuarantineRevision { + modified: string; + name?: string; + attackId?: string; + sources: QuarantineSource[]; + panelId: string; + showJson: boolean; + loading: boolean; + error: string; + detailsRequested: boolean; + raw?: RevisionResponse; + json?: string; +} + +interface QuarantineGroup { + objectRef: string; + name?: string; + revisions: QuarantineRevision[]; + selectedModified: string | null; + attackType?: AttackType; + currentObjectRoute?: string[]; + canViewDetails: boolean; +} + +@Component({ + selector: 'app-snapshot-quarantine-dialog', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatRadioModule, + RouterLink, + ], + templateUrl: './snapshot-quarantine-dialog.component.html', + styleUrls: ['./snapshot-quarantine-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class SnapshotQuarantineDialogComponent implements OnDestroy { + public readonly groups: QuarantineGroup[]; + public savingObjectRef: string | null = null; + public errorMessage = ''; + public errorObjectRef: string | null = null; + private readonly requests = new Subscription(); + + constructor( + @Inject(MAT_DIALOG_DATA) public readonly data: SnapshotQuarantineDialogData, + public readonly dialogRef: MatDialogRef< + SnapshotQuarantineDialogComponent, + boolean + >, + private readonly connector: ReleaseTracksConnectorService, + private readonly http: HttpClient, + private readonly dialog: MatDialog + ) { + const groups = new Map(); + for (const entry of data.entries) { + let group = groups.get(entry.object_ref); + if (!group) { + const attackType: AttackType | undefined = + StixTypeToAttackType[entry.object_ref.split('--')[0]]; + group = { + objectRef: entry.object_ref, + name: entry.name, + revisions: [], + selectedModified: null, + attackType, + currentObjectRoute: + attackType && !['collection', 'note'].includes(attackType) + ? ['/', attackType, entry.object_ref] + : undefined, + canViewDetails: !!attackType && attackType !== 'note', + }; + groups.set(entry.object_ref, group); + } + const modified = + entry.object_modified instanceof Date + ? entry.object_modified.toISOString() + : entry.object_modified; + let revision = group.revisions.find(item => item.modified === modified); + if (!revision) { + revision = { + modified, + name: entry.name, + attackId: entry.attack_id, + sources: [], + panelId: `quarantine-json-${groups.size}-${group.revisions.length}`, + showJson: false, + loading: false, + error: '', + detailsRequested: false, + }; + group.revisions.push(revision); + } + revision.sources.push({ + trackId: entry.source_track_id, + trackName: entry.source_track_name, + version: entry.source_snapshot_version, + reason: entry.conflict_reason, + }); + } + this.groups = Array.from(groups.values()); + } + + public ngOnDestroy(): void { + this.requests.unsubscribe(); + } + + public toggleJson( + group: QuarantineGroup, + revision: QuarantineRevision + ): void { + revision.showJson = !revision.showJson; + if (revision.showJson) this.loadRevision(group, revision); + } + + public compareJson(group: QuarantineGroup): void { + for (const revision of group.revisions) { + revision.showJson = true; + this.loadRevision(group, revision); + } + } + + public viewDetails( + group: QuarantineGroup, + revision: QuarantineRevision + ): void { + if (!group.canViewDetails || this.savingObjectRef) return; + revision.detailsRequested = true; + this.loadRevision(group, revision); + } + + public loadRevision( + group: QuarantineGroup, + revision: QuarantineRevision + ): void { + if (revision.loading) return; + if (revision.raw) { + if (revision.detailsRequested) this.openDetails(group, revision); + return; + } + if (!group.attackType) { + revision.error = + 'Exact revision retrieval is unavailable for this STIX type.'; + return; + } + revision.loading = true; + revision.error = ''; + const url = + `${environment.integrations.rest_api.url}/${AttackTypeToPlural[group.attackType]}` + + `/${encodeURIComponent(group.objectRef)}/modified/${encodeURIComponent(revision.modified)}`; + this.requests.add( + this.http + .get(url) + .pipe( + map(response => { + const candidates = Array.isArray(response) ? response : [response]; + const exact = candidates.find( + candidate => + candidate?.stix?.id === group.objectRef && + candidate.stix.modified === revision.modified && + candidate.stix.type === group.objectRef.split('--')[0] + ); + if (!exact) { + throw new Error( + 'The server did not return the requested exact revision. No current revision was substituted.' + ); + } + return exact; + }) + ) + .subscribe({ + next: raw => { + revision.raw = raw; + // Preserve all raw STIX fields; model serialization can discard them. + revision.json = JSON.stringify(raw.stix, null, 2); + revision.loading = false; + if (revision.detailsRequested) this.openDetails(group, revision); + }, + error: error => { + revision.loading = false; + const message = error?.error?.message ?? error?.message; + revision.error = + typeof message === 'string' + ? `Could not load this exact revision. ${message}` + : 'Could not load this exact revision. Please try again.'; + }, + }) + ); + } + + private openDetails( + group: QuarantineGroup, + revision: QuarantineRevision + ): void { + try { + const raw = revision.raw; + const ObjectClass = AttackTypeToClass[group.attackType] as new ( + value: RevisionResponse + ) => StixObject; + const object = + raw.stix.type === 'malware' || raw.stix.type === 'tool' + ? new Software(raw.stix.type, raw) + : new ObjectClass(raw); + this.dialog.open(StixDialogComponent, { + data: { + object, + mode: 'view', + editable: false, + sidebarControl: 'disable', + }, + width: '50em', + maxWidth: '96vw', + maxHeight: '85vh', + autoFocus: false, + ariaLabel: `${revision.name || group.objectRef}, exact revision ${revision.modified}`, + }); + revision.detailsRequested = false; + revision.error = ''; + } catch { + revision.error = + 'Could not display details for this exact revision. View JSON to inspect its original fields, or retry.'; + } + } + + public useSelectedRevision(group: QuarantineGroup): void { + if ( + !this.data.canResolve || + this.savingObjectRef || + !group.selectedModified + ) + return; + + this.errorMessage = ''; + this.savingObjectRef = group.objectRef; + this.dialogRef.disableClose = true; + this.connector + .promoteQuarantinedRevision(this.data.trackId, { + object_ref: group.objectRef, + object_modified: group.selectedModified, + }) + .subscribe({ + next: () => this.dialogRef.close(true), + error: error => { + this.dialogRef.disableClose = false; + this.savingObjectRef = null; + this.errorObjectRef = group.objectRef; + const message = + error?.error?.message ?? error?.error ?? error?.message; + this.errorMessage = + typeof message === 'string' + ? `Could not use the selected revision. ${message}` + : 'Could not use the selected revision. Your selection is unchanged. Please try again.'; + }, + }); + } +} diff --git a/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.html b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.html new file mode 100644 index 000000000..2bfe7171f --- /dev/null +++ b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.html @@ -0,0 +1,374 @@ +
+ + +
+
+
Virtual track · Snapshot contents
+
+

+ {{ title || workbench?.name || 'Snapshot' }} +

+ + {{ workbench.version ? 'Tagged · ' + workbench.version : 'Draft' }} + + Latest snapshot +
+

+ Inspect the manifest-selected graph for this exact snapshot, not the + latest workspace objects. +

+
+
+ {{ rows.length | number }} + {{ + workbench.content_manifest_entries + ? 'listed objects' + : 'exported objects' + }} +
+
+ +
+
+
Exact snapshot revision
+
+ +
+
+
+
Content manifest
+
+ {{ + workbench?.content_manifest_id || + (loading ? 'Loading…' : 'Not supplied by the snapshot response') + }} +
+
+
+ +
+ +

Loading exact snapshot contents

+

Retrieving its metadata and manifest-selected STIX 2.1 export.

+
+ + + + +
+
+ +
+ {{ + workbench.content_manifest_entries + ? 'Manifest entries and exported graph' + : 'Exported graph' + }} +

+ Includes relationships and supporting objects. The generated + collection projection is excluded. +

+

+ {{ exportedCount | number }} exported objects, plus any non-exported + manifest dependencies. Render-only entries support LinkById + citations and are not shipped in the bundle. +

+

+ This API does not expose the complete manifest entry list. + {{ workbench.linkTargetCount | number }} non-exported LinkById + render dependencies are not listed. + Non-exported LinkById render dependencies are not + listed. + The count above is the exported graph, not the complete manifest. +

+

+ Source tracks were not recorded for + {{ unrecordedSourceCount | number }} listed objects. Their origin is + shown as “Source not recorded”; current track membership is not used + to infer snapshot provenance. +

+
+
+ +
+ +
+ + Search snapshot contents + + + + + + STIX type + + All object types + {{ + type + }} + + + + Source track + + All source tracks + + {{ sourceTrackLabels.get(track.track_id) }} + + + Source not recorded + + + +
+

+ {{ filteredRows.length | number }} of {{ rows.length | number }} objects · + Read-only exact revisions +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Exact snapshot objects, including relationships, supporting objects, + and available manifest dependencies +
ObjectSTIX type / roleExact revisionInspect
+ {{ row.name }} + {{ + row.context + }} + {{ row.id }} + + + Source: + + {{ + sourceTrackLabels.get(track.track_id) + }}{{ last ? '' : ' · ' }} + + + Source not recorded + + + {{ row.type }}{{ row.roles.join(' · ') }} + + + UnversionedCreated {{ row.object?.created }} + +
+ + + View in Object Library + + + + Object Library navigation is unavailable for this + reference. + + + + + Exact reference
Payload not supplied
+
+ +
+
+ Exact revision · Read-only JSON{{ + row.roles.includes('Exported') + ? 'STIX 2.1 export representation' + : 'Manifest dependency payload' + }} +
+
{{ expandedJson }}
+
+
+ + +
+ +

+ {{ + rows.length + ? 'No matching objects' + : 'No exported objects in this snapshot' + }} +

+

+ {{ + rows.length + ? 'Try a different name, identifier, STIX type, or source track. Nothing has been removed from the snapshot.' + : 'This snapshot’s export contains no objects beyond its generated collection projection.' + }} +

+ +
+
+
+
diff --git a/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.scss b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.scss new file mode 100644 index 000000000..ba6d9deb7 --- /dev/null +++ b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.scss @@ -0,0 +1,421 @@ +@use '../../../style/colors'; +@use '../../../style/typography'; + +.virtual-snapshot-contents { + display: block; + min-width: 0; + padding: 8px 0 24px; + + .back-button { + margin: 0 0 20px -12px; + } + .snapshot-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; + } + .snapshot-heading { + min-width: 0; + } + .eyebrow { + margin-bottom: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + @include colors.theme-text-deemphasis; + } + .title-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + h2 { + margin: 0 8px 0 0; + font-size: 26px; + line-height: 1.3; + overflow-wrap: anywhere; + } + } + .snapshot-label, + .latest-label { + padding: 4px 9px; + border: 1px solid; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + @include colors.theme-border-color; + } + .snapshot-label { + @include colors.theme-property( + background, + rgba(colors.color(mitre-light-blue), 0.1), + rgba(colors.color(mitre-blue), 0.07) + ); + @include colors.theme-property( + color, + colors.color(mitre-light-blue), + colors.color(mitre-blue) + ); + } + .latest-label { + @include colors.theme-text-deemphasis; + } + .snapshot-intro { + margin: 12px 0 0; + line-height: 1.6; + @include colors.theme-text-deemphasis; + } + .object-count { + min-width: 126px; + flex: 0 0 auto; + padding: 14px 18px; + border: 1px solid; + border-radius: 8px; + text-align: center; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.color(mitre-light-blue), 0.06), + rgba(colors.color(mitre-blue), 0.035) + ); + strong { + display: block; + font-size: 30px; + line-height: 1.2; + } + span { + display: block; + margin-top: 5px; + font-size: 12px; + @include colors.theme-text-deemphasis; + } + } + .snapshot-identity { + display: grid; + grid-template-columns: minmax(240px, 0.8fr) minmax(0, 1.8fr); + gap: 16px 32px; + padding: 18px 20px; + margin: 0 0 24px; + border: 1px solid; + border-left: 3px solid colors.color(mitre-blue); + border-radius: 4px; + @include colors.theme-border-color; + dt { + margin-bottom: 6px; + font-size: 12px; + font-weight: 600; + @include colors.theme-text-deemphasis; + } + dd { + margin: 0; + font-size: 12px; + font-family: typography.$mono-font; + overflow-wrap: anywhere; + } + } + .contents-summary { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin: 0 0 24px; + } + .coverage-note { + display: flex; + flex: 1 1 420px; + align-items: flex-start; + gap: 12px; + > .mat-icon { + flex: 0 0 auto; + margin-top: 1px; + @include colors.theme-text-deemphasis; + } + strong { + font-size: 14px; + } + p { + margin: 5px 0 0; + font-size: 13px; + line-height: 1.6; + @include colors.theme-text-deemphasis; + } + .coverage-limit { + max-width: 850px; + } + } + .quarantine-button { + flex: 0 0 auto; + } + .contents-toolbar { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + .search-field { + flex: 1 1 300px; + min-width: 0; + } + .type-field, + .source-field { + flex: 1 1 220px; + min-width: 0; + } + .results-summary { + margin: 12px 0; + font-size: 12px; + @include colors.theme-text-deemphasis; + } + .table-shell { + width: 100%; + overflow-x: auto; + border: 1px solid; + border-radius: 6px; + @include colors.theme-border-color; + } + .contents-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; + font-size: 13px; + th, + td { + padding: 16px; + text-align: left; + vertical-align: top; + } + th { + font-size: 12px; + font-weight: 700; + white-space: nowrap; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.05), + rgba(colors.color(mitre-black), 0.035) + ); + } + td { + border-top: 1px solid; + @include colors.theme-border-color; + } + .object-cell { + width: 46%; + min-width: 285px; + max-width: 520px; + } + .object-cell > strong { + font-size: 14px; + overflow-wrap: anywhere; + } + .object-context { + display: block; + margin-top: 5px; + overflow-wrap: anywhere; + line-height: 1.5; + } + .object-id { + display: block; + margin-top: 7px; + font-family: typography.$mono-font; + font-size: 11px; + overflow-wrap: anywhere; + @include colors.theme-text-deemphasis; + } + .stix-type { + white-space: nowrap; + font-family: typography.$mono-font; + font-size: 11px; + } + .object-role { + display: block; + margin-top: 6px; + font-size: 11px; + line-height: 1.6; + @include colors.theme-text-deemphasis; + } + .object-sources { + display: block; + margin-top: 7px; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + @include colors.theme-text-deemphasis; + } + .revision-cell { + font-family: typography.$mono-font; + font-size: 11px; + white-space: nowrap; + } + .revision-cell small { + display: block; + margin-top: 6px; + @include colors.theme-text-deemphasis; + } + .inspect-cell { + width: 220px; + min-width: 200px; + padding: 8px; + } + .row-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + a, + button { + height: auto; + min-height: 36px; + padding-top: 6px; + padding-bottom: 6px; + text-align: left; + white-space: normal; + } + } + .preview-error { + max-width: 260px; + margin: 8px; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + } + .pointer-only { + display: block; + padding: 8px; + font-size: 11px; + line-height: 1.6; + @include colors.theme-text-deemphasis; + } + .expanded-row { + @include colors.theme-property( + background, + rgba(colors.color(mitre-light-blue), 0.04), + rgba(colors.color(mitre-blue), 0.025) + ); + } + .json-row td { + padding: 0 16px 16px; + border-top: 0; + } + } + .json-header { + display: flex; + flex-wrap: wrap; + gap: 6px 20px; + padding: 12px 0; + font-size: 12px; + span { + @include colors.theme-text-deemphasis; + } + } + pre { + max-height: 420px; + overflow: auto; + box-sizing: border-box; + margin: 0; + padding: 18px; + border: 1px solid; + border-radius: 4px; + font-size: 12px; + line-height: 1.65; + tab-size: 2; + @include colors.theme-border-color; + @include colors.theme-property( + background, + colors.color(mitre-black), + colors.color(mitre-silver) + ); + code { + font-family: typography.$mono-font; + } + } + .mat-mdc-paginator { + background: transparent; + } + .state-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 48px 24px; + text-align: center; + border: 1px solid; + border-radius: 6px; + @include colors.theme-border-color; + h3 { + margin: 4px 0 0; + font-size: 18px; + } + p { + max-width: 640px; + margin: 0 0 8px; + line-height: 1.6; + @include colors.theme-text-deemphasis; + } + > .mat-icon { + width: 32px; + height: 32px; + font-size: 32px; + } + } + .error-panel > .mat-icon { + color: colors.color(error); + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + @media (max-width: 760px) { + .snapshot-header { + gap: 16px; + } + .title-line h2 { + font-size: 22px; + } + .object-count { + min-width: 82px; + padding: 12px; + strong { + font-size: 26px; + } + } + .snapshot-identity { + grid-template-columns: 1fr; + gap: 16px; + } + .contents-toolbar { + flex-direction: column; + gap: 12px; + } + .search-field, + .type-field, + .source-field { + flex-basis: auto; + } + .quarantine-button { + width: 100%; + } + } + @media (max-width: 480px) { + .snapshot-header { + flex-direction: column; + } + .object-count { + display: flex; + align-items: center; + gap: 12px; + span { + margin: 0; + } + } + } +} diff --git a/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.ts b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.ts new file mode 100644 index 000000000..18a9deeec --- /dev/null +++ b/src/app/components/virtual-snapshot-contents/virtual-snapshot-contents.component.ts @@ -0,0 +1,761 @@ +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, + ViewEncapsulation, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterLink } from '@angular/router'; +import { defer, forkJoin, Observable, of, Subject, Subscription } from 'rxjs'; +import { map, switchMap, takeUntil } from 'rxjs/operators'; +import { ExportFormat } from 'src/app/classes/release-tracks'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { Software, StixObject } from 'src/app/classes/stix'; +import { AttackTypeToClass } from 'src/app/utils/class-mappings'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; +import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; +import { environment } from 'src/environments/environment'; +import { + SnapshotQuarantineDialogComponent, + SnapshotQuarantineDialogData, +} from '../snapshot-quarantine-dialog/snapshot-quarantine-dialog.component'; + +interface SnapshotObject { + id: string; + type: string; + modified?: string; + created?: string; + name?: string; + relationship_type?: string; + source_ref?: string; + target_ref?: string; + definition_type?: string; + definition?: Record; + external_references?: { external_id?: string }[]; + [key: string]: unknown; +} + +interface SourceTrack { + track_id: string; + track_name?: string; +} + +interface ManifestEntry { + kind: 'primary' | 'secondary' | 'relationship' | 'supporting' | 'link_target'; + object_ref: string; + object_modified?: string; + stix?: SnapshotObject; + source_tracks?: SourceTrack[]; +} + +interface SnapshotMetadata { + name: string; + version?: string; + content_manifest_id?: string; + composition: { deduplication: { strategy?: string } }; + quarantine: SnapshotQuarantineDialogData['entries']; + content_manifest_entries?: ManifestEntry[]; + linkTargetCount?: number; +} + +interface ContentsRow { + key: string; + id: string; + type: string; + modified?: string; + object?: SnapshotObject; + name: string; + context: string; + roles: string[]; + sourceTracks: SourceTrack[]; + searchText: string; + libraryRoute?: string[]; + previewLoading: boolean; + previewError: string; +} + +@Component({ + selector: 'app-virtual-snapshot-contents', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatSelectModule, + RouterLink, + ], + templateUrl: './virtual-snapshot-contents.component.html', + styleUrls: ['./virtual-snapshot-contents.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class VirtualSnapshotContentsComponent implements OnChanges, OnDestroy { + @Input() public trackId = ''; + @Input() public modified = ''; + @Input() public title = ''; + @Input() public isLatest = false; + @Input() public canResolve = false; + @Output() public back = new EventEmitter(); + @Output() public resolved = new EventEmitter(); + + public loading = false; + public error = ''; + public workbench: SnapshotMetadata | null = null; + public exportedCount = 0; + public rows: ContentsRow[] = []; + public filteredRows: ContentsRow[] = []; + public pageRows: ContentsRow[] = []; + public types: string[] = []; + public query = ''; + public selectedType = ''; + public selectedSourceTrack = ''; + public sourceTracks: SourceTrack[] = []; + public sourceTrackLabels = new Map(); + public unrecordedSourceCount = 0; + public pageIndex = 0; + public pageSize = 25; + public expandedKey: string | null = null; + public expandedJson = ''; + private loadSubscription?: Subscription; + private previewSubscription?: Subscription; + private previewRow?: ContentsRow; + private previewDialog?: MatDialogRef; + private quarantineDialog?: MatDialogRef< + SnapshotQuarantineDialogComponent, + boolean + >; + private readonly destroyed = new Subject(); + + constructor( + private readonly releaseTracks: ReleaseTracksConnectorService, + private readonly dialog: MatDialog, + private readonly http: HttpClient + ) {} + + public ngOnChanges(changes: SimpleChanges): void { + if (changes.trackId || changes.modified) this.load(); + } + + public ngOnDestroy(): void { + this.loadSubscription?.unsubscribe(); + this.cancelPreview(); + this.destroyed.next(); + this.destroyed.complete(); + this.quarantineDialog?.close(); + } + + public get showQuarantine(): boolean { + return ( + this.workbench?.composition?.deduplication?.strategy === 'quarantine' && + Array.isArray(this.workbench?.quarantine) && + this.workbench.quarantine.length > 0 + ); + } + + public load(): void { + this.loadSubscription?.unsubscribe(); + this.cancelPreview(); + this.quarantineDialog?.close(); + this.workbench = null; + this.rows = []; + this.types = []; + this.query = ''; + this.selectedType = ''; + this.selectedSourceTrack = ''; + this.sourceTracks = []; + this.sourceTrackLabels.clear(); + this.unrecordedSourceCount = 0; + this.pageSize = 25; + this.error = ''; + this.applyFilters(); + if (!this.trackId || !this.modified) { + this.loading = false; + this.error = + 'This snapshot is missing its exact track or revision identifier. Return to Releases and select a snapshot.'; + return; + } + + this.loading = true; + this.loadSubscription = forkJoin({ + workbench: this.releaseTracks.exportSnapshotByModified( + this.trackId, + this.modified, + ExportFormat.Workbench + ), + bundle: this.releaseTracks.exportSnapshotByModified( + this.trackId, + this.modified, + ExportFormat.Bundle, + { stixVersion: '2.1' } + ), + }) + .pipe( + map( + ({ workbench, bundle }: { workbench: unknown; bundle: unknown }) => { + if ( + !this.isRecord(bundle) || + !Array.isArray(bundle.objects) || + !bundle.objects.every(object => this.isSnapshotObject(object)) + ) { + throw new Error( + 'The snapshot export did not contain a valid contents bundle.' + ); + } + return { + workbench: this.parseMetadata(workbench), + objects: bundle.objects, + }; + } + ) + ) + .subscribe({ + next: ({ workbench, objects }) => { + this.workbench = workbench; + this.rows = this.createRows( + objects, + workbench.content_manifest_entries + ); + this.types = [...new Set(this.rows.map(row => row.type))].sort(); + this.prepareSourceTracks(); + this.applyFilters(); + this.loading = false; + }, + error: () => { + this.loading = false; + this.error = + 'We could not load this exact snapshot. It may no longer be available, or the service could not export its manifest. Retry or return to Releases to choose another snapshot.'; + }, + }); + } + + public applyFilters(): void { + const terms = this.query + .trim() + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean); + this.filteredRows = this.rows.filter( + row => + (!this.selectedType || row.type === this.selectedType) && + (!this.selectedSourceTrack || + (this.selectedSourceTrack === 'unrecorded' + ? row.sourceTracks.length === 0 + : row.sourceTracks.some( + track => `track:${track.track_id}` === this.selectedSourceTrack + ))) && + terms.every(term => row.searchText.includes(term)) + ); + this.pageIndex = 0; + this.updatePage(); + } + + public clearFilters(): void { + this.query = ''; + this.selectedType = ''; + this.selectedSourceTrack = ''; + this.applyFilters(); + } + + public changePage(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.updatePage(); + } + + public toggleJson(row: ContentsRow): void { + if (!row.object) return; + const opening = this.expandedKey !== row.key; + this.expandedKey = opening ? row.key : null; + this.expandedJson = opening ? JSON.stringify(row.object, null, 2) : ''; + } + + public preview(row: ContentsRow): void { + this.cancelPreview(); + this.previewRow = row; + row.previewLoading = true; + row.previewError = ''; + this.previewSubscription = this.exactObject(row) + .pipe( + switchMap(stix => { + if (stix.type !== 'relationship') return of({ stix }); + return forkJoin({ + source_object: this.snapshotEndpoint(stix.source_ref), + target_object: this.snapshotEndpoint(stix.target_ref), + }).pipe(map(endpoints => ({ stix, ...endpoints }))); + }), + map(raw => { + const attackType = StixTypeToAttackType[raw.stix.type]; + const ObjectClass = AttackTypeToClass[attackType] as new (value: { + stix: SnapshotObject; + }) => StixObject; + if (!ObjectClass || attackType === 'note') + throw new Error('Preview is unavailable for this STIX type.'); + const object = + raw.stix.type === 'malware' || raw.stix.type === 'tool' + ? new Software(raw.stix.type, raw) + : new ObjectClass(raw); + return this.dialog.open(StixDialogComponent, { + data: { + object, + mode: 'view', + editable: false, + sidebarControl: 'disable', + }, + width: '50em', + maxWidth: '96vw', + maxHeight: '85vh', + autoFocus: false, + ariaLabel: `${row.name}, exact snapshot revision ${row.modified || 'unversioned'}`, + }); + }) + ) + .subscribe({ + next: dialog => { + this.previewDialog = dialog; + row.previewLoading = false; + }, + error: error => { + row.previewLoading = false; + row.previewError = + error instanceof Error + ? `Could not preview this exact snapshot object. ${error.message}` + : 'Could not preview this exact snapshot object. Try Preview again.'; + }, + }); + } + + private cancelPreview(): void { + this.previewSubscription?.unsubscribe(); + if (this.previewRow) this.previewRow.previewLoading = false; + this.previewRow = undefined; + this.previewDialog?.close(); + this.previewDialog = undefined; + } + + private exactObject(row: ContentsRow): Observable { + return defer(() => { + const matches = (object: unknown): object is SnapshotObject => + this.isSnapshotObject(object) && + object.id === row.id && + object.type === row.type && + object.modified === row.modified; + if (row.object) { + if (!matches(row.object)) + throw new Error( + 'The supplied payload does not match its manifest reference.' + ); + return of(row.object); + } + const attackType = StixTypeToAttackType[row.type]; + if (!attackType || attackType === 'note') + throw new Error( + 'Exact revision retrieval is unavailable for this STIX type.' + ); + if (!row.modified && row.type !== 'marking-definition') + throw new Error('The manifest does not identify an exact revision.'); + const url = + `${environment.integrations.rest_api.url}/${AttackTypeToPlural[attackType]}` + + `/${encodeURIComponent(row.id)}` + + (row.modified ? `/modified/${encodeURIComponent(row.modified)}` : ''); + return this.http.get(url).pipe( + map(response => { + const candidates = Array.isArray(response) ? response : [response]; + const exact = candidates + .map(candidate => + this.isRecord(candidate) ? candidate.stix : undefined + ) + .find(matches); + if (!exact) + throw new Error( + 'The server did not return the requested exact revision. No current revision was substituted.' + ); + row.object = exact; + return exact; + }) + ); + }); + } + + private snapshotEndpoint( + id: string | undefined + ): Observable<{ stix: SnapshotObject }> { + return defer(() => { + const candidates = this.rows.filter(row => row.id === id); + if (candidates.length !== 1) + throw new Error( + 'This relationship endpoint is missing or ambiguous in the snapshot manifest.' + ); + return this.exactObject(candidates[0]).pipe(map(stix => ({ stix }))); + }); + } + + public trackRow(_index: number, row: ContentsRow): string { + return row.key; + } + + public openQuarantine(): void { + if (!this.workbench || !this.showQuarantine || this.quarantineDialog) + return; + const snapshotKey = `${this.trackId}|${this.modified}`; + const data: SnapshotQuarantineDialogData = { + trackId: this.trackId, + modified: this.modified, + title: this.title || this.workbench.name, + entries: this.workbench.quarantine, + canResolve: this.canResolve && this.isLatest && !this.workbench.version, + }; + this.quarantineDialog = this.dialog.open( + SnapshotQuarantineDialogComponent, + { + data, + width: '1000px', + maxWidth: '96vw', + maxHeight: '90vh', + autoFocus: 'first-heading', + ariaLabel: 'Quarantined snapshot objects', + } + ); + this.quarantineDialog + .afterClosed() + .pipe(takeUntil(this.destroyed)) + .subscribe(result => { + this.quarantineDialog = undefined; + if ( + result === true && + snapshotKey === `${this.trackId}|${this.modified}` + ) { + this.resolved.emit(); + } + }); + } + + private updatePage(): void { + const start = this.pageIndex * this.pageSize; + this.pageRows = this.filteredRows.slice(start, start + this.pageSize); + this.expandedKey = null; + this.expandedJson = ''; + } + + private createRows( + objects: SnapshotObject[], + entries?: ManifestEntry[] + ): ContentsRow[] { + const unique = new Map(); + const names = new Map( + objects.map(object => [object.id, object.name || object.id]) + ); + const add = ( + id: string, + modified: string | undefined, + object: SnapshotObject | undefined, + role: string, + sourceTracks: SourceTrack[] = [] + ): void => { + const key = `${id}|${modified || ''}`; + const existing = unique.get(key); + if (existing) { + if (!existing.roles.includes(role)) existing.roles.push(role); + if (!existing.object && object) existing.object = object; + this.mergeSourceTracks(existing.sourceTracks, sourceTracks); + return; + } + const type = object?.type || id.split('--')[0]; + let name = object?.name || type; + let context = ''; + if (type === 'relationship' && object) { + name = object.name || object.relationship_type || 'Relationship'; + context = `${names.get(object.source_ref || '') || object.source_ref || 'Unknown source'} → ${names.get(object.target_ref || '') || object.target_ref || 'Unknown target'}`; + } else if (type === 'marking-definition' && object) { + name = + object.name || + (object.definition_type + ? `${object.definition_type.toUpperCase()} marking` + : 'Marking definition'); + context = Object.values(object.definition || {}) + .map(value => + typeof value === 'string' ? value : JSON.stringify(value) + ) + .join(' · '); + } + const externalIds = (object?.external_references || []) + .map(reference => reference.external_id || '') + .join(' '); + const attackType = StixTypeToAttackType[type]; + const libraryRoute = + attackType === 'collection' + ? modified + ? ['/', 'collection', id, 'modified', modified] + : undefined + : attackType && attackType !== 'note' + ? ['/', attackType, id] + : undefined; + unique.set(key, { + key, + id, + type, + modified, + object, + libraryRoute, + previewLoading: false, + previewError: '', + name, + context, + roles: [role], + sourceTracks: this.mergeSourceTracks([], sourceTracks), + searchText: [ + name, + context, + id, + type, + modified, + object?.created, + object?.source_ref, + object?.target_ref, + externalIds, + ] + .join(' ') + .toLocaleLowerCase(), + }); + }; + objects.forEach((object, index) => { + // STIX 2.1 exports prepend one generated collection projection. It is + // not a manifest member; do not discard any other supporting objects. + if (index === 0 && object.type === 'x-mitre-collection') return; + add(object.id, object.modified, object, 'Exported'); + }); + this.exportedCount = unique.size; + const labels: Record = { + primary: 'Primary', + secondary: 'Secondary', + relationship: 'Relationship', + supporting: 'Supporting', + link_target: 'Render-only dependency', + }; + entries?.forEach(entry => { + add( + entry.object_ref, + entry.object_modified || entry.stix?.modified, + entry.stix, + labels[entry.kind], + entry.source_tracks + ); + }); + return [...unique.values()]; + } + + private mergeSourceTracks( + target: SourceTrack[], + sources: SourceTrack[] + ): SourceTrack[] { + for (const source of sources) { + const existing = target.find(track => track.track_id === source.track_id); + if (!existing) target.push({ ...source }); + else if (!existing.track_name && source.track_name) + existing.track_name = source.track_name; + } + return target; + } + + private prepareSourceTracks(): void { + const tracks: SourceTrack[] = []; + for (const row of this.rows) + this.mergeSourceTracks(tracks, row.sourceTracks); + const nameCounts = new Map(); + for (const track of tracks) { + const name = track.track_name || track.track_id; + nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + } + for (const track of tracks) { + const name = track.track_name || track.track_id; + this.sourceTrackLabels.set( + track.track_id, + (nameCounts.get(name) || 0) > 1 ? `${name} (${track.track_id})` : name + ); + } + this.sourceTracks = tracks.sort((left, right) => + this.sourceTrackLabels + .get(left.track_id)! + .localeCompare(this.sourceTrackLabels.get(right.track_id)!) + ); + this.unrecordedSourceCount = this.rows.filter( + row => row.sourceTracks.length === 0 + ).length; + } + + private isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + + private isSnapshotObject(value: unknown): value is SnapshotObject { + if ( + !this.isRecord(value) || + typeof value.id !== 'string' || + typeof value.type !== 'string' + ) + return false; + if ( + ![ + 'modified', + 'created', + 'name', + 'relationship_type', + 'source_ref', + 'target_ref', + 'definition_type', + ].every(key => value[key] === undefined || typeof value[key] === 'string') + ) + return false; + if (value.definition !== undefined && !this.isRecord(value.definition)) + return false; + return ( + value.external_references === undefined || + (Array.isArray(value.external_references) && + value.external_references.every( + reference => + this.isRecord(reference) && + (reference.external_id === undefined || + typeof reference.external_id === 'string') + )) + ); + } + + private parseMetadata(value: unknown): SnapshotMetadata { + if (!this.isRecord(value)) throw new Error('Invalid snapshot metadata.'); + const composition = this.isRecord(value.composition) + ? value.composition + : {}; + const deduplication = this.isRecord(composition.deduplication) + ? composition.deduplication + : {}; + const statistics = this.isRecord(value.content_statistics) + ? value.content_statistics + : {}; + const quarantine = Array.isArray(value.quarantine) ? value.quarantine : []; + const metadata: SnapshotMetadata = { + name: typeof value.name === 'string' ? value.name : 'Snapshot', + version: typeof value.version === 'string' ? value.version : undefined, + content_manifest_id: + typeof value.content_manifest_id === 'string' + ? value.content_manifest_id + : undefined, + composition: { + deduplication: { + strategy: + typeof deduplication.strategy === 'string' + ? deduplication.strategy + : undefined, + }, + }, + quarantine: quarantine.map((entry: unknown) => { + if ( + !this.isRecord(entry) || + typeof entry.object_ref !== 'string' || + typeof entry.object_modified !== 'string' + ) { + throw new Error('Invalid quarantine entry.'); + } + return { + object_ref: entry.object_ref, + object_modified: entry.object_modified, + source_track_id: + typeof entry.source_track_id === 'string' + ? entry.source_track_id + : '', + source_track_name: + typeof entry.source_track_name === 'string' + ? entry.source_track_name + : '', + source_snapshot_version: + typeof entry.source_snapshot_version === 'string' + ? entry.source_snapshot_version + : undefined, + conflict_reason: + typeof entry.conflict_reason === 'string' + ? entry.conflict_reason + : '', + name: typeof entry.name === 'string' ? entry.name : undefined, + attack_id: + typeof entry.attack_id === 'string' ? entry.attack_id : undefined, + }; + }), + linkTargetCount: + typeof statistics.link_target_count === 'number' + ? statistics.link_target_count + : undefined, + }; + // Optional preview contract: production exports currently omit manifest pointers. + if (value.content_manifest_entries !== undefined) { + if (!Array.isArray(value.content_manifest_entries)) + throw new Error('Invalid manifest entries.'); + metadata.content_manifest_entries = value.content_manifest_entries.map( + (entry: unknown) => { + if ( + !this.isRecord(entry) || + typeof entry.object_ref !== 'string' || + (entry.object_modified !== undefined && + typeof entry.object_modified !== 'string') || + (entry.stix !== undefined && !this.isSnapshotObject(entry.stix)) + ) { + throw new Error('Invalid manifest entry.'); + } + const kind = entry.kind; + if ( + kind !== 'primary' && + kind !== 'secondary' && + kind !== 'relationship' && + kind !== 'supporting' && + kind !== 'link_target' + ) + throw new Error('Invalid manifest role.'); + if ( + entry.source_tracks !== undefined && + (!Array.isArray(entry.source_tracks) || + !entry.source_tracks.every( + source => + this.isRecord(source) && + typeof source.track_id === 'string' && + source.track_id.length > 0 && + (source.track_name === undefined || + typeof source.track_name === 'string') + )) + ) + throw new Error('Invalid manifest source tracks.'); + return { + kind, + object_ref: entry.object_ref, + object_modified: entry.object_modified as string | undefined, + stix: entry.stix as SnapshotObject | undefined, + source_tracks: entry.source_tracks as SourceTrack[] | undefined, + }; + } + ); + } + return metadata; + } +} 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 76a178793..e5473f4d4 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 @@ -20,7 +20,7 @@

{{ releaseTrackName }}

- + - - -
-
-
-
-
-

Description

- -
- -

- {{ releaseTrackDescription || 'No description provided.' }} -

- -
- - - -
- - -
-
-
- -
-
- - {{ virtualComponentTracks.length }} - -
- Components - standard tracks -
-
- -
- - {{ members.length }} - -
- Members - resolved objects -
-
- -
- - {{ quarantineObjects.length }} - -
- Quarantined - resolution conflicts -
-
-
-
-
- -
-
-
-

- -

- - Resolved at: - {{ virtualResolvedAt | date: 'MMM d, y, h:mm:ss a' }} - -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Component TrackStrategyResolved VersionCandidatesStagedMembers
- No component tracks configured. -
- {{ row.trackName }} - {{ row.trackId }} - {{ formatConfigOption(row.strategy) }} - - {{ getVirtualResolvedVersion(row) }} - - - {{ - getVirtualTierCount(row.candidatesCount) - }} - - {{ - getVirtualTierCount(row.stagedCount) - }} - - {{ - getVirtualTierCount(row.membersCount) - }} -
-
-
-
- -
-
-
-
-

Members ({{ members.length || 0 }})

-
- - - -
-
- No member objects. -
- -
-
-

{{ getVirtualObjectTitle(item) }}

-

{{ getVirtualObjectSubtitle(item) }}

-

- Revision: - {{ item.object_modified | date: 'MMM d, y, h:mm:ss a' }} -

-
- -
-
-
-
- -
-
-
-

- Quarantine ({{ quarantineObjects.length || 0 }}) -

-
- - - -
-
- No quarantined objects. -
- -
-
-

{{ getVirtualObjectTitle(item) }}

-

{{ getVirtualObjectSubtitle(item) }}

-

- Source: - {{ item.source_track_name || item.source_track_id }} - - ({{ getVirtualSourceVersion(item) }}) - -

-

- {{ item.conflict_reason }} -

-
- -
-
-
-
-
-
-
@@ -815,6 +378,64 @@

{{ getVirtualObjectTitle(item) }}

+
+
+ +
+

Virtual snapshots

+

+ {{ + releaseTrackDescription || + 'A release track composed from component snapshots.' + }} +

+ +
+ + + +
+ + +
+
+
+
+

+ Open a snapshot to explore its complete graph: objects, relationships, + and supporting content. Each snapshot preserves its own graph + selection. +

+

+ This configuration uses the retired members-only latest draft rule. + Edit Config and explicitly choose Latest preview or Latest tagged for + each retired rule before materializing again. Existing snapshots are + unchanged. +

+
@@ -950,6 +571,15 @@

+
-
+
@@ -1075,119 +705,126 @@

-
-
-
- - Composition Resolution + class="snapshot-provenance-disclosure"> + Component provenance +
+
+
+ + Composition Resolution +
+ + Resolved: + {{ + item.compositionResolvedAt | date: 'MMM d, y, h:mm:ss a' + }} +
- - Resolved: - {{ item.compositionResolvedAt | date: 'MMM d, y, h:mm:ss a' }} - -
- -
- This snapshot has not been materialized, so it has no component - snapshot provenance yet. -
-
- No component snapshots were recorded. + This snapshot has not been materialized, so it has no + component snapshot provenance yet.
-
-
- - - {{ getVirtualResolvedVersion(row) }} - -
- -
-
-
-
Snapshot created
-
- +
+
+ No component snapshots were recorded. +
+ +
+
+ + + {{ getVirtualResolvedVersion(row) }} + +
+ +
+
+
+
Snapshot created
+
+ + {{ + row.resolvedSnapshotId + | date: 'MMM d, y, h:mm:ss a' + }} + + - + + {{ row.resolvedSnapshotId }} + +
+
+
+
Resolution strategy
+
+ {{ + formatConfigOption(row.strategy) + }} + + Filters: {{ row.filters?.join(', ') }} + +
+
+
+ +
+
+
{{ - row.resolvedSnapshotId - | date: 'MMM d, y, h:mm:ss a' + row.strategy === ResolutionStrategy.LatestPreview + ? 'Preview members' + : 'Source members' }} - - - - - {{ row.resolvedSnapshotId }} - -
-
-
-
Resolution strategy
-
- {{ - formatConfigOption(row.strategy) - }} - - Filters: {{ row.filters?.join(', ') }} - -
-
-
- -
-
-
- {{ - row.strategy === ResolutionStrategy.LatestPreview - ? 'Preview members' - : 'Source members' - }} -
-
- {{ getVirtualTierCount(row.totalObjectsInSource) }} -
-
-
-
After filters
-
- {{ getVirtualTierCount(row.objectsAfterFilter) }} -
-
-
-
Contributed
-
- {{ getVirtualTierCount(row.objectsContributed) }} -
-
-
-
-
-
-
+ +
+ {{ getVirtualTierCount(row.totalObjectsInSource) }} +
+
+
+
After filters
+
+ {{ getVirtualTierCount(row.objectsAfterFilter) }} +
+
+
+
Contributed
+
+ {{ getVirtualTierCount(row.objectsContributed) }} +
+
+ + + + + +
mat-icon { + color: colors.color(secondary); + margin-top: 3px; + } + } + + h2 { + margin: 0 0 8px; + font-size: 22px; + } + + p { + margin: 0 0 12px; + line-height: 1.6; + } + } + + .snapshot-provenance-disclosure { + summary { + padding: 16px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + &:focus-visible { + outline: 2px solid colors.color(secondary); + outline-offset: -3px; + } + } + } + + @media (max-width: 700px) { + .virtual-releases-intro { + padding-left: 0; + } + } + .config-tpl { max-width: 768px; margin: 24px auto 0; @@ -1056,17 +1103,6 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); color: color.mix(colors.color(spark-yellow), colors.color(warn), 72%); } - .virtual-track-layout { - display: flex; - flex-direction: column; - gap: 16px; - height: calc(100dvh - 148px); - min-height: 0; - } - - .virtual-object-panel, - .virtual-component-row, - .virtual-stat, .config-component-row, .empty-state { .dark & { @@ -1078,120 +1114,6 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } - .labelled-box.virtual-resolution-panel > .content, - .labelled-box.virtual-object-panel > .content { - display: flex; - flex-direction: column; - align-items: stretch; - min-height: 0; - padding: 14px 12px 16px; - overflow: hidden; - } - - .virtual-resolution-panel { - flex: 0 0 auto; - min-width: 0; - - &.collapsed > .content .virtual-resolution-header { - margin-bottom: 0; - } - } - - .virtual-resolution-header { - flex-wrap: wrap; - gap: 8px 24px; - } - - .virtual-resolution-toggle { - display: flex; - align-items: center; - gap: 8px; - border: 0; - padding: 0; - color: inherit; - background: transparent; - font: inherit; - text-align: left; - cursor: pointer; - - .mat-icon { - flex: 0 0 auto; - } - - &:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } - } - - .virtual-resolution-table { - width: 100%; - min-width: 0; - overflow-x: auto; - - table { - width: 100%; - min-width: 960px; - table-layout: fixed; - border-collapse: collapse; - font-size: 14px; - line-height: 20px; - } - - .virtual-resolution-component-col { - width: 34%; - } - - .virtual-resolution-strategy-col, - .virtual-resolution-version-col { - width: 18%; - } - - .virtual-resolution-count-col { - width: 10%; - } - - th, - td { - padding: 12px; - text-align: left; - vertical-align: middle; - overflow-wrap: anywhere; - } - - th:nth-child(n + 4), - td:nth-child(n + 4) { - text-align: right; - font-variant-numeric: tabular-nums; - } - - .component-track-id { - display: block; - color: colors.on-color-deemphasis(light); - - :host-context(.dark) & { - color: colors.on-color-deemphasis(dark); - } - } - - tbody tr { - border-top: 1px solid colors.border-color(light); - - :host-context(.dark) & { - border-color: colors.border-color(dark); - } - } - - .clickable-row:focus-visible { - outline-offset: -2px; - } - - .virtual-empty-cell { - padding: 24px 12px; - text-align: center; - } - } - .snapshot-composition-components { display: grid; } @@ -1393,86 +1315,6 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } - .virtual-object-panels { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 16px; - flex: 1 1 auto; - min-height: 0; - } - - .virtual-object-panel { - min-height: 0; - } - - .virtual-object-list { - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 12px; - min-height: 0; - padding-top: 12px; - overflow-y: auto; - padding-right: 4px; - scrollbar-gutter: stable; - } - - .virtual-object-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - border: 1px solid; - border-radius: 4px; - padding: 12px; - - .dark & { - border-color: colors.border-color(dark); - background: rgba(colors.color(mitre-silver), 0.025); - } - - .light & { - border-color: colors.border-color(light); - background: rgba(colors.color(mitre-black), 0.014); - } - - h4 { - margin: 0; - font-size: 15px; - font-weight: 800; - line-height: 20px; - } - - p { - margin: 4px 0 0; - font-size: 12px; - line-height: 16px; - - .dark & { - color: colors.on-color-deemphasis(dark); - } - - .light & { - color: colors.on-color-deemphasis(light); - } - } - - button { - flex: 0 0 auto; - height: 32px; - min-width: 0; - padding: 0 10px; - - .mat-icon { - width: 18px; - height: 18px; - margin-left: 4px; - font-size: 18px; - line-height: 18px; - } - } - } - .release-track-board { display: flex; flex-direction: column; @@ -1666,143 +1508,6 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); min-height: 0; } - .virtual-track-workspace { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.15fr) minmax( - 280px, - 0.7fr - ); - gap: 16px; - flex: 1 1 auto; - min-height: 0; - } - - .virtual-track-card { - min-height: 0; - - > .content { - display: flex; - flex-direction: column; - min-height: 0; - height: 100%; - padding: 14px 12px 16px; - } - } - - .virtual-card-body { - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; - padding-top: 12px; - overflow-y: auto; - scrollbar-gutter: stable; - } - - .virtual-component-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - border: 1px solid; - border-radius: 4px; - padding: 12px; - - > div:first-child { - flex: 1 1 auto; - min-width: 0; - } - - .light & { - border-color: colors.border-color(light); - background: rgba(colors.color(mitre-black), 0.014); - } - - .dark & { - border-color: colors.border-color(dark); - background: rgba(colors.color(mitre-silver), 0.025); - } - - h4 { - margin: 0; - font-size: 15px; - line-height: 20px; - } - - p { - margin: 4px 0 0; - overflow-wrap: anywhere; - font-size: 12px; - line-height: 16px; - - .light & { - color: colors.on-color-deemphasis(light); - } - - .dark & { - color: colors.on-color-deemphasis(dark); - } - } - } - - .virtual-component-meta { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; - max-width: 36%; - } - - .virtual-resolution-stats { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - overflow: visible; - } - - .virtual-stat { - border: 1px solid; - border-radius: 4px; - padding: 12px; - text-align: center; - - .light & { - border-color: colors.border-color(light); - background: rgba(colors.color(mitre-black), 0.014); - } - - .dark & { - border-color: colors.border-color(dark); - background: rgba(colors.color(mitre-silver), 0.025); - } - - strong { - display: block; - font-size: 28px; - font-weight: 800; - line-height: 32px; - } - - span { - display: block; - margin-top: 2px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - line-height: 16px; - text-transform: uppercase; - - .light & { - color: colors.on-color-deemphasis(light); - } - - .dark & { - color: colors.on-color-deemphasis(dark); - } - } - } - .release-column { display: flex; flex: 1 1 0; @@ -2015,8 +1720,7 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } @media (max-width: 960px) { - .release-track-board, - .virtual-track-layout { + .release-track-board { height: auto; } @@ -2028,14 +1732,6 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); flex-basis: calc(50% - 8px); } - .virtual-track-workspace { - grid-template-columns: 1fr; - } - - .virtual-object-panels { - grid-template-columns: 1fr; - } - .snapshot-component-body { grid-template-columns: 1fr; } 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 38b9650e1..ba5ebe5d5 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 @@ -174,6 +174,7 @@ describe('ReleaseTrackPageComponent', () => { }; mockRouter = { navigate: vi.fn(), + events: of(), }; mockAuthenticationService = { canEdit: vi.fn(() => true), @@ -348,9 +349,7 @@ describe('ReleaseTrackPageComponent', () => { component.onHistoryFilterChange(filter); fixture.detectChanges(); const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs')); - const view = tabs.properties[ - 'customTabs' - ][0].template.createEmbeddedView({}); + const view = tabs.properties['detailsTemplate'].createEmbeddedView({}); try { view.detectChanges(); const root = view.rootNodes.find( @@ -391,7 +390,7 @@ describe('ReleaseTrackPageComponent', () => { mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of(historyResponse([release])) ); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); expect(component.snapshotHistory[0].isLatestRelease).toBe(true); mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of(historyResponse([draft, release])) @@ -449,10 +448,11 @@ describe('ReleaseTrackPageComponent', () => { }); it('refreshes on visible tab entry and focus, and pauses for hidden documents and other tabs', () => { + component.onSelectedTabChange(1); vi.advanceTimersByTime(30_000); window.dispatchEvent(new Event('focus')); expect(mockReleaseTrackApiConnector.listSnapshots).not.toHaveBeenCalled(); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); window.dispatchEvent(new Event('focus')); expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes( 2 @@ -468,7 +468,7 @@ describe('ReleaseTrackPageComponent', () => { expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes( 3 ); - component.onSelectedTabChange(2); + component.onSelectedTabChange(1); vi.advanceTimersByTime(30_000); expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes( 3 @@ -476,7 +476,7 @@ describe('ReleaseTrackPageComponent', () => { }); it('pauses edits, dialogs and mutations and resumes without overlapping history or cleanup requests', () => { - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); mockReleaseTrackApiConnector.listSnapshots.mockClear(); for (const field of [ 'isEditingConfig', @@ -529,7 +529,7 @@ describe('ReleaseTrackPageComponent', () => { mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of(historyResponse([draft, release])) ); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); mockDialog.open.mockReturnValue({ afterClosed: () => of(true) }); mockReleaseTrackApiConnector.deleteSnapshotByModified.mockReturnValue( deletion @@ -566,7 +566,7 @@ describe('ReleaseTrackPageComponent', () => { mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of(historyResponse([release])) ); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); const pending = new Subject(); mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(pending); vi.advanceTimersByTime(30_000); @@ -595,16 +595,14 @@ describe('ReleaseTrackPageComponent', () => { component.onHistoryPageChange({ pageIndex: 2, pageSize: 25, length: 80 }); fixture.detectChanges(); const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs')); - const view = tabs.properties['customTabs'][0].template.createEmbeddedView( - {} - ); + const view = tabs.properties['detailsTemplate'].createEmbeddedView({}); try { view.detectChanges(); const root = view.rootNodes.find( (node: Node) => node instanceof HTMLElement ); const card = root.querySelector('.snapshot-history-entry'); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); vi.advanceTimersByTime(30_000); view.detectChanges(); expect(root.querySelector('.snapshot-history-entry')).toBe(card); @@ -641,7 +639,7 @@ describe('ReleaseTrackPageComponent', () => { it('cancels stale filters, routes and destroyed requests and removes timer and focus listeners', () => { const stale = new Subject(); mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(stale); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); const filtered = new Subject(); mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(filtered); component.onHistoryFilterChange('releases'); @@ -681,7 +679,7 @@ describe('ReleaseTrackPageComponent', () => { expect(component.snapshotHistory).toEqual([]); const pending = new Subject(); mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(pending); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); fixture.destroy(); const calls = mockReleaseTrackApiConnector.listSnapshots.mock.calls.length; @@ -708,7 +706,7 @@ describe('ReleaseTrackPageComponent', () => { mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue( of({ data: [completed] }) ); - component.onSelectedTabChange(1); + component.onSelectedTabChange(0); component.dismissCleanupNotification(); vi.advanceTimersByTime(60_000); expect(mockSnackbar.openFromTemplate).toHaveBeenCalledTimes(1); @@ -1195,6 +1193,10 @@ describe('ReleaseTrackPageComponent', () => { check: (header: HTMLElement) => void ) { component.id = 'release-track--header'; + component.releaseTrack = new ReleaseTrackSnapshot({ + id: component.id, + ...snapshot, + }); mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of(historyResponse([snapshot])) ); @@ -1214,36 +1216,6 @@ describe('ReleaseTrackPageComponent', () => { } } - it('keeps Latest beside the title, controls below metadata, and only real draft notes', () => { - withSnapshotHeader( - { - modified: '2026-09-09T23:08:38.000Z', - version: null, - snapshot_description: 'Review these candidates before tagging.', - }, - header => { - expect(header.textContent).not.toContain('Draft release snapshot'); - expect( - header.querySelector( - '.snapshot-history-title-row > app-workbench-chip' - )?.textContent - ).toContain('Latest'); - expect( - header.querySelector('.snapshot-history-chips')?.textContent - ).toContain('Draft Release'); - expect( - header.querySelector('.snapshot-history-description')?.textContent - ).toContain('Review these candidates before tagging.'); - expect(header.querySelector('.snapshot-history-dates')).toBeNull(); - expect( - header.lastElementChild?.classList.contains( - 'snapshot-history-actions' - ) - ).toBe(true); - } - ); - }); - it('renders snapshot and tagging timestamps as distinct metadata pills', () => { const modified = '2026-09-09T23:08:38.000Z'; withSnapshotHeader( @@ -2219,9 +2191,7 @@ describe('ReleaseTrackPageComponent', () => { ).toBe('latest_draft'); fixture.detectChanges(); const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs')); - const view = tabs.properties['customTabs'][0].template.createEmbeddedView( - {} - ); + const view = tabs.properties['detailsTemplate'].createEmbeddedView({}); try { view.detectChanges(); const root: HTMLElement = view.rootNodes.find( @@ -2289,9 +2259,7 @@ describe('ReleaseTrackPageComponent', () => { component.getSnapshotHistory(); fixture.detectChanges(); const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs')); - const view = tabs.properties['customTabs'][0].template.createEmbeddedView( - {} - ); + const view = tabs.properties['detailsTemplate'].createEmbeddedView({}); try { view.detectChanges(); const root: HTMLElement = view.rootNodes.find( 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 f60bd5ef6..7648a9551 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 @@ -13,7 +13,7 @@ import { ErrorStateMatcher } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { PageEvent } from '@angular/material/paginator'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { forkJoin, fromEvent, @@ -202,12 +202,7 @@ interface VirtualReleaseTrackConfigFormValue { } type VirtualCronCadence = - | 'interval' - | 'hourly' - | 'daily' - | 'weekly' - | 'monthly' - | 'yearly'; + 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly'; interface SchedulePreset { label: string; @@ -300,7 +295,6 @@ export class ReleaseTrackPageComponent implements OnInit, OnDestroy { public id = ''; public releaseTrack: ReleaseTrackSnapshot | null = null; public showReleasedMembers = false; - public showCompositionResolution = false; public descriptionDraft = ''; public isEditingDescription = false; public isSavingDescription = false; @@ -346,6 +340,7 @@ export class ReleaseTrackPageComponent implements OnInit, OnDestroy { private announcedCleanup = new Set(); @ViewChild('cleanupNotification', { static: true }) public cleanupNotification!: TemplateRef; + public selectedSnapshotModified: string | null = null; public configForm: FormGroup; private virtualComponentTrackSummaries = new Map< string, @@ -522,12 +517,24 @@ export class ReleaseTrackPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe(params => { + this.selectedSnapshotModified = params.snapshot || null; + if (!this.selectedSnapshotModified) this.selectedTabIndex = 0; + }); + this.router.events.pipe(takeUntil(this.destroy$)).subscribe(event => { + if (event instanceof NavigationEnd && this.releaseTrack) { + this.breadcrumbService.changeBreadcrumb( + this.route.snapshot, + this.releaseTrackName + ); + } + }); this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => { if (this.id !== params.id) { this.cancelRequests$.next(); this.cancelHistory$.next(); this.showReleasedMembers = false; - this.showCompositionResolution = false; + this.selectedTabIndex = 0; this.historyFilter = 'all'; this.historyPageIndex = 0; this.historyTotal = 0; @@ -584,9 +591,40 @@ export class ReleaseTrackPageComponent implements OnInit, OnDestroy { this.snackbar.dismiss(); } + public get selectedSnapshot(): SnapshotHistoryViewModel | undefined { + return this.snapshotHistory.find( + item => item.modified === this.selectedSnapshotModified + ); + } + + public onOpenSnapshot(item: SnapshotHistoryViewModel): void { + if (!this.isVirtualReleaseTrack || !item.modified) return; + this.router.navigate([], { + relativeTo: this.route, + queryParams: { snapshot: item.modified }, + queryParamsHandling: 'merge', + }); + } + + public onBackToReleases(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { snapshot: null }, + queryParamsHandling: 'merge', + }); + } + + public onQuarantineResolved(): void { + this.onBackToReleases(); + this.getReleaseTrack(); + this.snackbar.open('Resolution saved in a new draft snapshot.', 'Close', { + duration: 5000, + }); + } + public onSelectedTabChange(index: number): void { this.selectedTabIndex = index; - if (index === 1) this.refreshVisibleHistory(); + this.refreshVisibleHistory(); } private get automaticHistoryRefreshPaused(): boolean { @@ -594,7 +632,8 @@ export class ReleaseTrackPageComponent implements OnInit, OnDestroy { this.destroyed || !this.id || !this.releaseTrack || - this.selectedTabIndex !== 1 || + this.selectedTabIndex !== (this.isVirtualReleaseTrack ? 0 : 1) || + (this.isVirtualReleaseTrack && !!this.selectedSnapshotModified) || document.visibilityState !== 'visible' || this.isEditingConfig || this.isEditingDescription || From 970f026376850055787f0d28f4cf39b0fa093264 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:25:34 -0400 Subject: [PATCH 4/4] chore(preview): add isolated virtual snapshot design fixtures --- angular.json | 12 + docs/local-dev.md | 70 ++ package.json | 1 + scripts/snapshot-preview.mjs | 638 ++++++++++++++++++ scripts/snapshot-preview.proxy.json | 6 + .../environment.snapshot-preview.ts | 11 + 6 files changed, 738 insertions(+) create mode 100644 scripts/snapshot-preview.mjs create mode 100644 scripts/snapshot-preview.proxy.json create mode 100644 src/environments/environment.snapshot-preview.ts diff --git a/angular.json b/angular.json index b4225314a..7b6946fd3 100644 --- a/angular.json +++ b/angular.json @@ -57,6 +57,14 @@ "browser": "src/main.ts" }, "configurations": { + "snapshot-preview": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.snapshot-preview.ts" + } + ] + }, "production": { "fileReplacements": [ { @@ -98,6 +106,10 @@ "buildTarget": "app:build" }, "configurations": { + "snapshot-preview": { + "buildTarget": "app:build:snapshot-preview", + "proxyConfig": "scripts/snapshot-preview.proxy.json" + }, "production": { "buildTarget": "app:build:production" } diff --git a/docs/local-dev.md b/docs/local-dev.md index 76a427de7..cbae46054 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -95,6 +95,76 @@ npm run build-prod If variables are omitted, the generated asset uses the package version and reports its commit and build date as `unknown`. +### Virtual snapshot design preview + +Run `npm run preview:snapshots`, then open +. +This opt-in configuration runs Angular on port 4300 and an in-memory fixture API +on loopback port 4311. It never proxies to the production or local REST API. +Normal `npm start` and build configurations are unchanged. + +The preview includes a current quarantined draft, a tagged snapshot using another +deduplication strategy, and a tagged snapshot with quarantine enabled but no +conflicts. Open each card to compare contents and button visibility. The current +draft contains eleven distinct manifest objects: five members, three relationships, +two supporting objects, and one non-exported LinkById render dependency. +The source filter demonstrates seven Enterprise ATT&CK objects, three Research +extensions objects, and two objects whose source was not recorded. PowerShell +is one shared revision attributed to both component tracks, not a duplicate row. +Quarantine comparison serves two distinct exact STIX revisions, including changed +descriptions, platforms, and ATT&CK versions. Both can be inspected as raw JSON +or opened in the existing read-only detail dialog; **View in Object Library** +opens the ordinary object page without closing the comparison. +Quarantine selection simulates creating a new draft; earlier snapshots are preserved. +Restart the preview to reset its in-memory data. Other write operations are not +simulated and return an explicit preview-only error. + +The identity and marking-definition fixtures return plain arrays unless the +request explicitly sets `includePagination=true`, which returns `{ data, +pagination }`. Detail dialogs load marking definitions without pagination; the +fixture includes the referenced statement marking so the dialog shows one +statement. Collection-index listings also return a plain array. When smoke-testing +View details, check console error logs and notifications as well as uncaught +exceptions: connector errors are caught and can leave the object visible. + +All eleven manifest rows provide working **Preview** and **View in Object +Library** actions. The fixture serves typed current/exact object endpoints, +enriched relationship endpoints and their filtered lookups, identity field +options, and tactic technique listings. Supporting-object previews reuse the +identity and marking-definition views. `/relationship/:id` is a read-only detail +route, with no relationship creation or editing entry point. + +Snapshot-history fixtures follow the current `next` contract: `tagged`, `limit`, +and `offset` select the returned page; `counts` describes the filtered history; +`latest_snapshot_modified` and `latest_tagged_snapshot_modified` identify the +unfiltered live snapshots. The draft-cleanup endpoint returns an empty operation +list so Releases can exercise its automatic refresh without simulated cleanup. + +The complete-manifest mockup uses a **proposed**, not yet implemented production +response field on exact Workbench snapshot exports: + +```typescript +content_manifest_entries: Array<{ + kind: 'primary' | 'secondary' | 'relationship' | 'supporting' | 'link_target'; + object_ref: string; + object_modified?: string; + stix?: object; + source_tracks?: Array<{ track_id: string; track_name?: string }>; +}>; +``` + +Entries are combined by exact object ID and revision, retaining all roles and +recorded source tracks. Track IDs identify sources; names are display labels. +Absent origin metadata remains **Source not recorded** and is never reconstructed +from current component contents. +Non-exported dependencies remain visible; entries without payloads show their +exact reference rather than fetching a latest revision. The generated collection +projection is excluded. Production API responses currently omit this field; +the UI then shows the snapshot's exported graph and explicitly discloses that +non-exported render dependencies cannot be listed. A real complete-manifest read +contract is required before this design can provide full manifest browsing +against production data. + ## Note: Recommended setup using Visual Studio Code Workspaces diff --git a/package.json b/package.json index 9e1d0c33a..cd0e8569b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "scripts": { "ng": "ng", "start": "ng serve", + "preview:snapshots": "node scripts/snapshot-preview.mjs", "build": "ng build", "postbuild": "npm run generate:build-info", "build-prod": "ng build --configuration production", diff --git a/scripts/snapshot-preview.mjs b/scripts/snapshot-preview.mjs new file mode 100644 index 000000000..7d681669a --- /dev/null +++ b/scripts/snapshot-preview.mjs @@ -0,0 +1,638 @@ +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// Design fixture server, never used by normal start/build. All writes are in memory. +// content_manifest_entries is a proposed read contract, not an existing API field. +const root = fileURLToPath(new URL('..', import.meta.url)); +const trackId = 'release-track--snapshot-preview'; +const standardId = 'release-track--component-preview'; +const enterpriseSource = { + track_id: standardId, + track_name: 'Enterprise ATT&CK', +}; +const researchSource = { + track_id: 'release-track--research-preview', + track_name: 'Research extensions', +}; +const modified = '2026-09-30T10:30:00.000Z'; +const revision = '2026-09-18T09:00:00.000Z'; +const id = (type, n) => + `${type}--00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +const object = (type, n, name, extra = {}) => ({ + type, + id: id(type, n), + name, + created: '2026-01-15T09:00:00.000Z', + modified: revision, + spec_version: '2.1', + x_mitre_version: '1.0', + description: `${name}. Illustrative data for the virtual snapshot design preview.`, + ...extra, +}); +const attack = external_id => ({ + external_references: [{ source_name: 'mitre-attack', external_id }], +}); +const roots = [ + object( + 'attack-pattern', + 1, + 'Command and Scripting Interpreter', + attack('T1059') + ), + object('attack-pattern', 2, 'PowerShell', { + ...attack('T1059.001'), + x_mitre_is_subtechnique: true, + }), + object('attack-pattern', 3, 'Phishing', attack('T1566')), + object('intrusion-set', 4, 'Example threat group', attack('G0001')), + object('malware', 5, 'Example remote access tool', { + ...attack('S0001'), + is_family: true, + }), +]; +const relationship = (n, source, target, relationship_type) => + object('relationship', n, undefined, { + source_ref: source.id, + target_ref: target.id, + relationship_type, + description: 'Relationship pinned to the selected member revisions.', + }); +const relationships = [ + relationship(6, roots[1], roots[0], 'subtechnique-of'), + relationship(7, roots[3], roots[2], 'uses'), + relationship(8, roots[4], roots[1], 'uses'), +]; +const identity = object('identity', 9, 'Snapshot Design Lab', { + identity_class: 'organization', +}); +const marking = { + type: 'marking-definition', + id: id('marking-definition', 10), + created: '2026-01-15T09:00:00.000Z', + spec_version: '2.1', + definition_type: 'statement', + definition: { statement: 'Design preview data only' }, +}; +const linkTarget = object('x-mitre-tactic', 11, 'Execution', { + ...attack('TA0002'), + x_mitre_shortname: 'execution', +}); +const entry = (kind, stix, source_tracks) => ({ + kind, + object_ref: stix.id, + object_modified: stix.modified, + stix, + ...(source_tracks ? { source_tracks } : {}), +}); +const entries = [ + ...roots.map((stix, index) => + entry( + 'primary', + stix, + index === 4 + ? [researchSource] + : index === 1 + ? [enterpriseSource, researchSource] + : [enterpriseSource] + ) + ), + ...relationships.map((stix, index) => + entry('relationship', stix, [ + index === 2 ? researchSource : enterpriseSource, + ]) + ), + entry('supporting', identity), + entry('supporting', marking), + entry('link_target', linkTarget, [enterpriseSource]), +]; +const conflict = object( + 'attack-pattern', + 12, + 'Scheduled Task/Job', + attack('T1053') +); +const quarantine = ['2026-09-12T08:00:00.000Z', '2026-09-25T14:00:00.000Z'].map( + (object_modified, i) => ({ + object_ref: conflict.id, + object_modified, + name: conflict.name, + attack_id: 'T1053', + source_track_id: i ? 'release-track--research-preview' : standardId, + source_track_name: i ? 'Research extensions' : 'Enterprise ATT&CK', + conflict_reason: + 'Component tracks selected different revisions of this object.', + }) +); +const conflictRevisions = quarantine.map((item, index) => ({ + stix: { + ...conflict, + modified: item.object_modified, + x_mitre_version: index ? '1.1' : '1.0', + description: index + ? 'Research revision: adversaries may use scheduled tasks on Windows and Linux to execute commands at a defined time or on system startup.' + : 'Enterprise revision: adversaries may use Windows Task Scheduler to execute commands at a defined time or on system startup.', + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: index ? ['Windows', 'Linux'] : ['Windows'], + kill_chain_phases: [ + { kill_chain_name: 'mitre-attack', phase_name: 'execution' }, + { kill_chain_name: 'mitre-attack', phase_name: 'persistence' }, + ], + x_mitre_is_subtechnique: false, + x_mitre_detection: index + ? 'Monitor scheduled task registration and cron configuration changes.' + : 'Monitor Windows scheduled task registration.', + x_mitre_attack_spec_version: '3.3.0', + created_by_ref: identity.id, + object_marking_refs: [marking.id], + revoked: false, + x_mitre_deprecated: false, + }, + workspace: { workflow: { state: 'reviewed' } }, +})); +const libraryRecords = entries + .map(({ stix }) => { + const record = { stix, workspace: {} }; + if (stix.type === 'relationship') { + record.source_object = { + stix: entries.find(e => e.object_ref === stix.source_ref).stix, + workspace: {}, + }; + record.target_object = { + stix: entries.find(e => e.object_ref === stix.target_ref).stix, + workspace: {}, + }; + } + return record; + }) + .concat(conflictRevisions); +const libraryTypes = { + 'techniques': ['attack-pattern'], + 'groups': ['intrusion-set'], + 'software': ['malware', 'tool'], + 'relationships': ['relationship'], + 'identities': ['identity'], + 'marking-definitions': ['marking-definition'], + 'tactics': ['x-mitre-tactic'], +}; +const attackTypes = { + 'attack-pattern': 'technique', + 'intrusion-set': 'group', + 'malware': 'software', + 'tool': 'software', +}; +const stats = manifest => ({ + primary_count: manifest.filter(e => e.kind === 'primary').length, + secondary_count: manifest.filter(e => e.kind === 'secondary').length, + relationship_count: manifest.filter(e => e.kind === 'relationship').length, + supporting_count: manifest.filter(e => e.kind === 'supporting').length, + link_target_count: manifest.filter(e => e.kind === 'link_target').length, + total_count: manifest.length, +}); +const snapshot = (time, version, strategy, conflicts, manifest = entries) => ({ + id: trackId, + name: 'Enterprise Composite · Design Preview', + type: 'virtual', + description: + 'Enterprise ATT&CK and research extensions, composed into a single snapshot.', + modified: time, + created: time, + version, + creation_actor: { kind: 'system' }, + creation_cause: 'scheduled_snapshot', + snapshot_schedule: { mode: 'manual' }, + snapshot_description: version + ? 'Published composition of the selected component snapshots.' + : 'Scheduled composition. One object has competing revisions to review.', + content_manifest_id: `release-track-content-manifest--preview-${time.slice(0, 10)}`, + content_manifest_entries: structuredClone(manifest), + content_statistics: stats(manifest), + members: manifest + .filter(e => e.kind === 'primary') + .map(e => ({ + object_ref: e.object_ref, + object_modified: e.object_modified, + name: e.stix.name, + })), + members_count: manifest.filter(e => e.kind === 'primary').length, + quarantine: structuredClone(conflicts), + quarantine_count: conflicts.length, + composition: { + component_tracks: [ + { + track_id: standardId, + priority: 1, + resolution_strategy: 'latest_tagged', + }, + { + track_id: 'release-track--research-preview', + priority: 2, + resolution_strategy: 'latest_preview', + }, + ], + deduplication: { strategy }, + }, + composition_resolution: { + resolved_at: time, + component_snapshots: [ + { + track_id: standardId, + track_name: 'Enterprise ATT&CK', + resolved_snapshot_id: '2026-09-20T09:00:00.000Z', + resolved_version: '19.1', + strategy_used: 'latest_tagged', + total_objects_in_source: 4, + objects_after_filter: 4, + objects_contributed: 4, + }, + { + track_id: 'release-track--research-preview', + track_name: 'Research extensions', + resolved_snapshot_id: '2026-09-29T09:00:00.000Z', + strategy_used: 'latest_preview', + total_objects_in_source: 2, + objects_after_filter: 2, + objects_contributed: 1, + }, + ], + }, + config: {}, +}); +const snapshots = [ + snapshot(modified, null, 'quarantine', quarantine), + snapshot( + '2026-09-23T10:30:00.000Z', + '19.1', + 'prioritize_latest_object', + [], + entries.slice(0, -1) + ), + snapshot('2026-09-16T10:30:00.000Z', '19.0', 'quarantine', [], entries), +]; +const standard = { + id: standardId, + name: 'Enterprise ATT&CK · Design Preview', + type: 'standard', + modified, + created: modified, + members: [], + staged: [], + candidates: [], + config: {}, +}; +const page = data => ({ + data, + pagination: { total: data.length, limit: 200, offset: 0 }, +}); +const historyPage = (history, params) => { + const tagged = params.get('tagged'); + const filtered = + tagged === null + ? history + : history.filter(snapshot => !!snapshot.version === (tagged === 'true')); + const offset = Math.max(0, Number(params.get('offset')) || 0); + const limit = Math.max(1, Number(params.get('limit')) || 200); + const taggedCount = filtered.filter(snapshot => !!snapshot.version).length; + return { + data: filtered.slice(offset, offset + limit), + pagination: { total: filtered.length, limit, offset }, + counts: { + tagged: taggedCount, + drafts: filtered.length - taggedCount, + total: filtered.length, + }, + latest_snapshot_modified: history[0]?.modified ?? null, + latest_tagged_snapshot_modified: + history.find(snapshot => !!snapshot.version)?.modified ?? null, + }; +}; +const send = (res, status, data) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +}; +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1:4311'); + const path = decodeURIComponent(url.pathname); + if ( + req.method === 'POST' && + path === `/api/release-tracks/${trackId}/virtual/quarantine/promote` + ) { + let body = ''; + for await (const chunk of req) body += chunk; + let choice; + try { + choice = JSON.parse(body); + } catch { + return send(res, 400, { message: 'Invalid JSON.' }); + } + const current = snapshots[0]; + const selected = current.quarantine.find( + e => + e.object_ref === choice.object_ref && + e.object_modified === choice.object_modified + ); + if (!selected) + return send(res, 409, { + message: 'This revision is no longer quarantined in the current draft.', + }); + const next = structuredClone(current); + next.modified = new Date( + new Date(current.modified).getTime() + 1000 + ).toISOString(); + next.created = next.modified; + next.content_manifest_id += '-resolved'; + next.quarantine = next.quarantine.filter( + e => e.object_ref !== choice.object_ref + ); + next.quarantine_count = next.quarantine.length; + next.members.push({ + object_ref: choice.object_ref, + object_modified: choice.object_modified, + name: conflict.name, + }); + next.members_count = next.members.length; + next.content_manifest_entries.push( + entry( + 'primary', + conflictRevisions.find( + record => record.stix.modified === choice.object_modified + ).stix, + [ + { + track_id: selected.source_track_id, + track_name: selected.source_track_name, + }, + ] + ) + ); + next.content_statistics = stats(next.content_manifest_entries); + next.snapshot_description = + 'Exact revision selected in quarantine review. The previous snapshot is preserved.'; + next.creation_cause = 'quarantine_promoted'; + snapshots.unshift(next); + return send(res, 200, next); + } + if (req.method !== 'GET') + return send(res, 405, { + message: + 'Design preview: only quarantine resolution is simulated. No production data is changed.', + }); + const tacticTechniques = path.match( + /^\/api\/tactics\/([^/]+)\/modified\/([^/]+)\/techniques$/ + ); + if (tacticTechniques) { + const tactic = libraryRecords.find( + record => + record.stix.id === tacticTechniques[1] && + record.stix.modified === tacticTechniques[2] + ); + if (!tactic) + return send(res, 404, { message: 'Tactic revision not found.' }); + const techniques = new Map(); + for (const record of libraryRecords) { + if ( + record.stix.type !== 'attack-pattern' || + !record.stix.kill_chain_phases?.some( + phase => phase.phase_name === tactic.stix.x_mitre_shortname + ) + ) + continue; + const previous = techniques.get(record.stix.id); + if (!previous || record.stix.modified > previous.stix.modified) + techniques.set(record.stix.id, record); + } + return send(res, 200, [...techniques.values()]); + } + const libraryPath = path.match( + /^\/api\/([^/]+)(?:\/([^/]+)(?:\/modified\/([^/]+))?)?$/ + ); + if (libraryPath && libraryTypes[libraryPath[1]]) { + const [, resource, objectId, exactRevision] = libraryPath; + let records = libraryRecords.filter(record => + libraryTypes[resource].includes(record.stix.type) + ); + if (objectId) { + records = records.filter(record => record.stix.id === objectId); + if (exactRevision) + records = records.filter( + record => record.stix.modified === exactRevision + ); + records.sort((a, b) => + (b.stix.modified || b.stix.created).localeCompare( + a.stix.modified || a.stix.created + ) + ); + if (!records.length) + return send(res, 404, { + message: 'This exact object revision is not in the design preview.', + }); + return send( + res, + 200, + !exactRevision && url.searchParams.get('versions') === 'all' + ? records + : records[0] + ); + } + if (url.searchParams.get('versions') !== 'all') { + const latest = new Map(); + for (const record of records) { + const previous = latest.get(record.stix.id); + if ( + !previous || + (record.stix.modified || record.stix.created) > + (previous.stix.modified || previous.stix.created) + ) + latest.set(record.stix.id, record); + } + records = [...latest.values()]; + } + if (resource === 'relationships') { + records = records.filter(record => { + const { stix, source_object, target_object } = record; + const filters = { + sourceRef: stix.source_ref, + targetRef: stix.target_ref, + relationshipType: stix.relationship_type, + sourceType: + attackTypes[source_object.stix.type] || source_object.stix.type, + targetType: + attackTypes[target_object.stix.type] || target_object.stix.type, + }; + for (const [key, value] of Object.entries(filters)) { + if (url.searchParams.has(key) && url.searchParams.get(key) !== value) + return false; + } + const eitherRef = url.searchParams.get('sourceOrTargetRef'); + return ( + !eitherRef || + eitherRef === stix.source_ref || + eitherRef === stix.target_ref + ); + }); + } + const total = records.length; + const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0); + const limit = url.searchParams.has('limit') + ? Math.max(0, Number(url.searchParams.get('limit')) || 0) + : total; + records = records.slice(offset, offset + limit); + return send( + res, + 200, + url.searchParams.get('includePagination') === 'true' + ? { data: records, pagination: { total, offset, limit } } + : records + ); + } + if ( + [ + '/api/references', + '/api/notes', + '/api/collections', + '/api/matrices', + '/api/campaigns', + '/api/mitigations', + '/api/data-sources', + '/api/data-components', + '/api/detection-strategies', + '/api/analytics', + '/api/assets', + ].includes(path) + ) + return send( + res, + 200, + url.searchParams.get('includePagination') === 'true' ? page([]) : [] + ); + if (path === '/api/config/system-version') + return send(res, 200, { + name: 'snapshot-design-fixtures', + version: 'design-preview', + gitCommit: 'not-a-production-api', + buildDate: '2026-09-30', + }); + if (path === '/api/user-accounts') return send(res, 200, page([])); + if (path === '/api/session') + return send(res, 200, { userAccountId: 'snapshot-designer' }); + if (path === '/api/user-accounts/snapshot-designer') + return send(res, 200, { + id: 'snapshot-designer', + username: 'designer', + displayName: 'Design Preview', + status: 'active', + role: 'admin', + }); + if (path === '/api/config/authn') + return send(res, 200, { mechanisms: [{ authnType: 'anonymous' }] }); + if (path === '/api/config/allowed-values') + return send(res, 200, [ + { + objectType: 'identity', + properties: [ + { + propertyName: 'identity_class', + domains: [ + { + domainName: 'stix', + allowedValues: [ + 'individual', + 'group', + 'system', + 'organization', + 'class', + 'unspecified', + ], + }, + ], + }, + ], + }, + ]); + if (path === '/api/config/organization-identity') + return send(res, 200, { stix: identity, workspace: {} }); + if (path === '/api/config/organization-namespace') + return send(res, 200, { prefix: 'PREVIEW', range_start: 1 }); + if (path === '/api/release-tracks') + return send(res, 200, page([snapshots[0], standard])); + if (path === `/api/release-tracks/${trackId}/config`) + return send(res, 200, snapshots[0].config); + if (path === `/api/release-tracks/${standardId}/config`) + return send(res, 200, {}); + if (path === `/api/release-tracks/${standardId}/snapshots/latest`) + return send(res, 200, standard); + if (path === `/api/release-tracks/${standardId}/snapshots`) + return send(res, 200, historyPage([standard], url.searchParams)); + if (path === `/api/release-tracks/${trackId}/snapshots`) + return send(res, 200, historyPage(snapshots, url.searchParams)); + if (path === `/api/release-tracks/${trackId}/virtual/draft-cleanup`) + return send(res, 200, { data: [] }); + const snapshotPath = `/api/release-tracks/${trackId}/snapshots/`; + if (path.startsWith(snapshotPath)) { + const key = path.slice(snapshotPath.length); + const selected = + key === 'latest' ? snapshots[0] : snapshots.find(s => s.modified === key); + if (!selected) + return send(res, 404, { + message: 'Snapshot not found in the design preview.', + }); + if (url.searchParams.get('format') === 'bundle') + return send(res, 200, { + type: 'bundle', + id: id('bundle', 20), + objects: [ + { + type: 'x-mitre-collection', + id: id('x-mitre-collection', 21), + name: selected.name, + }, + ...selected.content_manifest_entries + .filter(e => e.kind !== 'link_target') + .map(e => e.stix), + ], + }); + return send(res, 200, selected); + } + if (path === '/api/collection-indexes') return send(res, 200, []); + console.warn(`Preview route not provided: ${req.method} ${path}`); + return send(res, 404, { + message: 'Not available in the snapshot design preview.', + }); +}); +server.listen(4311, '127.0.0.1', () => { + console.log( + 'Snapshot design fixture API ready on 127.0.0.1:4311 (in-memory only).' + ); + console.log( + `Preview: http://localhost:4300/dashboard/release-management/${trackId}` + ); +}); +const angular = spawn( + process.execPath, + [ + 'node_modules/@angular/cli/bin/ng.js', + 'serve', + '--configuration', + 'snapshot-preview', + '--port', + '4300', + '--host', + 'localhost', + ], + { cwd: root, stdio: 'inherit' } +); +const stop = () => { + angular.kill('SIGTERM'); + server.close(); +}; +process.on('SIGINT', stop); +process.on('SIGTERM', stop); +angular.on('exit', code => { + server.close(); + process.exitCode = code ?? 0; +}); +server.on('error', error => { + console.error(error.message); + angular.kill('SIGTERM'); + process.exitCode = 1; +}); diff --git a/scripts/snapshot-preview.proxy.json b/scripts/snapshot-preview.proxy.json new file mode 100644 index 000000000..23569001f --- /dev/null +++ b/scripts/snapshot-preview.proxy.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://127.0.0.1:4311", + "secure": false + } +} diff --git a/src/environments/environment.snapshot-preview.ts b/src/environments/environment.snapshot-preview.ts new file mode 100644 index 000000000..f5d681693 --- /dev/null +++ b/src/environments/environment.snapshot-preview.ts @@ -0,0 +1,11 @@ +import { environment as defaults } from './environment.prod'; + +// Explicit opt-in design preview. Requests go only to the local fixture server. +export const environment = { + ...defaults, + production: false, + integrations: { + ...defaults.integrations, + rest_api: { enabled: true, url: '/api' }, + }, +};