diff --git a/app/lib/event-constants.js b/app/lib/event-constants.js index a83a18b5..0b4354a4 100644 --- a/app/lib/event-constants.js +++ b/app/lib/event-constants.js @@ -169,4 +169,5 @@ module.exports = Object.freeze({ // latest snapshot, or null when the track (or its only snapshot) was deleted. // Listeners reconcile workspace.release_tracks backrefs on their own documents. RELEASE_TRACK_CONTENTS_CHANGED: 'release-track::contents-changed', + RELEASE_TRACK_OBJECTS_REVIEWED: 'release-track::objects-reviewed', }); diff --git a/app/repository/_base.repository.js b/app/repository/_base.repository.js index 1cec5a72..40dee3b4 100644 --- a/app/repository/_base.repository.js +++ b/app/repository/_base.repository.js @@ -527,6 +527,22 @@ class BaseRepository extends AbstractRepository { } } + async markRevisionsReviewed(entries) { + if (!entries.length) return { matchedCount: 0 }; + // The generic AttackObject schema omits modified; strictQuery would strip + // that predicate. Match BSON dates directly to preserve the exact revision. + return this.model.collection.updateMany( + { + $or: entries.map(({ object_ref, object_modified }) => ({ + 'stix.id': object_ref, + 'stix.modified': new Date(object_modified), + })), + 'workspace.workflow.state': { $ne: 'static' }, + }, + { $set: { 'workspace.workflow.state': 'reviewed' } }, + ); + } + /** * Retrieve the workspace.release_tracks backrefs of one object revision. * Lean, minimal projection — used to refresh a create/update response diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index d2ca4bfd..ac6e5db0 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -468,9 +468,11 @@ class BaseService extends ServiceWithHooks { * Validation errors that match a stored bypass rule are filtered out. * * @param {Object} data - The composed request data ({ stix, workspace }) + * @param {Object} [options] - Validation options + * @param {Array} [options.bypassRules] - Preloaded rules for batch validation * @returns {Promise<{ errors: Array, warnings: Array }>} Validation results */ - async validateComposedObject(data) { + async validateComposedObject(data, { bypassRules } = {}) { const empty = { errors: [], warnings: [] }; if (!config.validateRequests.withAttackDataModel) return empty; @@ -497,6 +499,7 @@ class BaseService extends ServiceWithHooks { const results = await EventBus.emit(Events.VALIDATION_BYPASS_CHECK_REQUESTED, { errors: allErrors, stixType, + bypassRules, }); // The handler returns { errors, warnings } diff --git a/app/services/release-tracks/reviewed-state-service.js b/app/services/release-tracks/reviewed-state-service.js new file mode 100644 index 00000000..394a9389 --- /dev/null +++ b/app/services/release-tracks/reviewed-state-service.js @@ -0,0 +1,80 @@ +'use strict'; + +const config = require('../../config/config'); +const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); +const { ValidationError } = require('../../exceptions'); +const primaryRevisionService = require('./primary-revision-service'); +const revisionReference = require('../../lib/release-tracks/revision-reference'); + +// This is deliberately revision-global legacy state, not a per-track review policy. +async function ensureReviewed(entries) { + if (!entries.length) return []; + const { documents } = await primaryRevisionService.assertStoredEntries(entries); + const attackObjectsService = require('../stix/attack-objects-service'); + require('../stix/relationships-service'); + require('../system/validation-bypasses-service'); + const bypassRules = config.validateRequests.withAttackDataModel + ? await require('../../repository/validation-bypasses-repository').findAll() + : undefined; + const errors = []; + const warnings = []; + const reviewedEntries = []; + + for (const document of documents) { + // Static system objects must retain their protected workflow state. + if (document.workspace?.workflow?.state === 'static') continue; + const reference = { + object_ref: document.stix.id, + object_modified: new Date(document.stix.modified).toISOString(), + }; + const result = await attackObjectsService.validateComposedObject( + { + // MongoDB dates, including type-specific dates, must reach ADM as strings. + // Validate a copy; never replace stored STIX with the parsed schema output. + stix: JSON.parse(JSON.stringify(document.stix)), + workspace: { workflow: { state: 'reviewed' } }, + }, + { bypassRules }, + ); + errors.push(...result.errors.map((issue) => ({ ...issue, ...reference }))); + warnings.push(...result.warnings.map((issue) => ({ ...issue, ...reference }))); + reviewedEntries.push(reference); + } + + // Reject the complete admission before changing any object's workflow metadata. + if (errors.length) { + throw new ValidationError('ADM validation failed', { details: errors, warnings }); + } + if (reviewedEntries.length) { + const results = await EventBus.emitRequired( + Events.RELEASE_TRACK_OBJECTS_REVIEWED, + { entries: reviewedEntries }, + { minimumListeners: 2 }, + ); + if ( + results.reduce((count, result) => count + result.matchedCount, 0) !== reviewedEntries.length + ) { + throw new Error('Not all admitted object revisions could be marked reviewed'); + } + } + return warnings; +} + +async function reviewSnapshotChanges(before, after, { membersWritten = false } = {}) { + const entries = []; + for (const tier of ['staged', 'members']) { + const previous = new Set( + (before?.[tier] || []).map( + (entry) => `${entry.object_ref}::${revisionReference.modifiedKey(entry.object_modified)}`, + ), + ); + for (const entry of after[tier] || []) { + const key = `${entry.object_ref}::${revisionReference.modifiedKey(entry.object_modified)}`; + if ((tier === 'members' && membersWritten) || !previous.has(key)) entries.push(entry); + } + } + return ensureReviewed(entries); +} + +module.exports = { ensureReviewed, reviewSnapshotChanges }; diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index d3ab9a47..7e36cfca 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -496,6 +496,11 @@ async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options clone.creation_cause = options.creationCause || CreationCause.Unknown; clone.creation_actor = creationActor(options.userAccountId); const normalized = tierRevisionInvariant.normalizeSnapshot(clone); + const reviewWarnings = await require('./reviewed-state-service').reviewSnapshotChanges( + sourceSnapshot, + normalized.snapshot, + { membersWritten: rewritesMembers }, + ); await options.lease.assertOwned(); const cleanupIntent = normalized.snapshot.type === 'virtual' && options.retention @@ -561,6 +566,7 @@ async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options ); } logger.verbose(`SnapshotService: Cloned snapshot for track "${trackId}"`); + if (reviewWarnings.length) saved.warnings = reviewWarnings; return saved; } @@ -636,6 +642,11 @@ async function _cloneToNewTrack(sourceSnapshot, options = {}) { await primaryRevisionService.assertStoredEntries( tierRevisionInvariant.TIER_PRECEDENCE.flatMap((tier) => normalized.snapshot[tier] || []), ); + const reviewWarnings = await require('./reviewed-state-service').reviewSnapshotChanges( + null, + normalized.snapshot, + { membersWritten: true }, + ); await modelFactory.ensureIndexes(newTrackId); const saved = await saveSealedSnapshot( @@ -667,6 +678,7 @@ async function _cloneToNewTrack(sourceSnapshot, options = {}) { ); } logger.verbose(`SnapshotService: Cloned track to new track "${clone.name}" (${newTrackId})`); + if (reviewWarnings.length) saved.warnings = reviewWarnings; return saved; } diff --git a/app/services/release-tracks/versioning-service.js b/app/services/release-tracks/versioning-service.js index ca41f1a7..ff2ebcac 100644 --- a/app/services/release-tracks/versioning-service.js +++ b/app/services/release-tracks/versioning-service.js @@ -556,6 +556,10 @@ async function releaseLocked(trackId, loadPlan, options) { if (options.squash_drafts) draftCleanupService.assertAdmin(options.actor); return withReleaseLock(trackId, async (lease) => { const plan = await loadPlan(); + if (plan.blockingError) throw plan.blockingError; + const reviewWarnings = await require('./reviewed-state-service').ensureReviewed( + plan.plannedSnapshot.members || [], + ); const intent = options.squash_drafts ? await draftCleanupService.beginSquash(plan, options, lease) : null; @@ -567,6 +571,7 @@ async function releaseLocked(trackId, loadPlan, options) { if (intent) throw await draftCleanupService.failRelease(intent, error); throw error; } + if (reviewWarnings.length) released.warnings = reviewWarnings; if (!intent) return released; intent.cleanup = { kind: 'squash', diff --git a/app/services/stix/attack-objects-service.js b/app/services/stix/attack-objects-service.js index 81d49f05..7d9b99e4 100644 --- a/app/services/stix/attack-objects-service.js +++ b/app/services/stix/attack-objects-service.js @@ -213,6 +213,11 @@ class AttackObjectsService extends BaseService { AttackObjectsService.handleRevisionsRequested, ); + EventBus.on( + Events.RELEASE_TRACK_OBJECTS_REVIEWED, + AttackObjectsService.handleReleaseTrackObjectsReviewed, + ); + logger.info('AttackObjectsService: Event listeners initialized'); } @@ -228,6 +233,12 @@ class AttackObjectsService extends BaseService { return attackObjectsRepository.findManyByIdAndModified(entries); } + static async handleReleaseTrackObjectsReviewed({ entries }) { + return attackObjectsRepository.markRevisionsReviewed( + entries.filter((entry) => !entry.object_ref.startsWith('relationship--')), + ); + } + /** * Reconcile workspace.release_tracks backrefs on attackObjects documents * when a release track's contents change. Covers every STIX type stored in diff --git a/app/services/stix/relationships-service.js b/app/services/stix/relationships-service.js index e10df3cb..a5deef20 100644 --- a/app/services/stix/relationships-service.js +++ b/app/services/stix/relationships-service.js @@ -124,6 +124,11 @@ class RelationshipsService extends BaseService { this.handleReleaseTrackContentsChanged.bind(this), ); + EventBus.on( + EventConstants.RELEASE_TRACK_OBJECTS_REVIEWED, + this.handleReleaseTrackObjectsReviewed.bind(this), + ); + EventBus.on( EventConstants.BUNDLE_RELATIONSHIPS_REQUESTED, this.handleBundleRelationshipsRequested.bind(this), @@ -149,6 +154,12 @@ class RelationshipsService extends BaseService { }); } + static async handleReleaseTrackObjectsReviewed({ entries }) { + return relationshipsRepository.markRevisionsReviewed( + entries.filter((entry) => entry.object_ref.startsWith('relationship--')), + ); + } + /** * Reconcile workspace.release_tracks backrefs on relationship documents * when a release track's contents change. Relationships live in their own diff --git a/app/services/system/validation-bypasses-service.js b/app/services/system/validation-bypasses-service.js index d5dd49e7..fd560946 100644 --- a/app/services/system/validation-bypasses-service.js +++ b/app/services/system/validation-bypasses-service.js @@ -63,13 +63,13 @@ class ValidationBypassesService { * @returns {Promise<{ errors: Array, warnings: Array }>} */ static async handleBypassCheckRequested(payload) { - const { errors, stixType } = payload; + const { errors, stixType, bypassRules } = payload; const service = module.exports; const nonBypassed = []; const warnings = []; for (const error of errors) { - const result = await service.checkBypassRule(error, stixType); + const result = await service.checkBypassRule(error, stixType, bypassRules); if (result.bypassed) { if (result.warningMessage) { warnings.push({ message: result.warningMessage, path: error.path, code: error.code }); @@ -108,10 +108,11 @@ class ValidationBypassesService { * A rule matches if it either suppresses the error or converts it to a warning. * @param {Object} error - The validation error ({ path, code, ... }) * @param {string} stixType - The STIX type being validated + * @param {Array} [bypassRules] - Preloaded rules, or load the current rules * @returns {Promise<{ bypassed: boolean, warningMessage: string|null }>} */ - async checkBypassRule(error, stixType) { - const rules = await this.repository.findAll(); + async checkBypassRule(error, stixType, bypassRules) { + const rules = bypassRules ?? (await this.repository.findAll()); const errorPathStr = JSON.stringify(error.path.map(String)); diff --git a/app/tests/api/release-tracks/destructive-authorization.spec.js b/app/tests/api/release-tracks/destructive-authorization.spec.js index 27fa1658..0fe2def8 100644 --- a/app/tests/api/release-tracks/destructive-authorization.spec.js +++ b/app/tests/api/release-tracks/destructive-authorization.spec.js @@ -133,6 +133,7 @@ describe('Release-track destructive authorization and audit', function () { x_mitre_is_subtechnique: false, x_mitre_domains: ['enterprise-attack'], x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', object_marking_refs: [markingDefinitionId], }, }, diff --git a/app/tests/api/release-tracks/deterministic-graph-migration.spec.js b/app/tests/api/release-tracks/deterministic-graph-migration.spec.js index b5c075e7..303a55ca 100644 --- a/app/tests/api/release-tracks/deterministic-graph-migration.spec.js +++ b/app/tests/api/release-tracks/deterministic-graph-migration.spec.js @@ -99,6 +99,7 @@ describe('Release-track manifest migrations', function () { x_mitre_is_subtechnique: false, x_mitre_domains: ['enterprise-attack'], x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', object_marking_refs: [markingDefinitionId], }, }); @@ -112,6 +113,8 @@ describe('Release-track manifest migrations', function () { name: 'Migration graph group', description: 'A group migration fixture.', object_marking_refs: [markingDefinitionId], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }); relationship = await post('/api/relationships', { diff --git a/app/tests/api/release-tracks/draft-lifecycle.spec.js b/app/tests/api/release-tracks/draft-lifecycle.spec.js index 39b71233..c40a1a39 100644 --- a/app/tests/api/release-tracks/draft-lifecycle.spec.js +++ b/app/tests/api/release-tracks/draft-lifecycle.spec.js @@ -294,6 +294,8 @@ describe('Virtual draft lifecycle API', function () { name: 'Lifecycle Quarantine', created: '2026-01-01T00:00:00.000Z', modified: '2026-01-01T00:00:00.000Z', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], x_mitre_platforms: ['Windows'], x_mitre_is_subtechnique: false, kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], diff --git a/app/tests/api/release-tracks/primary-revision-integrity.spec.js b/app/tests/api/release-tracks/primary-revision-integrity.spec.js index 4a043001..5c087f42 100644 --- a/app/tests/api/release-tracks/primary-revision-integrity.spec.js +++ b/app/tests/api/release-tracks/primary-revision-integrity.spec.js @@ -36,6 +36,8 @@ function buildTechnique(name, previous) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/publication-config.spec.js b/app/tests/api/release-tracks/publication-config.spec.js index 4225ac5a..f58913c9 100644 --- a/app/tests/api/release-tracks/publication-config.spec.js +++ b/app/tests/api/release-tracks/publication-config.spec.js @@ -80,6 +80,7 @@ describe('Release-track publication configuration', function () { x_mitre_domains: ['enterprise-attack'], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', }, }; } diff --git a/app/tests/api/release-tracks/reconciliation-durability.spec.js b/app/tests/api/release-tracks/reconciliation-durability.spec.js index 8f17d397..df6bcacd 100644 --- a/app/tests/api/release-tracks/reconciliation-durability.spec.js +++ b/app/tests/api/release-tracks/reconciliation-durability.spec.js @@ -33,6 +33,8 @@ function buildTechnique(name) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/release-tracks-backrefs.spec.js b/app/tests/api/release-tracks/release-tracks-backrefs.spec.js index 27b5f5a6..6f86406d 100644 --- a/app/tests/api/release-tracks/release-tracks-backrefs.spec.js +++ b/app/tests/api/release-tracks/release-tracks-backrefs.spec.js @@ -31,6 +31,8 @@ function buildTechnique(name) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/release-tracks-bundle.spec.js b/app/tests/api/release-tracks/release-tracks-bundle.spec.js index 1c0f5e96..7da3c323 100644 --- a/app/tests/api/release-tracks/release-tracks-bundle.spec.js +++ b/app/tests/api/release-tracks/release-tracks-bundle.spec.js @@ -124,6 +124,8 @@ describe('Release Tracks Bundle Export API', function () { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], ...overrides, }, }; @@ -171,6 +173,8 @@ describe('Release Tracks Bundle Export API', function () { spec_version: '2.1', type: 'intrusion-set', object_marking_refs: [staticMarkingDefinitionId], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }); includedRelationship = await postObject('/api/relationships', { @@ -209,6 +213,8 @@ describe('Release Tracks Bundle Export API', function () { spec_version: '2.1', type: 'intrusion-set', object_marking_refs: [staticMarkingDefinitionId], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }); secondaryRelationship = await postObject('/api/relationships', { diff --git a/app/tests/api/release-tracks/release-tracks-release.spec.js b/app/tests/api/release-tracks/release-tracks-release.spec.js index 17e05f1f..1d40de5d 100644 --- a/app/tests/api/release-tracks/release-tracks-release.spec.js +++ b/app/tests/api/release-tracks/release-tracks-release.spec.js @@ -67,6 +67,8 @@ function buildTechnique(name, previous) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/release-tracks-tier-invariant.spec.js b/app/tests/api/release-tracks/release-tracks-tier-invariant.spec.js index 272ff916..396da973 100644 --- a/app/tests/api/release-tracks/release-tracks-tier-invariant.spec.js +++ b/app/tests/api/release-tracks/release-tracks-tier-invariant.spec.js @@ -31,6 +31,8 @@ function buildTechnique(name, previous) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/release-tracks.spec.js b/app/tests/api/release-tracks/release-tracks.spec.js index b56ce27a..5d601f1b 100644 --- a/app/tests/api/release-tracks/release-tracks.spec.js +++ b/app/tests/api/release-tracks/release-tracks.spec.js @@ -33,6 +33,7 @@ function buildTechnique(name, description) { x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/releases-by-object.spec.js b/app/tests/api/release-tracks/releases-by-object.spec.js index 54a53262..b6b224f6 100644 --- a/app/tests/api/release-tracks/releases-by-object.spec.js +++ b/app/tests/api/release-tracks/releases-by-object.spec.js @@ -28,6 +28,8 @@ function buildTechnique(name, identity = {}) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/reviewed-state.spec.js b/app/tests/api/release-tracks/reviewed-state.spec.js new file mode 100644 index 00000000..e32af3a6 --- /dev/null +++ b/app/tests/api/release-tracks/reviewed-state.spec.js @@ -0,0 +1,264 @@ +'use strict'; + +const { randomUUID } = require('node:crypto'); +const request = require('supertest'); +const { expect } = require('expect'); +const config = require('../../../config/config'); +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const login = require('../../shared/login'); +const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); +const snapshotService = require('../../../services/release-tracks/snapshot-service'); + +function campaign(complete = true) { + return { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'campaign', + spec_version: '2.1', + id: `campaign--${randomUUID()}`, + name: 'Reviewed admission campaign', + ...(complete ? { description: 'Campaign used to verify release admission.' } : {}), + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-02T00:00:00.000Z', + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + revoked: false, + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'C0001', + url: 'https://attack.mitre.org/campaigns/C0001', + }, + { source_name: 'Admission', description: 'Campaign evidence' }, + ], + object_marking_refs: ['marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9'], + x_mitre_attack_spec_version: '3.3.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + aliases: ['Reviewed admission campaign'], + first_seen: '2025-01-01T00:00:00.000Z', + last_seen: '2025-02-01T00:00:00.000Z', + x_mitre_first_seen_citation: '(Citation: Admission)', + x_mitre_last_seen_citation: '(Citation: Admission)', + }, + }; +} + +describe('Legacy reviewed state on release-track admission', function () { + let app; + let cookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + app = await require('../../../index').initializeApp(); + const passportCookie = await login.loginAnonymous(app); + cookie = `${passportCookie.name}=${passportCookie.value}`; + }); + + async function post(path, body, status = 200) { + const response = await request(app).post(path).set('Cookie', cookie).send(body).expect(status); + return response.body; + } + + async function get(path) { + return (await request(app).get(path).set('Cookie', cookie).expect(200)).body; + } + + async function createTrack(objects) { + const track = await post( + '/api/release-tracks/new', + { name: `Review ${randomUUID().replaceAll('-', '')}`, type: 'standard' }, + 201, + ); + await post(`/api/release-tracks/${track.id}/candidates`, { + object_refs: objects.map((object) => ({ + id: object.stix.id, + modified: object.stix.modified, + })), + }); + return track; + } + + it('rejects an incomplete staged batch without reviewing its valid companion', async function () { + const valid = await post('/api/campaigns', campaign(), 201); + const invalid = await post('/api/campaigns', campaign(false), 201); + const track = await createTrack([valid, invalid]); + await post( + `/api/release-tracks/${track.id}/candidates/promote`, + { + object_refs: [valid.stix.id, invalid.stix.id], + }, + 400, + ); + const snapshot = await get(`/api/release-tracks/${track.id}/snapshots/latest`); + expect(snapshot.staged).toEqual([]); + expect(snapshot.candidates.map((entry) => entry.object_ref).sort()).toEqual( + [valid.stix.id, invalid.stix.id].sort(), + ); + for (const object of [valid, invalid]) { + const stored = await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`); + expect(stored.workspace.workflow.state).toBe('work-in-progress'); + } + }); + + it('reviews a valid staged revision without changing its STIX or other workflow metadata', async function () { + const object = await post('/api/campaigns', campaign(), 201); + const track = await createTrack([object]); + const before = await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`); + expect(before.workspace.workflow.state).toBe('work-in-progress'); + await post(`/api/release-tracks/${track.id}/candidates/promote`, { + object_refs: [object.stix.id], + }); + const after = await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`); + expect(after.stix).toEqual(before.stix); + expect(after.workspace.workflow).toEqual({ ...before.workspace.workflow, state: 'reviewed' }); + const snapshot = await get(`/api/release-tracks/${track.id}/snapshots/latest`); + expect(snapshot.staged.map((entry) => entry.object_ref)).toEqual([object.stix.id]); + }); + + it('blocks a legacy incomplete member before tagging the snapshot', async function () { + const object = await post('/api/campaigns', campaign(false), 201); + const track = await createTrack([object]); + const source = await snapshotService.getLatestSnapshot(track.id); + await dynamicRepo.updateSnapshot(track.id, source.modified, { + $set: { + candidates: [], + members: [{ object_ref: object.stix.id, object_modified: object.stix.modified }], + }, + }); + await post(`/api/release-tracks/${track.id}/snapshots/latest/release`, {}, 400); + const snapshot = await get(`/api/release-tracks/${track.id}/snapshots/latest`); + expect(snapshot.version).toBeNull(); + expect( + (await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`)).workspace + .workflow.state, + ).toBe('work-in-progress'); + }); + + it('reviews a valid legacy member on release', async function () { + const object = await post('/api/campaigns', campaign(), 201); + const track = await createTrack([object]); + const source = await snapshotService.getLatestSnapshot(track.id); + await dynamicRepo.updateSnapshot(track.id, source.modified, { + $set: { + candidates: [], + members: [{ object_ref: object.stix.id, object_modified: object.stix.modified }], + }, + }); + const released = await post(`/api/release-tracks/${track.id}/snapshots/latest/release`, {}); + expect(released.version).not.toBeNull(); + const stored = await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`); + expect(stored.workspace.workflow.state).toBe('reviewed'); + expect(stored.stix).toEqual(object.stix); + }); + + it('rejects member materialization before storing a replacement draft', async function () { + const object = await post('/api/campaigns', campaign(false), 201); + const track = await createTrack([object]); + const source = await snapshotService.getLatestSnapshot(track.id); + await expect( + snapshotService.cloneSnapshot(track.id, source, { + candidates: [], + members: [{ object_ref: object.stix.id, object_modified: object.stix.modified }], + }), + ).rejects.toMatchObject({ message: 'ADM validation failed' }); + const after = await snapshotService.getLatestSnapshot(track.id); + expect(after.modified).toEqual(source.modified); + expect(after.members).toEqual([]); + }); + + it('honors a warning bypass while admitting and reviewing the revision', async function () { + const rule = await post( + '/api/config/validation-bypasses', + { + fieldPath: ['description'], + errorCode: 'invalid_type', + stixType: 'campaign', + warningMessage: 'Campaign description temporarily waived', + }, + 201, + ); + try { + const object = await post('/api/campaigns', campaign(false), 201); + const track = await createTrack([object]); + const snapshot = await post(`/api/release-tracks/${track.id}/candidates/promote`, { + object_refs: [object.stix.id], + }); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ object_ref: object.stix.id, message: rule.warningMessage }), + ]), + ); + const stored = await get(`/api/campaigns/${object.stix.id}/modified/${object.stix.modified}`); + expect(stored.workspace.workflow.state).toBe('reviewed'); + expect(stored.stix.description).toBeUndefined(); + } finally { + await request(app) + .delete(`/api/config/validation-bypasses/${rule._id}`) + .set('Cookie', cookie) + .expect(204); + } + }); + + it('reviews relationship revisions through their owning collection', async function () { + const source = await post('/api/campaigns', campaign(), 201); + const target = await post( + '/api/software', + { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { type: 'tool', spec_version: '2.1', name: 'Admission target' }, + }, + 201, + ); + const relationship = await post( + '/api/relationships', + { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'uses', + source_ref: source.stix.id, + target_ref: target.stix.id, + description: 'Campaign uses the tool.', + created_by_ref: source.stix.created_by_ref, + object_marking_refs: source.stix.object_marking_refs, + x_mitre_attack_spec_version: '3.3.0', + x_mitre_modified_by_ref: source.stix.x_mitre_modified_by_ref, + }, + }, + 201, + ); + const track = await createTrack([relationship]); + await post(`/api/release-tracks/${track.id}/candidates/promote`, { + object_refs: [relationship.stix.id], + }); + const stored = await get( + `/api/relationships/${relationship.stix.id}/modified/${relationship.stix.modified}`, + ); + expect(stored.workspace.workflow.state).toBe('reviewed'); + expect(stored.stix).toEqual(relationship.stix); + }); + + it('reviews only the selected revision when the object has a newer revision', async function () { + const older = await post('/api/campaigns', campaign(), 201); + const next = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { ...older.stix, modified: '2026-01-03T00:00:00.000Z' }, + }; + const newer = await post('/api/campaigns', next, 201); + const track = await createTrack([older]); + await post(`/api/release-tracks/${track.id}/candidates/promote`, { + object_refs: [older.stix.id], + }); + const selected = await get(`/api/campaigns/${older.stix.id}/modified/${older.stix.modified}`); + const unselected = await get(`/api/campaigns/${newer.stix.id}/modified/${newer.stix.modified}`); + expect(selected.workspace.workflow.state).toBe('reviewed'); + expect(unselected.workspace.workflow.state).toBe('work-in-progress'); + expect(unselected.stix).toEqual(newer.stix); + }); +}); diff --git a/app/tests/api/release-tracks/snapshot-creation-causes.spec.js b/app/tests/api/release-tracks/snapshot-creation-causes.spec.js index 508de922..0b693f35 100644 --- a/app/tests/api/release-tracks/snapshot-creation-causes.spec.js +++ b/app/tests/api/release-tracks/snapshot-creation-causes.spec.js @@ -201,7 +201,18 @@ describe('Snapshot creation causes', function () { '/api/techniques', { workspace: { workflow: { state: 'work-in-progress' } }, - stix: { type: 'attack-pattern', spec_version: '2.1', name: 'Creation Cause Technique' }, + stix: { + type: 'attack-pattern', + spec_version: '2.1', + name: 'Creation Cause Technique', + description: 'Technique used to verify workflow snapshot provenance.', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['Windows'], + x_mitre_is_subtechnique: false, + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + }, }, 201, ); diff --git a/app/tests/api/release-tracks/tagged-content-immutability.spec.js b/app/tests/api/release-tracks/tagged-content-immutability.spec.js index 69810775..d4d01bee 100644 --- a/app/tests/api/release-tracks/tagged-content-immutability.spec.js +++ b/app/tests/api/release-tracks/tagged-content-immutability.spec.js @@ -31,6 +31,8 @@ function buildTechnique(name, previous) { kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], }, }; } diff --git a/app/tests/api/release-tracks/virtual-deduplication.spec.js b/app/tests/api/release-tracks/virtual-deduplication.spec.js index 64b70c76..e28d6293 100644 --- a/app/tests/api/release-tracks/virtual-deduplication.spec.js +++ b/app/tests/api/release-tracks/virtual-deduplication.spec.js @@ -72,12 +72,14 @@ describe('Virtual release-track deduplication API', function () { id: previous?.stix.id, created: previous?.stix.created || timestamp, modified: timestamp, + created_by_ref: previous?.stix.created_by_ref, name, description: `${name} description`, spec_version: '2.1', type: 'course-of-action', labels: ['test'], x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], object_marking_refs: [staticMarkingDefinitionId], }, }; diff --git a/app/tests/api/release-tracks/virtual-domain-filters.spec.js b/app/tests/api/release-tracks/virtual-domain-filters.spec.js index 71c798e3..193559dc 100644 --- a/app/tests/api/release-tracks/virtual-domain-filters.spec.js +++ b/app/tests/api/release-tracks/virtual-domain-filters.spec.js @@ -7,12 +7,15 @@ const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); const { cloneForCreate } = require('../../shared/clone-for-create'); const { releaseExactMembers } = require('./release-track-test-helpers'); +const modelFactory = require('../../../models/release-tracks/model-factory'); +const virtualTrackService = require('../../../services/release-tracks/virtual-track-service'); const staticMarkingDefinitionId = 'marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9'; describe('Virtual Release Track Domain Filters API', function () { let app; let passportCookie; + let tacticId; before(async function () { await database.initializeConnection(); @@ -23,6 +26,16 @@ describe('Virtual Release Track Domain Filters API', function () { app = await require('../../../index').initializeApp(); passportCookie = await login.loginAnonymous(app); + const tactic = await post('/api/tactics', { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'x-mitre-tactic', + spec_version: '2.1', + name: 'Initial Access', + x_mitre_shortname: 'initial-access', + }, + }); + tacticId = tactic.stix.id; }); async function post(path, body, status = 201) { @@ -68,12 +81,14 @@ describe('Virtual Release Track Domain Filters API', function () { external_references: [{ source_name: 'test-source', external_id: externalDomain }], object_marking_refs: [staticMarkingDefinitionId], x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + tactic_refs: [tacticId], }, }; } - async function createVirtualSnapshot(name, componentTrackId, domains) { - const virtual = await post('/api/release-tracks/new', { + async function createVirtual(name, componentTrackId, domains) { + return post('/api/release-tracks/new', { name, type: 'virtual', composition: { @@ -88,6 +103,10 @@ describe('Virtual Release Track Domain Filters API', function () { deduplication: { strategy: 'prioritize_latest_object' }, }, }); + } + + async function createVirtualSnapshot(name, componentTrackId, domains) { + const virtual = await createVirtual(name, componentTrackId, domains); return post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}); } @@ -111,7 +130,7 @@ describe('Virtual Release Track Domain Filters API', function () { const noDomain = await post('/api/mitigations', buildMitigation('No Domain Member', undefined)); const enterpriseMatrix = await post( '/api/matrices', - buildMatrix('Domainless Enterprise Matrix', 'enterprise-attack'), + buildMatrix('Enterprise Matrix', 'enterprise-attack'), ); const component = await post('/api/release-tracks/new', { @@ -123,9 +142,21 @@ describe('Virtual Release Track Domain Filters API', function () { ics, shared, mobile, - noDomain, enterpriseMatrix, ]); + // Simulate a legacy member that predates reviewed-state admission. It must + // remain domainless so domain filtering, not admission, excludes it. + await modelFactory.getModel(component.id).collection.updateOne( + { id: component.id, version: '1.0' }, + { + $push: { + members: { + object_ref: noDomain.stix.id, + object_modified: new Date(noDomain.stix.modified), + }, + }, + }, + ); // A newer revision has a different domain, but virtual composition must // evaluate the exact revision pinned in the tagged component snapshot. @@ -166,6 +197,51 @@ describe('Virtual Release Track Domain Filters API', function () { expect(mobileIds).not.toContain(ics.stix.id); }); + it('selects a legacy domainless matrix by fallback but rejects its new admission', async function () { + const matrixData = buildMatrix('Domainless Enterprise Matrix', 'enterprise-attack'); + delete matrixData.stix.x_mitre_domains; + const matrix = await post('/api/matrices', matrixData); + const component = await post('/api/release-tracks/new', { + name: 'Legacy Matrix Domain Component', + type: 'standard', + }); + await post( + `/api/release-tracks/${component.id}/snapshots/latest/release`, + { version: '1.0' }, + 200, + ); + // Install old membership directly: current admission must not accept this + // deliberately incomplete matrix merely to exercise its domain fallback. + await modelFactory.getModel(component.id).collection.updateOne( + { id: component.id, version: '1.0' }, + { + $push: { + members: { + object_ref: matrix.stix.id, + object_modified: new Date(matrix.stix.modified), + }, + }, + }, + ); + + const enterprise = await createVirtual('Legacy Matrix Enterprise Virtual', component.id, [ + 'enterprise', + ]); + await expect(virtualTrackService.createVirtualSnapshot(enterprise.id)).rejects.toMatchObject({ + message: 'ADM validation failed', + details: [ + expect.objectContaining({ + object_ref: matrix.stix.id, + object_modified: matrix.stix.modified, + path: ['x_mitre_domains'], + }), + ], + }); + + const ics = await createVirtualSnapshot('Legacy Matrix ICS Virtual', component.id, ['ics']); + expect(ics.members).toEqual([]); + }); + after(async function () { await database.closeConnection(); }); diff --git a/app/tests/api/release-tracks/virtual-graph-integrity.spec.js b/app/tests/api/release-tracks/virtual-graph-integrity.spec.js index 0867aa32..a1f21193 100644 --- a/app/tests/api/release-tracks/virtual-graph-integrity.spec.js +++ b/app/tests/api/release-tracks/virtual-graph-integrity.spec.js @@ -62,6 +62,7 @@ describe('Virtual release-track graph integrity', function () { type: 'attack-pattern', object_marking_refs: [staticMarkingDefinitionId], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + x_mitre_version: '1.0', x_mitre_domains: domains, x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], @@ -81,6 +82,7 @@ describe('Virtual release-track graph integrity', function () { spec_version: '2.1', type: 'course-of-action', object_marking_refs: [staticMarkingDefinitionId], + x_mitre_version: '1.0', x_mitre_domains: domains, }, }; diff --git a/app/tests/api/release-tracks/virtual-object-type-filters.spec.js b/app/tests/api/release-tracks/virtual-object-type-filters.spec.js index 9353d49e..1dac46a7 100644 --- a/app/tests/api/release-tracks/virtual-object-type-filters.spec.js +++ b/app/tests/api/release-tracks/virtual-object-type-filters.spec.js @@ -113,12 +113,13 @@ describe('Virtual release-track object-type filters API', function () { type: 'course-of-action', labels: ['test'], x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], object_marking_refs: [staticMarkingDefinitionId], }, }; } - function buildMatrix(name) { + function buildMatrix(name, tacticId) { const timestamp = new Date().toISOString(); return { workspace: { workflow: { state: 'work-in-progress' } }, @@ -132,6 +133,8 @@ describe('Virtual release-track object-type filters API', function () { external_references: [{ source_name: 'test-source', external_id: 'enterprise-attack' }], object_marking_refs: [staticMarkingDefinitionId], x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + tactic_refs: [tacticId], }, }; } @@ -201,7 +204,16 @@ describe('Virtual release-track object-type filters API', function () { it('filters members without replacing the revision pinned by the tagged component', async function () { const mitigation = await post('/api/mitigations', buildMitigation('Pinned Type Member')); - const matrix = await post('/api/matrices', buildMatrix('Excluded Type Member')); + const tactic = await post('/api/tactics', { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'x-mitre-tactic', + spec_version: '2.1', + name: 'Initial Access', + x_mitre_shortname: 'initial-access', + }, + }); + const matrix = await post('/api/matrices', buildMatrix('Excluded Type Member', tactic.stix.id)); await releaseExactMembers(app, passportCookie, componentTrack.id, [mitigation, matrix]); diff --git a/app/tests/api/release-tracks/virtual-quarantine.spec.js b/app/tests/api/release-tracks/virtual-quarantine.spec.js index fcb18ee7..915d2b32 100644 --- a/app/tests/api/release-tracks/virtual-quarantine.spec.js +++ b/app/tests/api/release-tracks/virtual-quarantine.spec.js @@ -22,12 +22,15 @@ function buildTechnique(name, previous) { id: previous?.stix.id, created: previous?.stix.created || timestamp, modified: timestamp, + created_by_ref: previous?.stix.created_by_ref, name, description: `${name} description`, spec_version: '2.1', type: 'attack-pattern', object_marking_refs: [staticMarkingDefinitionId], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], x_mitre_is_subtechnique: false, x_mitre_platforms: ['Windows'], }, diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index 51cd890d..4490d960 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -25,6 +25,41 @@ The three workflow states tracked per release track: 2. **awaiting-review** - Object is complete and waiting for team review 3. **reviewed** - Object has been reviewed and approved, ready for release +#### Temporary legacy ADM admission gate + +Admission to `staged` or `members` now also uses the existing revision-global +`workspace.workflow.state`. Before writing the tier transition, the API +validates the selected object revisions as `reviewed`, using the existing ADM +configuration and validation bypass rules. If validation succeeds, it sets +only their legacy workflow state to `reviewed`. STIX content, `stix.modified`, +and other workflow metadata are unchanged. Protected `static` objects retain +their state. + +This applies to explicit and automatic staging, snapshot member writes, +track cloning, and release commits. A release commit checks every planned +member, including legacy members that were never marked reviewed. Candidates +alone do not change legacy state. Lowering the candidacy threshold does not +relax this ADM check. Disabling ADM validation globally still disables it here. + +An ADM failure returns HTTP `400` with revision-specific `details` and prevents +the staged/member admission. No object in the rejected validation batch is +marked reviewed. Warning bypasses allow admission and return `warnings`. +Existing candidate/review operations may already have saved a candidate +snapshot before their subsequent automatic promotion fails. + +This is a temporary single-track-oriented workaround, **not track-scoped +validation state**. Reviewing a revision through one track changes its legacy +state for every track referencing it; demotion does not reset that state. +Legacy `"latest"` selectors remain dynamic: following a newer revision without +a tier transition does not itself rerun this gate. Release commit validates the +exact revisions selected at that time. Supporting graph objects and generated +collection envelopes are not validated by this gate. + +There is no startup migration or historical backfill. Workflow metadata writes +and snapshot writes are not one transaction: an infrastructure failure after +validation can leave validated revisions marked reviewed without a completed +tier transition. + ### Version Pinning Each tier entry includes a revision selector in `object_modified`: