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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/lib/event-constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
16 changes: 16 additions & 0 deletions app/repository/_base.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/services/meta-classes/base.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 }
Expand Down
80 changes: 80 additions & 0 deletions app/services/release-tracks/reviewed-state-service.js
Original file line number Diff line number Diff line change
@@ -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 };
12 changes: 12 additions & 0 deletions app/services/release-tracks/snapshot-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions app/services/release-tracks/versioning-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions app/services/stix/attack-objects-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/services/stix/relationships-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions app/services/system/validation-bypasses-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
},
});
Expand All @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions app/tests/api/release-tracks/draft-lifecycle.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
1 change: 1 addition & 0 deletions app/tests/api/release-tracks/publication-config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions app/tests/api/release-tracks/release-tracks-backrefs.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
6 changes: 6 additions & 0 deletions app/tests/api/release-tracks/release-tracks-bundle.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions app/tests/api/release-tracks/release-tracks-release.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
1 change: 1 addition & 0 deletions app/tests/api/release-tracks/release-tracks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
}
Expand Down
Loading
Loading