diff --git a/.nvmrc b/.nvmrc index 7d41c735..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.14.0 +22 diff --git a/Dockerfile b/Dockerfile index f6fca030..de322061 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22 +FROM node:22 AS base # Define build arguments ARG VERSION=dev @@ -26,15 +26,27 @@ WORKDIR /usr/src/app # where available (npm@5+) COPY package*.json ./ -# Install the app dependencies -RUN npm ci --only=production - -# Copy app source -COPY . . - # Set version as environment variable for runtime access ENV APP_VERSION=${VERSION} \ GIT_COMMIT=${REVISION} \ BUILD_DATE=${BUILDTIME} -CMD [ "npm", "start" ] \ No newline at end of file +FROM base AS dev + +# Install development dependencies. +RUN npm ci + +# Copy app source +COPY . . + +CMD [ "node", "./bin/www" ] + +FROM base AS production + +# Install the app dependencies +RUN npm ci --omit=dev + +# Copy app source +COPY . . + +CMD [ "npm", "start" ] diff --git a/app/api/definitions/components/stix-common.yml b/app/api/definitions/components/stix-common.yml index 72b8e24f..6e50517b 100644 --- a/app/api/definitions/components/stix-common.yml +++ b/app/api/definitions/components/stix-common.yml @@ -38,12 +38,22 @@ components: created: type: string format: date-time + description: >- + Creation timestamp. Authoring POSTs for an existing object preserve + the value from the latest stored revision and ignore client overrides. + Earlier revisions are not consulted for recovery. + Bundle imports retain the source value. modified: type: string format: date-time created_by_ref: type: string example: 'identity--6444f546-6900-4456-b3b1-015c88d70dab' + description: >- + Original creator identity. Authoring revisions preserve the value, + including its absence, from the latest stored revision and ignore + client overrides. Existing objects are not backfilled with the current + organization identity. Bundle imports retain source attribution. revoked: type: boolean example: false diff --git a/app/config/allowed-values.json b/app/config/allowed-values.json index 9d0cba74..3e8e1a98 100644 --- a/app/config/allowed-values.json +++ b/app/config/allowed-values.json @@ -27,17 +27,7 @@ }, { "domainName": "ics-attack", - "allowedValues": [ - "Field Controller/RTU/PLC/IED", - "Safety Instrumented System/Protection Relay", - "Control Server", - "Input/Output Server", - "Data Historian", - "Human-Machine Interface", - "Engineering Workstation", - "None", - "Windows" - ] + "allowedValues": ["Windows", "Linux", "Network Devices", "Embedded", "None"] } ] } diff --git a/app/lib/event-constants.js b/app/lib/event-constants.js index 5b6a2dc1..6bc62654 100644 --- a/app/lib/event-constants.js +++ b/app/lib/event-constants.js @@ -174,4 +174,6 @@ module.exports = Object.freeze({ // 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', + RELEASE_TRACK_OBJECT_DELETION_REQUESTED: 'release-track::object-deletion-requested', + RELEASE_TRACK_OBJECT_DELETION_COMPLETED: 'release-track::object-deletion-completed', }); diff --git a/app/repository/_base.repository.js b/app/repository/_base.repository.js index d75a1919..8c7431d2 100644 --- a/app/repository/_base.repository.js +++ b/app/repository/_base.repository.js @@ -187,6 +187,16 @@ class BaseRepository extends AbstractRepository { } } + async hasOtherVersions(stixId, modified) { + try { + return Boolean( + await this.model.exists({ 'stix.id': stixId, 'stix.modified': { $ne: modified } }).exec(), + ); + } catch (err) { + throw new DatabaseError(err); + } + } + async retrieveAllById(stixId) { try { // .lean() bypasses Mongoose toJSON/toObject transforms, so .select() @@ -697,13 +707,15 @@ class BaseRepository extends AbstractRepository { } /** - * Return every release-track ID present in denormalized object backrefs. - * Used only by administrative full-scan repair so deleted tracks with stale - * backrefs are included alongside registry-backed tracks. + * Return release-track IDs present in denormalized object backrefs. + * An optional STIX ID bounds deletion retry cleanup to the affected object; + * administrative full-scan repair omits it to include all stale backrefs. */ - async distinctReleaseTrackIds() { + async distinctReleaseTrackIds(stixId) { try { - return await this.model.distinct('workspace.release_tracks.id').exec(); + return await this.model + .distinct('workspace.release_tracks.id', stixId ? { 'stix.id': stixId } : {}) + .exec(); } catch (err) { throw new DatabaseError(err); } diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js index 495519cb..aa0453d4 100644 --- a/app/repository/release-tracks/release-track-dynamic.repository.js +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -503,6 +503,43 @@ class ReleaseTrackDynamicRepository { } } + /** + * Workflow selectors are mutable metadata, including on historical snapshots. + * Never change snapshot identity, members, or sealed content while retracting + * references to an object that is about to be deleted. + */ + async removeWorkflowObjectReferences(trackId, stixId, stixModified, hasSurvivingVersions) { + try { + const Model = this._getModel(trackId); + const pull = {}; + for (const tier of ['candidates', 'staged', 'quarantine']) { + const selector = { object_ref: stixId }; + if (stixModified !== undefined) { + const modified = new Date(stixModified); + // Quarantine entries always use exact revisions, unlike the two + // standard-track workflow tiers, which also allow dynamic selectors. + selector.object_modified = + hasSurvivingVersions || tier === 'quarantine' + ? modified + : { $in: [modified, 'latest'] }; + } + pull[tier] = selector; + } + return await Model.updateMany( + { + id: trackId, + $or: Object.entries(pull).map(([tier, selector]) => ({ + [tier]: { $elemMatch: selector }, + })), + }, + { $pull: pull }, + { runValidators: true }, + ).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + /** * Replace a tagged snapshot's content manifest. Used by administrative * source-attested reconstruction, which must name the manifest it expects diff --git a/app/repository/release-tracks/release-track-registry.repository.js b/app/repository/release-tracks/release-track-registry.repository.js index 6b79b9e3..6c2b39be 100644 --- a/app/repository/release-tracks/release-track-registry.repository.js +++ b/app/repository/release-tracks/release-track-registry.repository.js @@ -128,6 +128,20 @@ class ReleaseTrackRegistryRepository { } } + async findTrackBatch(afterTrackId) { + try { + return await this.model + .find(afterTrackId ? { track_id: { $gt: afterTrackId } } : {}) + .select({ _id: 0, track_id: 1 }) + .sort({ track_id: 1 }) + .limit(100) + .lean() + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async findVirtualTrackBatch(afterTrackId) { return this.model .find({ diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index 47aeb934..645bd3b3 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -39,6 +39,7 @@ const WorkflowResult = require('../../lib/workflow-result'); const graphWriteLock = require('../../lib/graph-write-lock'); const lifecycle = require('../stix/lifecycle-service'); const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); // Import required repositories const systemConfigurationRepository = require('../../repository/system-configurations-repository'); const identitiesRepository = require('../../repository/identities-repository'); @@ -107,8 +108,8 @@ class BaseService extends ServiceWithHooks { } /** - * Whether create() should stamp created_by_ref and x_mitre_modified_by_ref - * with the active organization identity. + * Whether create() should stamp brand-new objects' created_by_ref and each + * authoring revision's x_mitre_modified_by_ref with the active organization identity. * * Most ATT&CK objects are attributed to an organization identity. Identity * objects themselves opt out so first-time setup can create the placeholder @@ -365,10 +366,10 @@ class BaseService extends ServiceWithHooks { * * Note: Fields like `created_by_ref`, `x_mitre_modified_by_ref`, and `object_marking_refs` * are intentionally NOT here — they are only server-controlled in certain contexts - * (e.g., new objects only, specific STIX types) or use merge semantics (marking definitions). + * (e.g., authoring revisions, specific STIX types) or use merge semantics (marking definitions). * Their handling remains in the existing composition logic of create() and updateFull(). * - * Future additions: 'id', 'created', 'modified' (when server takes control of these) + * Existing-object created timestamps are preserved during create() composition. */ static ALWAYS_STRIPPED_STIX_FIELDS = ['x_mitre_attack_spec_version', 'revoked']; @@ -562,10 +563,8 @@ class BaseService extends ServiceWithHooks { // Determine if this is a new object or a new version of an existing object let existingVersion = null; if (data.stix?.id) { - // TODO change this to repository's get latest method - there should be a method for that - const existingVersions = await this.repository.retrieveAllById(data.stix.id); - if (existingVersions?.length > 0) { - existingVersion = existingVersions[0]; + existingVersion = await this.repository.retrieveLatestByStixIdLean(data.stix.id); + if (existingVersion) { logger.debug( `Found existing version(s) with stix.id: ${data.stix.id}, will reuse attack_id: ${existingVersion.workspace?.attack_id}`, ); @@ -668,14 +667,16 @@ class BaseService extends ServiceWithHooks { ? await this.retrieveOrganizationIdentityRef() : null; - // Check for an existing object (may differ from existingVersion if stix.id was just generated) - let existingObject; - if (data.stix.id) { - existingObject = await this.repository.retrieveLatestByStixId(data.stix.id); - } - + const existingObject = existingVersion; if (existingObject) { - // New version of an existing object — carry forward revoked status, set modified_by + // Preserve latest creation provenance; only an absent creator may be explicitly set. + data.stix.created = + existingObject.stix.created instanceof Date + ? existingObject.stix.created.toISOString() + : existingObject.stix.created; + if (existingObject.stix.created_by_ref !== undefined) { + data.stix.created_by_ref = existingObject.stix.created_by_ref; + } data.stix.revoked = existingObject.stix.revoked ?? false; if (organizationIdentityRef) { data.stix.x_mitre_modified_by_ref = organizationIdentityRef; @@ -1175,6 +1176,30 @@ class BaseService extends ServiceWithHooks { } } + async _prepareReleaseTrackDeletion(stixId, stixModified) { + // Standalone service callers need the same required listener as HTTP + // requests. The listener owns all release-track writes. + require('../release-tracks/object-deletion-service'); + const hasSurvivingVersions = + stixModified !== undefined && (await this.repository.hasOtherVersions(stixId, stixModified)); + const backrefTrackIds = await this.repository.distinctReleaseTrackIds(stixId); + const results = await EventBus.emitRequired( + Events.RELEASE_TRACK_OBJECT_DELETION_REQUESTED, + { stixId, stixModified, hasSurvivingVersions, backrefTrackIds }, + { minimumListeners: 1 }, + ); + return results.flatMap((result) => result.dynamicLatestTrackIds || []); + } + + async _completeReleaseTrackDeletion(trackIds) { + if (trackIds.length === 0) return; + await EventBus.emitRequired( + Events.RELEASE_TRACK_OBJECT_DELETION_COMPLETED, + { trackIds }, + { minimumListeners: 1 }, + ); + } + // TODO rename to deleteVersionByStixId and repurpose the existing name for deleting by the document's unique _id async deleteVersionById(stixId, stixModified) { if (!graphWriteLock.isHeld()) { @@ -1195,13 +1220,16 @@ class BaseService extends ServiceWithHooks { // Members-pinned revisions are released content — they must never be // deleted (the track's member entry would silently dangle). const existing = await this.repository.retrieveOneByVersion(stixId, stixModified); - if (!existing) { - return null; + if (existing) { + await BaseService.assertNotMemberPinned(existing, 'deleted'); + await BaseService.assertNotGraphPinned(existing, 'deleted'); } - await BaseService.assertNotMemberPinned(existing, 'deleted'); - await BaseService.assertNotGraphPinned(existing, 'deleted'); + const trackIds = await this._prepareReleaseTrackDeletion(stixId, stixModified); const document = await this.repository.findOneAndDelete(stixId, stixModified); + // Dynamic selectors must move to the surviving latest revision only after + // persistence. Also run on retries whose previous delete already succeeded. + await this._completeReleaseTrackDeletion(trackIds); if (!document) { //Note: document is null if not found @@ -1388,6 +1416,8 @@ class BaseService extends ServiceWithHooks { await BaseService.assertNoMemberPinnedVersions(stixId, memberPinned, 'deleted'); await BaseService.assertNoGraphPinnedVersions(stixId, 'deleted'); + await this._prepareReleaseTrackDeletion(stixId); + const result = await this.repository.deleteMany(stixId); if (result.deletedCount > 0) { await this.afterDeleteById(stixId, result); diff --git a/app/services/release-tracks/export-service.js b/app/services/release-tracks/export-service.js index 8f8d58c9..f26640a9 100644 --- a/app/services/release-tracks/export-service.js +++ b/app/services/release-tracks/export-service.js @@ -21,7 +21,7 @@ // app/lib/release-tracks/export-schemas.js for schema definitions. // ============================================================================= -const { v5: uuidv5 } = require('uuid'); +const { v4: uuidv4 } = require('uuid'); const logger = require('../../lib/logger'); const linkById = require('../../lib/linkById'); const primaryRevisionService = require('./primary-revision-service'); @@ -33,9 +33,6 @@ const { filesystemStoreTransformSchema, } = require('../../lib/release-tracks/export-schemas'); -// Namespace for deterministic draft bundle identifiers. -const DRAFT_BUNDLE_NAMESPACE = 'c1d8c0a6-6a3d-4f0a-9c9b-4d9d0c8a5f21'; - // ============================================================================= // Hydration // ============================================================================= @@ -91,10 +88,7 @@ function normalizeSourceBundleDefaults(documents, graph) { function bundleIdFor(snapshot) { if (snapshot.bundle_id) return snapshot.bundle_id; - return `bundle--${uuidv5( - `${snapshot.id}|${new Date(snapshot.modified).toISOString()}`, - DRAFT_BUNDLE_NAMESPACE, - )}`; + return `bundle--${uuidv4()}`; } // ============================================================================= diff --git a/app/services/release-tracks/object-deletion-service.js b/app/services/release-tracks/object-deletion-service.js new file mode 100644 index 00000000..db9985da --- /dev/null +++ b/app/services/release-tracks/object-deletion-service.js @@ -0,0 +1,79 @@ +'use strict'; + +const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); +const graphWriteLock = require('../../lib/graph-write-lock'); +const revisionReference = require('../../lib/release-tracks/revision-reference'); +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const reconciliationService = require('./reconciliation-service'); + +const WORKFLOW_TIERS = ['candidates', 'staged', 'quarantine']; + +async function prepareDeletion({ + stixId, + stixModified, + hasSurvivingVersions, + backrefTrackIds = [], +}) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => + prepareDeletion({ stixId, stixModified, hasSurvivingVersions, backrefTrackIds }), + ); + } + + const dynamicLatestTrackIds = []; + const backrefTracks = new Set(backrefTrackIds); + let afterTrackId; + for (;;) { + const tracks = await registryRepo.findTrackBatch(afterTrackId); + if (tracks.length === 0) break; + + for (const { track_id: trackId } of tracks) { + const result = await dynamicRepo.removeWorkflowObjectReferences( + trackId, + stixId, + stixModified, + hasSurvivingVersions, + ); + const removedReferences = result.modifiedCount > 0; + if (!removedReferences && !backrefTracks.has(trackId) && !hasSurvivingVersions) { + continue; + } + const snapshot = await dynamicRepo.getLatestSnapshot(trackId); + const retainsLatest = + hasSurvivingVersions && + WORKFLOW_TIERS.some((tier) => + (snapshot?.[tier] || []).some( + (entry) => + entry.object_ref === stixId && revisionReference.isLatest(entry.object_modified), + ), + ); + // Stale backrefs identify retries whose snapshot cleanup already landed. + // Do not turn object deletion into an unrelated full-registry repair. + if (removedReferences || backrefTracks.has(trackId) || retainsLatest) { + await reconciliationService.reconcileContentsChanged(trackId, snapshot); + } + if (retainsLatest) dynamicLatestTrackIds.push(trackId); + } + afterTrackId = tracks[tracks.length - 1].track_id; + } + return { dynamicLatestTrackIds }; +} + +async function completeDeletion({ trackIds }) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => completeDeletion({ trackIds })); + } + for (const trackId of trackIds) { + const snapshot = await dynamicRepo.getLatestSnapshot(trackId); + // The deleted revision no longer resolves as latest. Reuse the durable + // reconciliation workflow so failures are visible and repairable. + await reconciliationService.reconcileContentsChanged(trackId, snapshot); + } +} + +EventBus.on(Events.RELEASE_TRACK_OBJECT_DELETION_REQUESTED, prepareDeletion); +EventBus.on(Events.RELEASE_TRACK_OBJECT_DELETION_COMPLETED, completeDeletion); + +module.exports = { prepareDeletion, completeDeletion }; diff --git a/app/tests/api/campaigns/campaigns.spec.js b/app/tests/api/campaigns/campaigns.spec.js index a24fa178..cab29b8d 100644 --- a/app/tests/api/campaigns/campaigns.spec.js +++ b/app/tests/api/campaigns/campaigns.spec.js @@ -6,6 +6,7 @@ const { cloneForCreate } = require('../../shared/clone-for-create'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const Campaign = require('../../../models/campaign-model'); +const campaignsService = require('../../../services/stix/campaigns-service'); const markingDefinitionService = require('../../../services/stix/marking-definitions-service'); const systemConfigurationService = require('../../../services/system/system-configuration-service'); @@ -461,6 +462,167 @@ describe('Campaigns API', function () { expect(campaigns.length).toBe(0); }); + for (const provenance of ['omitted', 'tampered']) { + it(`POST /api/campaigns preserves stored creation provenance when ${provenance}`, async function () { + const originalBody = cloneForCreate(initialObjectData); + originalBody.stix.created = '2020-01-01T00:00:00.000Z'; + originalBody.stix.modified = originalBody.stix.created; + const original = await request(app) + .post('/api/campaigns') + .send(originalBody) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + const revision = cloneForCreate(original.body); + revision.stix.modified = '2020-02-01T00:00:00.000Z'; + revision.stix.description = 'An edited campaign.'; + if (provenance === 'omitted') { + delete revision.stix.created; + delete revision.stix.created_by_ref; + } else { + revision.stix.created = revision.stix.modified; + revision.stix.created_by_ref = initialObjectData.stix.created_by_ref; + expect(revision.stix.created_by_ref).not.toBe(original.body.stix.created_by_ref); + } + + const saved = await request(app) + .post('/api/campaigns') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.created).toBe(original.body.stix.created); + expect(saved.body.stix.created_by_ref).toBe(original.body.stix.created_by_ref); + expect(saved.body.stix.modified).toBe(revision.stix.modified); + expect(saved.body.stix.description).toBe(revision.stix.description); + + const persisted = await request(app) + .get(`/api/campaigns/${saved.body.stix.id}/modified/${saved.body.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(persisted.body.stix).toEqual(saved.body.stix); + + const historical = await request(app) + .get(`/api/campaigns/${original.body.stix.id}/modified/${original.body.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(historical.body.stix).toEqual(original.body.stix); + }); + } + + for (const [latestCreator, suppliedCreator] of [ + ['present', 'explicit'], + ['absent', 'omitted'], + ['absent', 'explicit'], + ]) { + it(`uses latest provenance with ${latestCreator} stored creator and ${suppliedCreator} input`, async function () { + const original = await request(app) + .post('/api/campaigns') + .send({ + ...cloneForCreate(initialObjectData), + stix: { + ...initialObjectData.stix, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-15T00:00:00.000Z', + }, + }) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + // Import may contain divergent provenance. Preserve it, without repairing history. + const imported = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + ...original.body.stix, + created: '2020-02-01T00:00:00.000Z', + modified: '2020-03-01T00:00:00.000Z', + aliases: ['Imported campaign alias'], + }, + }; + if (latestCreator === 'present') { + imported.stix.created_by_ref = initialObjectData.stix.created_by_ref; + } else { + delete imported.stix.created_by_ref; + } + await campaignsService.create(structuredClone(imported), { + import: true, + validateContents: true, + }); + const importedRevision = await request(app) + .get(`/api/campaigns/${imported.stix.id}/modified/${imported.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(importedRevision.body.stix).toEqual(imported.stix); + + const track = await request(app) + .post('/api/release-tracks/new') + .send({ name: `Campaign provenance ${latestCreator} ${suppliedCreator}`, type: 'standard' }) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + await request(app) + .post(`/api/release-tracks/${track.body.id}/candidates`) + .send({ object_refs: [{ id: imported.stix.id, modified: imported.stix.modified }] }) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + // Clone latest authored content; the ordinary POST composes provenance. + const revision = cloneForCreate(importedRevision.body); + revision.stix.modified = '2020-04-01T00:00:00.000Z'; + revision.stix.created = original.body.stix.created; + if (suppliedCreator === 'explicit') { + revision.stix.created_by_ref = original.body.stix.created_by_ref; + } else { + delete revision.stix.created_by_ref; + } + const saved = await request(app) + .post('/api/campaigns') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.aliases).toEqual([imported.stix.name, ...imported.stix.aliases]); + expect(saved.body.stix.created).toBe(imported.stix.created); + if (latestCreator === 'present') { + expect(saved.body.stix.created_by_ref).toBe(imported.stix.created_by_ref); + } else if (suppliedCreator === 'explicit') { + expect(saved.body.stix.created_by_ref).toBe(revision.stix.created_by_ref); + } else { + expect(saved.body.stix).not.toHaveProperty('created_by_ref'); + } + expect(saved.body.stix.modified).toBe(revision.stix.modified); + expect(saved.body.stix.x_mitre_modified_by_ref).toBe( + original.body.stix.x_mitre_modified_by_ref, + ); + + const persisted = await request(app) + .get(`/api/campaigns/${saved.body.stix.id}/modified/${saved.body.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(persisted.body.stix).toEqual(saved.body.stix); + const historical = await request(app) + .get(`/api/campaigns/${original.body.stix.id}/modified/${original.body.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(historical.body.stix).toEqual(original.body.stix); + const intermediate = await request(app) + .get(`/api/campaigns/${imported.stix.id}/modified/${imported.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(intermediate.body.stix).toEqual(imported.stix); + const history = await request(app) + .get(`/api/campaigns/${imported.stix.id}?versions=all`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(history.body).toHaveLength(3); + expect(saved.body.workspace.release_tracks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: track.body.id, tier: 'candidates' }), + ]), + ); + expect( + (intermediate.body.workspace.release_tracks || []).map((entry) => entry.id), + ).not.toContain(track.body.id); + }); + } + after(async function () { await database.closeConnection(); }); diff --git a/app/tests/api/relationships/relationships.spec.js b/app/tests/api/relationships/relationships.spec.js index ed4b16b5..674b93bf 100644 --- a/app/tests/api/relationships/relationships.spec.js +++ b/app/tests/api/relationships/relationships.spec.js @@ -9,6 +9,7 @@ const login = require('../../shared/login'); const { cloneForCreate } = require('../../shared/clone-for-create'); const Software = require('../../../models/software-model'); const Technique = require('../../../models/technique-model'); +const relationshipsService = require('../../../services/stix/relationships-service'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -300,10 +301,12 @@ describe('Relationships API', function () { }); let relationship1b; - it('POST /api/relationships should create a new version of a relationship with a duplicate stix.id but different stix.modified date', async function () { + it('POST /api/relationships preserves creation provenance omitted from a new revision', async function () { relationship1b = cloneForCreate(relationship1a); const timestamp = new Date().toISOString(); relationship1b.stix.modified = timestamp; + delete relationship1b.stix.created; + delete relationship1b.stix.created_by_ref; const body = relationship1b; const res = await request(app) .post('/api/relationships') @@ -316,13 +319,20 @@ describe('Relationships API', function () { // We expect to get the created relationship const relationship = res.body; expect(relationship).toBeDefined(); + expect(relationship.stix.created).toBe(relationship1a.stix.created); + expect(relationship.stix.created_by_ref).toBe(relationship1a.stix.created_by_ref); + expect(relationship.stix.modified).toBe(timestamp); + relationship1b = relationship; }); let relationship1c; - it('POST /api/relationships should create a new version of a relationship with a duplicate stix.id but different stix.modified date', async function () { + it('POST /api/relationships ignores tampered creation provenance on a new revision', async function () { relationship1c = cloneForCreate(relationship1a); const timestamp = new Date().toISOString(); relationship1c.stix.modified = timestamp; + relationship1c.stix.created = timestamp; + relationship1c.stix.created_by_ref = initialObjectData.stix.created_by_ref; + expect(relationship1c.stix.created_by_ref).not.toBe(relationship1a.stix.created_by_ref); const body = relationship1c; const res = await request(app) .post('/api/relationships') @@ -335,6 +345,10 @@ describe('Relationships API', function () { // We expect to get the created relationship const relationship = res.body; expect(relationship).toBeDefined(); + expect(relationship.stix.created).toBe(relationship1a.stix.created); + expect(relationship.stix.created_by_ref).toBe(relationship1a.stix.created_by_ref); + expect(relationship.stix.modified).toBe(timestamp); + relationship1c = relationship; }); it('GET /api/relationships returns the latest added relationship', async function () { @@ -692,6 +706,48 @@ describe('Relationships API', function () { expect(relationships.length).toBe(0); }); + it('ordinary POST initializes an explicit missing relationship creator without recovering older dates', async function () { + const original = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: structuredClone(relationship1a.stix), + }; + original.stix.created = '2019-01-01T00:00:00.000Z'; + original.stix.modified = '2020-01-01T00:00:00.000Z'; + await relationshipsService.create(structuredClone(original), { + import: true, + validateContents: true, + }); + const intermediate = structuredClone(original); + intermediate.stix.modified = '2021-01-01T00:00:00.000Z'; + intermediate.stix.created = intermediate.stix.modified; + delete intermediate.stix.created_by_ref; + await relationshipsService.create(structuredClone(intermediate), { + import: true, + validateContents: true, + }); + + const revision = cloneForCreate(intermediate); + revision.stix.modified = '2022-01-01T00:00:00.000Z'; + revision.stix.created = original.stix.created; + revision.stix.created_by_ref = original.stix.created_by_ref; + const saved = await request(app) + .post('/api/relationships') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.created).toBe(intermediate.stix.created); + expect(saved.body.stix.created_by_ref).toBe(revision.stix.created_by_ref); + expect(saved.body.stix.source_ref).toBe(intermediate.stix.source_ref); + expect(saved.body.stix.target_ref).toBe(intermediate.stix.target_ref); + for (const historical of [original, intermediate]) { + const stored = await request(app) + .get(`/api/relationships/${historical.stix.id}/modified/${historical.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(stored.body.stix).toEqual(historical.stix); + } + }); + after(async function () { await database.closeConnection(); }); diff --git a/app/tests/api/release-tracks/content-manifests.spec.js b/app/tests/api/release-tracks/content-manifests.spec.js index b012f65e..f302db6f 100644 --- a/app/tests/api/release-tracks/content-manifests.spec.js +++ b/app/tests/api/release-tracks/content-manifests.spec.js @@ -236,7 +236,6 @@ describe('Sealed release-track content manifests', function () { draftBundle.objects.find((object) => object.id === originalRelationship.stix.id).modified, ).toBe(originalRelationship.stix.modified); expect(draftBundle.id).not.toBe(released.bundle_id); - expect((await bundle(track.id, draft.modified)).id).toBe(draftBundle.id); }); it('reseals at commit so relationships added between releases ship and previews report them', async function () { 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 7da3c323..2be456a1 100644 --- a/app/tests/api/release-tracks/release-tracks-bundle.spec.js +++ b/app/tests/api/release-tracks/release-tracks-bundle.spec.js @@ -296,7 +296,9 @@ describe('Release Tracks Bundle Export API', function () { const bundle = await getBundle(`/api/release-tracks/${trackId}/snapshots/latest?format=bundle`); expect(bundle.type).toBe('bundle'); - expect(bundle.id).toMatch(/^bundle--/); + expect(bundle.id).toMatch( + /^bundle--[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); // STIX 2.1 removed spec_version from the bundle object expect(bundle.spec_version).toBeUndefined(); diff --git a/app/tests/api/release-tracks/reviewed-state.spec.js b/app/tests/api/release-tracks/reviewed-state.spec.js index 5c963547..6806b71c 100644 --- a/app/tests/api/release-tracks/reviewed-state.spec.js +++ b/app/tests/api/release-tracks/reviewed-state.spec.js @@ -10,6 +10,8 @@ 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'); +const AttackObject = require('../../../models/attack-object-model'); +const policyRepository = require('../../../repository/validation-policy-repository'); function campaign(complete = true) { return { @@ -88,6 +90,220 @@ describe('Legacy reviewed state on release-track admission', function () { return track; } + async function archivedObject(kind) { + const isTechnique = kind === 'revoked'; + const path = isTechnique ? '/api/techniques' : '/api/mitigations'; + const object = await post( + path, + { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: isTechnique ? 'attack-pattern' : 'course-of-action', + spec_version: '2.1', + name: `Archived ${kind} admission`, + description: 'Historical content retained in release-track membership.', + x_mitre_domains: ['enterprise-attack'], + object_marking_refs: ['marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9'], + x_mitre_version: '1.0', + ...(isTechnique + ? { + x_mitre_is_subtechnique: false, + x_mitre_platforms: ['Windows'], + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + } + : {}), + }, + }, + 201, + ); + // Model imported historical revisions without relaxing API creation validation. + await AttackObject.collection.updateOne( + { 'stix.id': object.stix.id, 'stix.modified': new Date(object.stix.modified) }, + { + $set: { + 'stix.revoked': kind !== 'deprecated', + 'stix.x_mitre_deprecated': kind !== 'revoked', + ...(isTechnique + ? { 'stix.x_mitre_platforms': ['Office 365'] } + : { 'stix.external_references.0.external_id': 'T1174' }), + }, + }, + ); + return { + path, + object: await get(`${path}/${object.stix.id}/modified/${object.stix.modified}`), + }; + } + + for (const kind of ['revoked', 'deprecated', 'revoked-and-deprecated']) { + it(`retains ${kind} legacy content through staging, virtual materialization, release and export`, async function () { + const { path, object } = await archivedObject(kind); + const reference = { object_ref: object.stix.id, object_modified: object.stix.modified }; + const component = await createTrack([object]); + await post(`/api/release-tracks/${component.id}/candidates/promote`, { + object_refs: [object.stix.id], + }); + const staged = await get(`/api/release-tracks/${component.id}/snapshots/latest`); + expect(staged.staged).toEqual([expect.objectContaining(reference)]); + const stored = await get(`${path}/${object.stix.id}/modified/${object.stix.modified}`); + expect(stored.stix).toEqual(object.stix); + expect(stored.workspace.workflow).toEqual({ + ...object.workspace.workflow, + state: 'reviewed', + }); + const componentRelease = await post( + `/api/release-tracks/${component.id}/snapshots/latest/release`, + {}, + ); + expect(componentRelease.members).toEqual([reference]); + const virtual = await post( + '/api/release-tracks/new', + { + name: `Virtual ${kind} ${randomUUID()}`.replaceAll('-', ''), + type: 'virtual', + composition: { + component_tracks: [ + { track_id: component.id, resolution_strategy: 'latest_preview', priority: 1 }, + ], + deduplication: { strategy: 'prioritize_latest_object' }, + }, + }, + 201, + ); + const draft = await post( + `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, + {}, + 201, + ); + expect(draft.members).toEqual([reference]); + const released = await post(`/api/release-tracks/${virtual.id}/snapshots/latest/release`, {}); + expect(released.members).toEqual([reference]); + expect(released.content_manifest_id).toBe(draft.content_manifest_id); + for (const snapshot of [draft, released]) { + const bundle = await get( + `/api/release-tracks/${virtual.id}/snapshots/${snapshot.modified}?format=bundle`, + ); + expect(bundle.objects.find((entry) => entry.id === object.stix.id)).toEqual(object.stix); + } + await request(app) + .delete(`${path}/${object.stix.id}/modified/${object.stix.modified}`) + .set('Cookie', cookie) + .expect(409); + + // The same legacy fields remain invalid on active content. A failed + // materialization must not replace the snapshot or its sealed manifest. + await AttackObject.collection.updateOne( + { 'stix.id': object.stix.id, 'stix.modified': new Date(object.stix.modified) }, + { $set: { 'stix.revoked': false, 'stix.x_mitre_deprecated': false } }, + ); + const before = await snapshotService.getLatestSnapshot(virtual.id); + const rejected = await post( + `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, + {}, + 400, + ); + expect(rejected.details).toContainEqual( + expect.objectContaining({ + ...reference, + path: + kind === 'revoked' + ? ['x_mitre_platforms', 0] + : ['external_references', 0, 'external_id'], + }), + ); + expect(await snapshotService.getLatestSnapshot(virtual.id)).toEqual(before); + }); + } + + for (const kind of ['revoked', 'deprecated']) { + for (const policyChange of ['disabled', 'different type']) { + it(`rejects ${kind} admission atomically when its exemption is ${policyChange}`, async function () { + const { path, object } = await archivedObject(kind); + const valid = await post('/api/campaigns', campaign(), 201); + const track = await createTrack([valid, object]); + const before = await snapshotService.getLatestSnapshot(track.id); + const baseline = (await policyRepository.readPolicy()).rules; + const exemption = baseline.find((rule) => rule.retirementStatus === kind); + const promotion = `/api/release-tracks/${track.id}/candidates/promote`; + const body = { object_refs: [valid.stix.id, object.stix.id] }; + try { + await policyRepository.mutateRules(() => ({ + rules: baseline.map((rule) => + rule === exemption + ? { + ...rule, + ...(policyChange === 'disabled' + ? { enabled: false } + : { stixTypes: ['malware'] }), + } + : rule, + ), + })); + const rejected = await post(`${promotion}?exemptionReport=summary`, body, 400); + expect(rejected.details).toContainEqual( + expect.objectContaining({ + object_ref: object.stix.id, + object_modified: object.stix.modified, + path: + kind === 'revoked' + ? ['x_mitre_platforms', 0] + : ['external_references', 0, 'external_id'], + }), + ); + expect(rejected.exemptionReport.evaluatedScope.byOutcome.invalid).toBe(1); + expect(rejected.exemptionReport.reportedExemptRevisions).toBe(0); + expect(await snapshotService.getLatestSnapshot(track.id)).toEqual(before); + for (const [endpoint, original] of [ + [path, object], + ['/api/campaigns', valid], + ]) { + const stored = await get( + `${endpoint}/${original.stix.id}/modified/${original.stix.modified}`, + ); + expect(stored.stix).toEqual(original.stix); + expect(stored.workspace.workflow).toEqual(original.workspace.workflow); + } + + // A matching type-scoped policy permits retry and reports the actual rule. + await policyRepository.mutateRules(() => ({ + rules: baseline.map((rule) => + rule === exemption ? { ...rule, stixTypes: [object.stix.type] } : rule, + ), + })); + const admitted = await post(`${promotion}?exemptionReport=summary`, body); + expect(admitted.exemptionReport.reportedExemptRevisions).toBe(1); + expect(admitted.exemptionReport.byRule).toContainEqual( + expect.objectContaining({ ruleId: String(exemption._id) }), + ); + const stored = await get(`${path}/${object.stix.id}/modified/${object.stix.modified}`); + expect(stored.stix).toEqual(object.stix); + expect(stored.workspace.workflow.state).toBe('reviewed'); + } finally { + await policyRepository.mutateRules(() => ({ rules: baseline })); + } + }); + } + } + + it('rejects a missing inactive revision before admission without falling back to another version', async function () { + const { object } = await archivedObject('deprecated'); + const track = await createTrack([object]); + const before = await snapshotService.getLatestSnapshot(track.id); + await AttackObject.collection.updateOne( + { 'stix.id': object.stix.id, 'stix.modified': new Date(object.stix.modified) }, + { $set: { 'stix.modified': new Date(new Date(object.stix.modified).getTime() + 1000) } }, + ); + const rejected = await post( + `/api/release-tracks/${track.id}/candidates/promote`, + { object_refs: [object.stix.id] }, + 409, + ); + expect(rejected.missing_references).toEqual([ + { object_ref: object.stix.id, object_modified: object.stix.modified }, + ]); + expect(await snapshotService.getLatestSnapshot(track.id)).toEqual(before); + }); + 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); diff --git a/app/tests/api/release-tracks/workflow-object-deletion.spec.js b/app/tests/api/release-tracks/workflow-object-deletion.spec.js new file mode 100644 index 00000000..9dfe129b --- /dev/null +++ b/app/tests/api/release-tracks/workflow-object-deletion.spec.js @@ -0,0 +1,409 @@ +'use strict'; + +const request = require('supertest'); +const { expect } = require('expect'); +const sinon = require('sinon'); + +const config = require('../../../config/config'); +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const login = require('../../shared/login'); +const { DatabaseError } = require('../../../exceptions'); +const AttackObject = require('../../../models/attack-object-model'); +const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../../repository/release-tracks/release-track-registry.repository'); +const { + ReleaseTrackContentManifest, + ReleaseTrackContentManifestEntry, +} = require('../../../models/release-tracks/release-track-content-manifest-model'); +const { releaseExactMembers } = require('./release-track-test-helpers'); + +const workflowTiers = ['candidates', 'staged', 'quarantine']; +const markingDefinitionId = 'marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'; + +function json(value) { + return JSON.parse(JSON.stringify(value)); +} + +function buildObject(type, name, previous) { + const modified = previous + ? new Date(new Date(previous.stix.modified).getTime() + 1000).toISOString() + : new Date().toISOString(); + return { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + id: previous?.stix.id, + ...(previous ? { created_by_ref: previous.stix.created_by_ref } : {}), + created: previous?.stix.created || modified, + modified, + type, + name, + ...(type === 'x-mitre-detection-strategy' ? {} : { description: `${name} description` }), + spec_version: '2.1', + object_marking_refs: [markingDefinitionId], + x_mitre_domains: ['enterprise-attack'], + x_mitre_version: '1.0', + }, + }; +} + +describe('Release-track workflow references after object deletion', function () { + let app; + let passportCookie; + let analytic; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + app = await require('../../../index').initializeApp(); + passportCookie = await login.loginAnonymous(app); + + const body = buildObject('x-mitre-analytic', 'Workflow Deletion Analytic'); + body.stix.x_mitre_platforms = ['Windows']; + analytic = await post('/api/analytics', body, 201); + }); + + after(async function () { + await database.closeConnection(); + }); + + function api(method, path, body, status) { + const call = request(app) + [method](path) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + if (body !== undefined) call.send(body); + return call.expect(status); + } + + async function post(path, body, status = 200) { + return (await api('post', path, body, status)).body; + } + + async function get(path, status = 200) { + return (await api('get', path, undefined, status)).body; + } + + async function createStrategy(name, previous) { + const body = buildObject('x-mitre-detection-strategy', name, previous); + body.stix.x_mitre_analytic_refs = [analytic.stix.id]; + return post('/api/detection-strategies', body, 201); + } + + async function createTrack(name, type = 'standard') { + return post('/api/release-tracks/new', { name, type }, 201); + } + + function revisionPath(object) { + return `/api/detection-strategies/${object.stix.id}/modified/${object.stix.modified}`; + } + + async function addCandidates(track, objects, dynamic = false) { + return post(`/api/release-tracks/${track.id}/candidates`, { + object_refs: objects.map((object) => ({ + id: object.stix.id, + ...(dynamic ? {} : { modified: object.stix.modified }), + })), + }); + } + + async function stage(track, objects, dynamic = false) { + await addCandidates(track, objects, dynamic); + return post(`/api/release-tracks/${track.id}/candidates/promote`, { + object_refs: objects.map((object) => object.stix.id), + }); + } + + async function seedQuarantine(track, objects, sourceTrack) { + // Isolate workflow cleanup from component member/manifest protection. + // These fixtures represent retained virtual workflow metadata, not a new + // composition whose component members would correctly prevent deletion. + const snapshot = await dynamicRepo.getLatestSnapshot(track.id); + await dynamicRepo.updateSnapshot(track.id, snapshot.modified, { + $set: { + quarantine: objects.map((object) => ({ + object_ref: object.stix.id, + object_modified: object.stix.modified, + source_track_id: sourceTrack.id, + source_track_name: sourceTrack.name, + conflict_reason: 'conflicting object revisions', + })), + }, + }); + } + + async function retainHistoricalSnapshot(track, overrides = {}) { + // Normal standard writes roll one draft. Persist a separate successor to + // exercise retained historical metadata without releasing workflow refs. + const current = await dynamicRepo.getLatestSnapshot(track.id); + const next = { ...current, ...overrides }; + delete next._id; + delete next.__v; + next.modified = new Date(new Date(current.modified).getTime() + 1000); + await dynamicRepo.saveSnapshot(track.id, next); + const snapshots = await dynamicRepo.getAllSnapshots(track.id); + await registryRepo.updateByTrackId(track.id, { + latest_snapshot_modified: next.modified, + snapshot_count: snapshots.pagination.total, + }); + } + + async function capture(track) { + return json({ + snapshots: (await dynamicRepo.getAllSnapshots(track.id)).data, + manifests: await ReleaseTrackContentManifest.find({ track_id: track.id }) + .sort({ manifest_id: 1 }) + .lean() + .exec(), + entries: await ReleaseTrackContentManifestEntry.find({ track_id: track.id }) + .sort({ manifest_id: 1, revision_key: 1, kind: 1 }) + .lean() + .exec(), + }); + } + + async function expectCleanup(track, before, object, { exactOnly = false } = {}) { + const expected = structuredClone(before); + for (const snapshot of expected.snapshots) { + for (const tier of workflowTiers) { + if (!snapshot[tier]) continue; + snapshot[tier] = snapshot[tier].filter((entry) => { + if (entry.object_ref !== object.stix.id) return true; + return exactOnly && entry.object_modified !== object.stix.modified; + }); + } + } + // Exact document equality also protects members, snapshot IDs/timestamps, + // release versions, manifest pointers, and unrelated workflow metadata. + expect(await capture(track)).toEqual(expected); + for (const selector of ['latest', ...expected.snapshots.map((snapshot) => snapshot.modified)]) { + const snapshot = await get( + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(selector)}`, + ); + for (const tier of workflowTiers) { + const deleted = (snapshot[tier] || []).filter( + (entry) => + entry.object_ref === object.stix.id && + (!exactOnly || entry.object_modified === object.stix.modified), + ); + expect(deleted).toEqual([]); + } + } + } + + it('keeps latest and historical snapshots readable after deleting a candidate detection strategy', async function () { + const deleted = await createStrategy('Deleted Candidate Strategy'); + const retained = await createStrategy('Retained Candidate Strategy'); + const member = await createStrategy('Retained Released Strategy'); + const track = await createTrack('Candidate Strategy Deletion'); + await releaseExactMembers(app, passportCookie, track.id, [member]); + await addCandidates(track, [deleted, retained]); + await retainHistoricalSnapshot(track); + const before = await capture(track); + + await api('delete', `/api/detection-strategies/${deleted.stix.id}`, undefined, 204); + + await get(revisionPath(deleted), 404); + await expectCleanup(track, before, deleted); + const retainedAfter = await get(revisionPath(retained)); + expect(retainedAfter.workspace.release_tracks).toContainEqual({ + id: track.id, + type: 'standard', + tier: 'candidates', + status: 'work-in-progress', + }); + }); + + it('cleans every workflow tier and registered track even without current object backrefs', async function () { + const deleted = await createStrategy('Deleted Across Workflow Tiers'); + const retained = await createStrategy('Retained Across Workflow Tiers'); + const candidates = await createTrack('Historical Candidate Deletion'); + const staged = await createTrack('Staged Strategy Deletion'); + const quarantine = await createTrack('Quarantined Strategy Deletion', 'virtual'); + await addCandidates(candidates, [deleted, retained]); + const current = await dynamicRepo.getLatestSnapshot(candidates.id); + await retainHistoricalSnapshot(candidates, { + candidates: current.candidates.filter((entry) => entry.object_ref !== deleted.stix.id), + }); + await stage(staged, [deleted, retained]); + await retainHistoricalSnapshot(staged); + await seedQuarantine(quarantine, [deleted, retained], candidates); + await retainHistoricalSnapshot(quarantine); + + // Backrefs describe current snapshots only and can be absent after imports. + // They must not be the index used to discover historical workflow refs. + await AttackObject.collection.updateMany( + { 'stix.id': deleted.stix.id }, + { $unset: { 'workspace.release_tracks': '' } }, + ); + const tracks = [candidates, staged, quarantine]; + const before = await Promise.all(tracks.map(capture)); + + await api('delete', `/api/detection-strategies/${deleted.stix.id}`, undefined, 204); + + await get(revisionPath(deleted), 404); + for (let index = 0; index < tracks.length; index++) { + await expectCleanup(tracks[index], before[index], deleted); + } + await get(revisionPath(retained)); + }); + + for (const deletedRevision of ['newest', 'older']) { + it(`deletes only the ${deletedRevision} exact revision and reconciles retained latest selectors`, async function () { + const older = await createStrategy(`Exact Revision ${deletedRevision} A`); + const newer = await createStrategy(`Exact Revision ${deletedRevision} B`, older); + const deleted = deletedRevision === 'newest' ? newer : older; + const survivor = deletedRevision === 'newest' ? older : newer; + const keptExact = await createTrack(`Kept Exact ${deletedRevision}`); + const removedExact = await createTrack(`Removed Exact ${deletedRevision}`); + const latestCandidate = await createTrack(`Latest Candidate ${deletedRevision}`); + const latestStaged = await createTrack(`Latest Staged ${deletedRevision}`); + const quarantine = await createTrack(`Exact Quarantine ${deletedRevision}`, 'virtual'); + await addCandidates(keptExact, [survivor]); + await stage(removedExact, [deleted]); + await addCandidates(latestCandidate, [newer], true); + await stage(latestStaged, [newer], true); + await seedQuarantine(quarantine, [older, newer], keptExact); + const tracks = [keptExact, removedExact, latestCandidate, latestStaged, quarantine]; + for (const track of tracks) await retainHistoricalSnapshot(track); + const before = await Promise.all(tracks.map(capture)); + + await api('delete', revisionPath(deleted), undefined, 204); + + await get(revisionPath(deleted), 404); + for (let index = 0; index < tracks.length; index++) { + await expectCleanup(tracks[index], before[index], deleted, { exactOnly: true }); + } + const survivingObject = await get(revisionPath(survivor)); + expect(survivingObject.workspace.release_tracks).toEqual( + expect.arrayContaining([ + { id: keptExact.id, type: 'standard', tier: 'candidates', status: 'work-in-progress' }, + { + id: latestCandidate.id, + type: 'standard', + tier: 'candidates', + status: 'work-in-progress', + }, + { id: latestStaged.id, type: 'standard', tier: 'staged', status: 'work-in-progress' }, + ]), + ); + expect( + survivingObject.workspace.release_tracks.some((entry) => entry.id === removedExact.id), + ).toBe(false); + const versions = await get(`/api/detection-strategies/${survivor.stix.id}?versions=all`); + expect(versions.map((version) => version.stix.modified)).toEqual([survivor.stix.modified]); + for (const [track, tier] of [ + [latestCandidate, 'candidates'], + [latestStaged, 'staged'], + ]) { + const snapshot = await get(`/api/release-tracks/${track.id}/snapshots/latest`); + expect(snapshot[tier]).toEqual([ + expect.objectContaining({ object_ref: survivor.stix.id, object_modified: 'latest' }), + ]); + } + }); + } + + it('removes dynamic latest selectors when the final revision is deleted', async function () { + const deleted = await createStrategy('Last Revision Strategy'); + const retained = await createStrategy('Unrelated Latest Strategy'); + const candidateTrack = await createTrack('Last Revision Candidate'); + const stagedTrack = await createTrack('Last Revision Staged'); + await addCandidates(candidateTrack, [deleted, retained], true); + await stage(stagedTrack, [deleted, retained], true); + const tracks = [candidateTrack, stagedTrack]; + for (const track of tracks) await retainHistoricalSnapshot(track); + const before = await Promise.all(tracks.map(capture)); + + await api('delete', revisionPath(deleted), undefined, 204); + + await get(revisionPath(deleted), 404); + for (let index = 0; index < tracks.length; index++) { + await expectCleanup(tracks[index], before[index], deleted); + } + expect((await get(revisionPath(retained))).workspace.release_tracks).toHaveLength(2); + }); + + for (const failedOperation of ['removeWorkflowObjectReferences', 'getLatestSnapshot']) { + it(`fails DELETE when workflow cleanup ${failedOperation} fails`, async function () { + const object = await createStrategy(`Failed Cleanup ${failedOperation}`); + const track = await createTrack(`Failed Cleanup ${failedOperation}`); + await addCandidates(track, [object]); + const before = await capture(track); + const failure = sinon + .stub(dynamicRepo, failedOperation) + .rejects(new DatabaseError(new Error('injected workflow cleanup failure'))); + + try { + await api('delete', `/api/detection-strategies/${object.stix.id}`, undefined, 500); + expect(failure.called).toBe(true); + } finally { + failure.restore(); + } + + expect((await get(revisionPath(object))).stix).toEqual(object.stix); + const after = await capture(track); + expect(after.manifests).toEqual(before.manifests); + expect(after.entries).toEqual(before.entries); + if (failedOperation === 'removeWorkflowObjectReferences') { + expect(after.snapshots).toEqual(before.snapshots); + } + await get(`/api/release-tracks/${track.id}/snapshots/latest`); + }); + } + + for (const protection of ['member', 'manifest']) { + it(`rejects ${protection}-protected deletion before changing any workflow references`, async function () { + const protectedObject = await createStrategy(`Protected ${protection} Strategy`); + const releaseTrack = await createTrack(`Protected ${protection} Release`); + let root = protectedObject; + if (protection === 'manifest') { + const body = buildObject('attack-pattern', 'Manifest Link Root'); + body.stix.description = `See (LinkById: ${protectedObject.workspace.attack_id}).`; + body.stix.x_mitre_is_subtechnique = false; + body.stix.x_mitre_platforms = ['Windows']; + body.stix.kill_chain_phases = [ + { kill_chain_name: 'mitre-attack', phase_name: 'persistence' }, + ]; + root = await post('/api/techniques', body, 201); + } + await releaseExactMembers(app, passportCookie, releaseTrack.id, [root]); + const workflowTrack = await createTrack(`Protected ${protection} Workflow`); + await addCandidates(workflowTrack, [protectedObject]); + await retainHistoricalSnapshot(workflowTrack); + const tracks = [releaseTrack, workflowTrack]; + const before = await Promise.all(tracks.map(capture)); + if (protection === 'manifest') { + expect(before[0].entries).toContainEqual( + expect.objectContaining({ + kind: 'link_target', + object_ref: protectedObject.stix.id, + object_modified: protectedObject.stix.modified, + }), + ); + expect(before[0].snapshots[0].members).not.toContainEqual( + expect.objectContaining({ object_ref: protectedObject.stix.id }), + ); + } + + for (const path of [ + `/api/detection-strategies/${protectedObject.stix.id}`, + revisionPath(protectedObject), + ]) { + const rejected = await api('delete', path, undefined, 409); + if (protection === 'member') { + expect(rejected.body.release_tracks).toContain(releaseTrack.id); + } else { + expect(rejected.body.snapshot_graph_pins.length).toBeGreaterThan(0); + } + expect((await get(revisionPath(protectedObject))).stix).toEqual(protectedObject.stix); + for (let index = 0; index < tracks.length; index++) { + expect(await capture(tracks[index])).toEqual(before[index]); + await get(`/api/release-tracks/${tracks[index].id}/snapshots/latest`); + } + } + }); + } +}); diff --git a/app/tests/api/system-configuration/allowed-values.spec.js b/app/tests/api/system-configuration/allowed-values.spec.js index 7bafd64a..41df9f42 100644 --- a/app/tests/api/system-configuration/allowed-values.spec.js +++ b/app/tests/api/system-configuration/allowed-values.spec.js @@ -225,6 +225,11 @@ describe('Runtime allowed values API', function () { }); it('adds, toggles and removes options for multiple object types without broadening other scopes', async function () { + // Start with the option absent, regardless of the bundled seed defaults. + const seededRule = await getRule(platformRule); + await api('put', ruleUrl(seededRule), { + values: seededRule.values.filter(({ value }) => value !== 'Network Devices'), + }); const initial = (await api('get', rules)).body; const rule = initial.find((item) => sameRule(item, platformRule)); const option = { diff --git a/app/tests/api/system-configuration/create-object-identity.spec.js b/app/tests/api/system-configuration/create-object-identity.spec.js index 3942d785..508a388a 100644 --- a/app/tests/api/system-configuration/create-object-identity.spec.js +++ b/app/tests/api/system-configuration/create-object-identity.spec.js @@ -7,6 +7,7 @@ const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); const config = require('../../../config/config'); const { cloneForCreate } = require('../../shared/clone-for-create'); +const tacticsService = require('../../../services/stix/tactics-service'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -129,6 +130,8 @@ describe('Create Object with Organization Identity API', function () { expect(newIdentity.stix.id).toBeDefined(); expect(newIdentity.stix.created).toBeDefined(); expect(newIdentity.stix.modified).toBeDefined(); + expect(newIdentity.stix.created_by_ref).toBeUndefined(); + expect(newIdentity.stix.x_mitre_modified_by_ref).toBeUndefined(); }); it('POST /api/config/organization-identity sets the organization identity', async function () { @@ -144,10 +147,19 @@ describe('Create Object with Organization Identity API', function () { .expect(204); }); - it('POST /api/tactics creates a new version of the tactic', async function () { + it('POST /api/tactics preserves creation provenance after the organization identity changes', async function () { + // Alpha's explicit organization-identity workflow creates a newer revision. + // Authoring must preserve that stored provenance, even from a stale payload. + const latest = await request(app) + .get(`/api/tactics/${tactic1.stix.id}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(latest.body[0].stix.created_by_ref).toBe(newIdentity.stix.id); const tactic2 = cloneForCreate(tactic1); const timestamp = new Date().toISOString(); tactic2.stix.modified = timestamp; + tactic2.stix.created = timestamp; + tactic2.stix.created_by_ref = placeholderIdentity.stix.id; const body = tactic2; const res = await request(app) .post('/api/tactics') @@ -162,12 +174,127 @@ describe('Create Object with Organization Identity API', function () { expect(tactic).toBeDefined(); expect(tactic.stix).toBeDefined(); expect(tactic.stix.id).toBeDefined(); - expect(tactic.stix.created).toBeDefined(); + expect(tactic.stix.created).toBe(latest.body[0].stix.created); expect(tactic.stix.modified).toBeDefined(); expect(tactic.stix.created_by_ref).toBeDefined(); expect(tactic.stix.x_mitre_modified_by_ref).toBeDefined(); - expect(tactic.stix.created_by_ref).toBe(placeholderIdentity.stix.id); + expect(tactic.stix.created_by_ref).toBe(latest.body[0].stix.created_by_ref); expect(tactic.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); // Should match the new identity + const original = await request(app) + .get(`/api/tactics/${tactic1.stix.id}/modified/${tactic1.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(original.body.stix).toEqual(tactic1.stix); + }); + + it('POST leaves an omitted creator absent and initializes only an explicit creator', async function () { + const imported = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: structuredClone(tactic1.stix), + }; + imported.stix.id = `x-mitre-tactic--${uuid.v4()}`; + imported.stix.created = '2019-01-01T00:00:00.000Z'; + imported.stix.modified = '2020-01-01T00:00:00.000Z'; + delete imported.stix.created_by_ref; + await tacticsService.create(structuredClone(imported), { + import: true, + validateContents: true, + }); + + const revision = cloneForCreate(imported); + revision.stix.modified = '2021-01-01T00:00:00.000Z'; + revision.stix.created = revision.stix.modified; + const saved = await request(app) + .post('/api/tactics') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.created).toBe(imported.stix.created); + expect(saved.body.stix).not.toHaveProperty('created_by_ref'); + expect(saved.body.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); + const historical = await request(app) + .get(`/api/tactics/${imported.stix.id}/modified/${imported.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(historical.body.stix).toEqual(imported.stix); + + const explicitRevision = cloneForCreate(saved.body); + explicitRevision.stix.modified = '2022-01-01T00:00:00.000Z'; + explicitRevision.stix.created = explicitRevision.stix.modified; + explicitRevision.stix.created_by_ref = placeholderIdentity.stix.id; + const initialized = await request(app) + .post('/api/tactics') + .send(explicitRevision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(initialized.body.stix.created).toBe(imported.stix.created); + expect(initialized.body.stix.created_by_ref).toBe(placeholderIdentity.stix.id); + expect(initialized.body.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); + const previous = await request(app) + .get(`/api/tactics/${saved.body.stix.id}/modified/${saved.body.stix.modified}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(previous.body.stix).toEqual(saved.body.stix); + }); + + it('POST rejects an invalid explicit creator when the latest creator is absent', async function () { + const imported = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: structuredClone(tactic1.stix), + }; + imported.stix.id = `x-mitre-tactic--${uuid.v4()}`; + imported.stix.created = '2019-01-01T00:00:00.000Z'; + imported.stix.modified = '2020-01-01T00:00:00.000Z'; + delete imported.stix.created_by_ref; + await tacticsService.create(structuredClone(imported), { + import: true, + validateContents: true, + }); + + const revision = cloneForCreate(imported); + revision.stix.modified = '2021-01-01T00:00:00.000Z'; + revision.stix.created_by_ref = 'not-an-identity-reference'; + const rejected = await request(app) + .post('/api/tactics') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + expect(rejected.body.message).toBe('ADM validation failed'); + expect(rejected.body.details).toEqual( + expect.arrayContaining([expect.objectContaining({ path: ['created_by_ref'] })]), + ); + const history = await request(app) + .get(`/api/tactics/${imported.stix.id}?versions=all`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + expect(history.body).toHaveLength(1); + expect(history.body[0].stix).toEqual(imported.stix); + }); + + it('POST identity revisions still opt out of organization attribution', async function () { + const revision = cloneForCreate(newIdentity); + revision.stix.modified = new Date(Date.parse(newIdentity.stix.modified) + 1000).toISOString(); + const saved = await request(app) + .post('/api/identities') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.created_by_ref).toBeUndefined(); + expect(saved.body.stix.x_mitre_modified_by_ref).toBeUndefined(); + }); + + it('POST /api/tactics stamps brand-new objects with the active organization identity', async function () { + const body = cloneForCreate(tactic1); + delete body.stix.id; + const res = await request(app) + .post('/api/tactics') + .send(body) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + expect(res.body.stix.id).not.toBe(tactic1.stix.id); + expect(res.body.stix.created_by_ref).toBe(newIdentity.stix.id); + expect(res.body.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); }); after(async function () { diff --git a/app/tests/api/validation-reports/validation-reports.spec.js b/app/tests/api/validation-reports/validation-reports.spec.js index 0198d5f0..cc596d50 100644 --- a/app/tests/api/validation-reports/validation-reports.spec.js +++ b/app/tests/api/validation-reports/validation-reports.spec.js @@ -1062,6 +1062,64 @@ describe('Quiet opt-in validation reports', function () { ).body; expect(JSON.stringify(exported)).not.toContain('exemptionReport'); }); + it('reports overlapping retirement exemptions on review and rechecks policy before release', async () => { + const object = (await call('post', '/api/groups', group()).expect(201)).body; + await Model.collection.updateOne( + { 'stix.id': object.stix.id }, + { $set: { 'stix.revoked': true } }, + ); + const track = ( + await call('post', '/api/release-tracks/new', { + name: `Retired ${randomUUID().replaceAll('-', '')}`, + type: 'standard', + }).expect(201) + ).body; + await call('post', `/api/release-tracks/${track.id}/candidates`, { + object_refs: [{ id: object.stix.id, modified: object.stix.modified }], + }).expect(200); + const reviewed = ( + await call( + 'post', + `/api/release-tracks/${track.id}/candidates/promote?exemptionReport=details`, + { object_refs: [object.stix.id] }, + ).expect(200) + ).body; + expect(reviewed.exemptionReport.reportedExemptRevisions).toBe(1); + expect(reviewed.exemptionReport.ruleApplications).toBe(2); + expect(reviewed.exemptionReport.details.map((entry) => entry.ruleId).sort()).toEqual( + [deprecated, revoked].sort(), + ); + await rules.updateById(revoked, { + ...baseline.find((rule) => String(rule._id) === revoked), + enabled: false, + }); + const publication = `/api/release-tracks/${track.id}/snapshots/latest/release`; + const preview = ( + await call('get', `${publication}/preview?exemptionReport=details`).expect(200) + ).body; + expect(preview.exemptionReport.ruleApplications).toBe(1); + expect(preview.exemptionReport.details[0]).toMatchObject({ + ruleId: deprecated, + phase: 'preflight', + }); + await rules.updateById(deprecated, { + ...baseline.find((rule) => String(rule._id) === deprecated), + enabled: false, + }); + const historyPath = `/api/release-tracks/${track.id}/snapshots`; + const history = (await call('get', historyPath).expect(200)).body; + const before = await Model.collection.findOne({ 'stix.id': object.stix.id }); + const rejected = ( + await call('post', `${publication}?exemptionReport=details`, { version: '1.0' }).expect(400) + ).body; + expect(rejected.message).toBe('ADM validation failed'); + expect(rejected.exemptionReport.evaluatedScope.byOutcome).toEqual({ invalid: 1 }); + expect(rejected.exemptionReport.reportedExemptRevisions).toBe(0); + expect(rejected.exemptionReport.state).toBe('partial'); + expect(await Model.collection.findOne({ 'stix.id': object.stix.id })).toEqual(before); + expect((await call('get', historyPath).expect(200)).body).toEqual(history); + }); + it('captures revoke preflight once per exact revision/rule', async () => { const primary = (await call('post', '/api/groups', group()).expect(201)).body; const replacement = (await call('post', '/api/groups', group()).expect(201)).body; diff --git a/docs/admin/authentication/configuration.md b/docs/admin/authentication/configuration.md index 6d4fe778..100dafbd 100644 --- a/docs/admin/authentication/configuration.md +++ b/docs/admin/authentication/configuration.md @@ -89,6 +89,11 @@ docker compose restart rest-api npm start ``` +For Docker Compose environment-file changes, recreate the container with +`docker compose up -d --force-recreate rest-api` to load the updated environment. +See the [deployment configuration guide](https://github.com/mitre-attack/attack-workbench-deployment/blob/main/docs/configuration.md#rest-api-configuration-files) +for configuration file locations and details on applying changes. + ### Step 4: Verify Configuration Load Check the REST API logs during startup to confirm the configuration was loaded successfully: diff --git a/docs/admin/authentication/testing-verification.md b/docs/admin/authentication/testing-verification.md index a58da3a5..3fdfb7ca 100644 --- a/docs/admin/authentication/testing-verification.md +++ b/docs/admin/authentication/testing-verification.md @@ -13,6 +13,9 @@ Before testing, ensure you have: ## Verification Steps +For details on configuring REST API configuration files and container logging, +see the [deployment configuration guide](https://github.com/mitre-attack/attack-workbench-deployment/blob/main/docs/configuration.md#rest-api-configuration-files). + ### Step 1: Check REST API Logs When the REST API starts, it should log information about the authentication configuration. diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index 121094bc..c0c2ff9c 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -30,8 +30,15 @@ JSON configuration files, or a combination of both. - [Collection Indexes](#collection-indexes) - [Configuration Files](#configuration-files) - [Allowed Values](#allowed-values) + - [Initialization and the JSON file](#initialization-and-the-json-file) + - [Managing a configuration](#managing-a-configuration) + - [Adding values](#adding-values) + - [ADM version and enforcement](#adm-version-and-enforcement) + - [Legacy invalid settings](#legacy-invalid-settings) + - [API](#api) - [Static Marking Definitions](#static-marking-definitions) - [ATT\&CK Specific](#attck-specific) + - [STIX graph-write exclusion](#stix-graph-write-exclusion) - [Additional Resources](#additional-resources) --- @@ -111,6 +118,10 @@ JSON configuration files are recommended for complex configurations, especially JSON_CONFIG_PATH=/path/to/config.json ``` +The API reads the JSON file during startup. Restart the process after changing +the file. For container configuration and restart procedures, see the +[deployment configuration guide](https://github.com/mitre-attack/attack-workbench-deployment/blob/main/docs/configuration.md#rest-api-configuration-files). + ### Configuration Precedence When both environment variables and JSON configuration are used: @@ -857,6 +868,14 @@ disabled rules, or recreating deleted defaults. Custom overlapping rules are allowed; any enabled match grants exemption. A disabled rule does not cancel another enabled rule. +These rules also govern release-track admission, preview and publication. +Retirement flags do not bypass a disabled or nonmatching policy. To require ADM +validation of retired revisions, disable or narrow every applicable exemption; +a revision with both retirement flags remains exempt while either matching rule +is enabled. Subsequent operations use the updated policy, while previously +published content remains unchanged. Optional validation reports identify the +rules that actually granted an exemption. + The canonical policy stores rules and pending reevaluation intent atomically. Saving a change advances policy revision and evaluation generation; it does not synchronously scan historical revisions. Rule payloads and control metadata are diff --git a/docs/admin/release-track-reconciliation.md b/docs/admin/release-track-reconciliation.md index 53ca08c3..77de5f4e 100644 --- a/docs/admin/release-track-reconciliation.md +++ b/docs/admin/release-track-reconciliation.md @@ -72,6 +72,24 @@ backrefs for tracks that no longer exist are removed. The command prints JSON and exits nonzero if any track still fails. Preserve failed records and command output for incident review. +## Dangling workflow references after object deletion + +Object deletion retracts candidate, staged, and quarantine references across +snapshot history before deleting stored revisions. It does not remove members +or sealed-manifest pins; those continue to block deletion. + +For an object already deleted by an older server, repeating its object-wide +DELETE runs the same idempotent workflow cleanup. The endpoint may still +return `404` because the object itself is absent; verify the affected latest +and historical snapshot GETs afterward. This is a targeted write to workflow +metadata, not a read-time workaround. Deployment alone performs no repair. + +The backref repair commands above synchronize derived object metadata; they +do not remove dangling references from snapshot arrays. If a deletion returns +a reconciliation failure, inspect the object and snapshot state before +retrying: workflow cleanup may already be persisted, and post-delete +reconciliation can fail after the revision has been deleted. + ## Known crash window Snapshot persistence and reconciliation-record creation do not share a MongoDB diff --git a/docs/developer/release-tracks/backref-reconciliation.md b/docs/developer/release-tracks/backref-reconciliation.md index b6fe5c54..d790bfe0 100644 --- a/docs/developer/release-tracks/backref-reconciliation.md +++ b/docs/developer/release-tracks/backref-reconciliation.md @@ -141,13 +141,40 @@ clone or an explicit contents-changed reconciliation then moves that dynamic backref to the newly latest revision without rewriting the stored selector (see `member-sync-strategies.md`). +## Object deletion + +`BaseService.deleteById` and `deleteVersionById` run under the shared graph +write lock. Existing member and content-manifest guards execute before any +workflow cleanup. The required `release-track::object-deletion-requested` +event delegates writes to `object-deletion-service`. + +The listener scans the registry in bounded keyset pages and retracts matching +candidate, staged, and quarantine entries from all snapshots, not just the +latest snapshot or tracks named by derived backrefs. Cleanup updates workflow +arrays in place; it does not clone snapshots, change timestamps or release +versions, or modify members and sealed manifests. + +Object-wide deletion removes all workflow references to the STIX ID. +Version deletion removes exact references to that revision and removes +dynamic `"latest"` references only when no other revision survives. Affected +tracks are reconciled before object persistence is deleted. After deletion, +the required `release-track::object-deletion-completed` event reconciles tracks +whose latest snapshots retain dynamic selectors, moving their backrefs to the +surviving latest revision. + +Cleanup and reconciliation failures propagate instead of becoming successful +deletes. Pre-delete failures leave the object intact, although workflow +cleanup in earlier tracks may already have persisted. Post-delete +reconciliation failures use the existing durable repair records. Cleanup is +idempotent and also runs when a retried delete finds the object or revision +already missing; the endpoint's not-found response remains unchanged. + ## Known limitations -- **Deleted-then-recreated revisions.** If an object revision document is - deleted while pinned by a track, the backref disappears with the document - and the track keeps a dangling pin (pre-existing behavior). If an identical - revision is later re-created, its backref is restored on the next - contents-changed event for that track, not immediately. +- **Existing dangling workflow references.** Deploying the deletion fix does + not scan or rewrite existing snapshots. Repeating object-wide deletion + retracts that ID's stale workflow references even when the object is already + missing. Backref reconciliation alone does not remove snapshot references. - **Historical snapshots.** Backrefs describe only the *latest* snapshot per track. Delete guards do not trust that derived view: they query every registered track's tagged snapshots and graph manifests for the exact diff --git a/docs/developer/release-tracks/bundle-export.md b/docs/developer/release-tracks/bundle-export.md index de75a245..4f57a028 100644 --- a/docs/developer/release-tracks/bundle-export.md +++ b/docs/developer/release-tracks/bundle-export.md @@ -143,8 +143,8 @@ STIX version serialization. The design is recorded in configuration; release commit freezes the resolved values onto the tagged snapshot as `publication`. 7. **Bundle identity and hashes** — a released snapshot stores a stable - `bundle_id` assigned at commit; drafts derive a UUIDv5 from the track ID - and snapshot `modified`. The bundle ID therefore changes across snapshots + `bundle_id` assigned at commit; drafts generate a random UUIDv4 for each + export. Repeated draft exports therefore have different bundle IDs, while the collection ID stays constant per track. Release commit serializes each STIX version with `JSON.stringify(bundle, null, 4)`, hashes the exact UTF-8 bytes with SHA-256, and stores both digests on the snapshot as diff --git a/docs/developer/release-tracks/sealed-content-manifests.md b/docs/developer/release-tracks/sealed-content-manifests.md index 931b54ea..4b6136f0 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -31,6 +31,16 @@ endpoint. clone, and track creation. Snapshots produced by candidate, staged, config, and metadata clones inherit the predecessor's manifest by reference. A manifest is deleted only when no snapshot in its track references it. + Before admission, `reviewed-state-service.ensureReviewed` verifies that + every selected exact revision exists. It evaluates revisions under the + canonical policy: matching enabled retirement exemptions skip ADM, while + all other revisions use the full reviewed schema and error-bypass rules. + Disabling or narrowing those exemptions restores ordinary ADM admission + requirements. Exemption does not filter members or rewrite STIX: inactive member + revisions remain manifest roots and are retained in snapshot exports. + Membership invariants, manifest sealing/replay, and pin protection are + unchanged. Virtual materialization and release commit each use the current + policy, so later policy changes can affect admission at the tag step. 2. **One graph algorithm.** Roots are the exact `members` revisions. A relationship is selected when its `source_ref` and `target_ref` are both member IDs; the newest revision of each relationship lineage is chosen and @@ -81,9 +91,8 @@ endpoint. the materialization manifest unchanged, because the materialized draft is the artifact that was reviewed. Commit also stores a stable `bundle_id` and SHA-256 hashes of both serializations on the tagged snapshot. Draft - bundles use a deterministic UUIDv5 derived from the track ID and snapshot - `modified`; the bundle ID changes across snapshots while the collection ID - stays constant per track. + bundles generate a random UUIDv4 for each export; repeated draft exports + have different bundle IDs while the collection ID stays constant per track. 7. **Tagged snapshots are immutable including notes.** `snapshot_description` is editable on drafts only. The graph create and delete endpoints are removed. The admin-only source-attested reconstruction endpoint remains diff --git a/docs/developer/stix-versioning-and-embedded-relationships.md b/docs/developer/stix-versioning-and-embedded-relationships.md index 28b683b7..8fa3b0c1 100644 --- a/docs/developer/stix-versioning-and-embedded-relationships.md +++ b/docs/developer/stix-versioning-and-embedded-relationships.md @@ -22,6 +22,20 @@ workspace metadata. - Builds a **temporal version chain** of immutable snapshots - Each version is a complete, independent document - Versions are linked by sharing the same `stix.id` +- Authoring revisions preserve `stix.created` and `stix.created_by_ref` from the + latest stored revision, regardless of whether the client omits or changes them. +- Missing provenance remains missing. Ordinary authoring does not consult older + history or backfill existing objects with the current organization identity. + Recovery is separate from this prevention rule. +- `x_mitre_modified_by_ref` identifies the current editing organization. Ordinary + authoring cannot replace the latest stored creator. The explicit organization + identity configuration workflow can create new revisions that update creators + in the organization's provenance chain; subsequent authoring preserves those + new stored values. Historical revisions retain their original provenance. +- Only the new revision is written; intermediate history remains unchanged. Normal + lifecycle hooks, ADM validation, and release-track synchronization still apply. +- Bundle imports follow a separate source-preserving path and retain the source + object's creation provenance rather than applying authoring defaults. **Example:** ```javascript @@ -85,9 +99,10 @@ PUT /api/data-components/x-mitre-data-component--123/modified/2024-01-01T00:00:0 } ``` -To correct a description, name, relationship endpoint, or any other STIX field, +To correct authored content such as a description, name, or relationship endpoint, POST a complete new revision with the same `stix.id` and a later -`stix.modified` timestamp. +`stix.modified` timestamp. Creation provenance remains server-controlled as +described above; ordinary authoring POSTs cannot override it. **Use Case:** review and other workspace-only state that is not exported. diff --git a/docs/developer/validation-policy.md b/docs/developer/validation-policy.md index 17ff7c63..df8ed38f 100644 --- a/docs/developer/validation-policy.md +++ b/docs/developer/validation-policy.md @@ -130,6 +130,14 @@ orchestrates publication; repositories only retrieve revisions and save results. `assertNoAdmErrors` in `app/lib/adm-validation.js` provides the common ADM rejection contract while callers retain their evaluation phases and import behavior. +Release-track admission routes retired revisions through this same evaluator; +it must not skip validation based directly on retirement flags. This preserves +administrator exemption scopes and enabled states, along with the collector's +matching-rule evidence. Admission validates the whole batch before changing +workflow state or snapshot membership. Later preview and publication operations +capture their own current policy, so an earlier exemption does not authorize a +release after the applicable rules have been disabled. + `validation-policy-repository` owns conditional reconciliation claims, lease renewals, checkpoints, completion, failure, retry and recovery-intent writes. `validation-diagnostics-repository` owns bounded revision scans and guarded diff --git a/docs/legacy/docker.md b/docs/legacy/docker.md index 69442deb..c42ad316 100644 --- a/docs/legacy/docker.md +++ b/docs/legacy/docker.md @@ -1,8 +1,7 @@ # Standalone Docker Installation -The ATT&CK Workbench Frontend project includes a `docker-compose.yml` file that can be used to install all of the ATT&CK Workbench components as part of a Docker Compose installation. - -This document describes an alternate method of installing the ATT&CK Workbench REST API service in a Docker container without using Docker Compose. +This guide covers standalone Docker commands. For a complete Workbench deployment +with Docker Compose, use the [deployment repository](https://github.com/mitre-attack/attack-workbench-deployment). ## Create a Docker Image for the REST API diff --git a/docs/user/release-tracks/object-backrefs.md b/docs/user/release-tracks/object-backrefs.md index 6b8b6c23..b6c7e0ab 100644 --- a/docs/user/release-tracks/object-backrefs.md +++ b/docs/user/release-tracks/object-backrefs.md @@ -81,11 +81,22 @@ Release tracks are never blind to changes in the objects they pin: a track. Create corrections and deprecations as new POST revisions. PUT is limited to non-exported `workspace` metadata and does not trigger revision sync. -- **Graph and membership pins protect deletion.** Exact revisions in tagged - membership or an active/pending opt-in graph cannot be hard-deleted. The - guard checks authoritative tagged snapshots and graph entries rather than - relying only on `workspace.release_tracks`. A revision remains protected - even if a derived backref is temporarily absent. +- **Graph and membership pins protect deletion.** Member-pinned revisions and + exact revisions in sealed content manifests cannot be hard-deleted. The + guard checks authoritative tagged snapshots and manifests as well as current + member backrefs. Historical tagged membership and manifest pins remain + protected even if a derived backref is temporarily absent. +- **Permitted deletion retracts workflow references.** Deleting an object + removes its candidate, staged, and quarantine entries from every snapshot + of every registered track, including historical snapshots. This changes + workflow metadata only: snapshot timestamps, release versions, members, + and sealed manifests remain unchanged. Candidate membership alone does not + prevent deletion, but another track's member or manifest pin still can. +- **Version deletion preserves surviving selectors.** Deleting one revision + removes its exact workflow references. Dynamic `"latest"` selectors remain + when another revision exists, and their backrefs move to the surviving + latest revision. Deleting the final revision also removes those dynamic + selectors. - **Revoking a tracked object queues the revoked revision.** The revoke workflow creates one new revision of the revoked object (`revoked: true`); revision sync enrolls it as a candidate in tracks where diff --git a/docs/user/release-tracks/output-formats.md b/docs/user/release-tracks/output-formats.md index 438f3f9b..adb9115d 100644 --- a/docs/user/release-tracks/output-formats.md +++ b/docs/user/release-tracks/output-formats.md @@ -111,7 +111,8 @@ Standard STIX bundle format: which resolves the planned members live. - Released snapshots carry a stable `bundle_id` and SHA-256 `bundle_hashes` for both serializations; repeated downloads are byte-for-byte identical. - Draft bundles use a deterministic identifier derived from the snapshot. + Draft bundles generate a new random UUIDv4 identifier for each export; + their bundle IDs are not stable across repeated downloads. - Bundle export is fail-closed for primary content. If any selected exact revision no longer exists, the server returns HTTP `409` with every missing `(object_ref, object_modified)` pair in `missing_references`; it never emits diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index 4490d960..29421905 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -29,17 +29,28 @@ The three workflow states tracked per release track: 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. +evaluates the selected object revisions as `reviewed` under the operation's +canonical validation policy. Enabled object-exemption rules can exempt +revisions with `stix.revoked === true` or `stix.x_mitre_deprecated === true`; +new policies enable both exemptions for all types. Disabling or narrowing +every applicable exemption restores full ADM validation and configured +error-bypass handling for those revisions. Exemption is recorded as `exempt`, +not ADM conformance, and optional validation reports identify matching rules. +Inactive revisions are not removed from membership or exports. +Every selected revision must still exist, and snapshot membership and sealed +manifest integrity checks remain enforced. + +After the batch passes admission, the API sets only the selected revisions' +legacy workflow state to `reviewed`, including inactive revisions. 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. +track cloning, virtual snapshot materialization, and release commits. A release +commit checks every planned member's existence and evaluates its current policy, +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 diff --git a/package.json b/package.json index d3a814d9..e265cbf5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,13 @@ ], "license": "Apache-2.0", "main": "index.js", + "devEngines": { + "runtime": { + "name": "node", + "version": "22.x", + "onFail": "error" + } + }, "scripts": { "coverage:cobertura": "c8 --reporter=cobertura npm test", "coverage:text": "c8 npm test", diff --git a/template.env b/template.env index e49e3452..4251263d 100644 --- a/template.env +++ b/template.env @@ -1,3 +1,11 @@ +# Template-Version: 1.0 +# Template-Updated: 2026-10-11 +# Template-Source: attack-workbench-rest-api/template.env +# +# Changelog +# --------- +# - 1.0 (2026-10-11): Introduce template metadata + # Attack Workbench REST API - Environment Configuration Template # Guidance: # - Booleans: use true or false @@ -11,7 +19,7 @@ # Database (REQUIRED) # DATABASE_URL (string) - MongoDB connection string # Example (Docker): -#DATABASE_URL=mongodb://attack-workbench-database/attack-workspace +#DATABASE_URL=mongodb://mongodb/attack-workspace # Example (local): #DATABASE_URL=mongodb://localhost:27017/attack-workspace DATABASE_URL= @@ -51,6 +59,14 @@ DATABASE_URL= # Configuration Files # JSON_CONFIG_PATH (string) - Path to a JSON file with additional configuration. # Use this to provide arrays for service accounts and OIDC clients +# +# Some example values which align to some sample configurations which can be found here: +# https://github.com/mitre-attack/attack-workbench-rest-api/tree/main/resources/sample-configurations +# +# ./resources/collection-manager-apikey.json +# ./resources/collection-manager-oidc-keycloak.json +# ./resources/collection-manager-oidc-okta.json +# # Default: empty (disabled) #JSON_CONFIG_PATH= @@ -62,11 +78,23 @@ DATABASE_URL= # Default: ./app/lib/default-static-marking-definitions/ #WB_REST_STATIC_MARKING_DEFS_PATH=./app/lib/default-static-marking-definitions/ +# WB_REST_STATIC_BYPASS_RULES_PATH (string) - Default validation bypass rules loaded at startup +# Default: ./app/lib/default-bypass-rules.json +#WB_REST_STATIC_BYPASS_RULES_PATH=./app/lib/default-bypass-rules.json + # Scheduler # ENABLE_SCHEDULER (bool) - Enable background scheduler # Default: true #ENABLE_SCHEDULER=true +# SYNC_COLLECTION_INDEXES_CRON (string) - Cron schedule for polling collection indexes (process timezone) +# Default: * * * * * (every minute); disabled when ENABLE_SCHEDULER=false +#SYNC_COLLECTION_INDEXES_CRON=* * * * * + +# CHECK_WIP_ATTACK_IDS_CRON (string) - Cron setting for WIP ATT&CK-ID checks (currently unused by scheduler) +# Default: 0 * * * * (hourly); no current task consumes this setting +#CHECK_WIP_ATTACK_IDS_CRON=0 * * * * + # VIRTUAL_TRACK_SCHEDULES_CRON (string) - Reconcile virtual-track schedules # Discovers new cron schedules, recovers due dates, and retries failed runs. # Standard 5-field cron syntax, interpreted in UTC.