Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c3d3d4c
fix(ics-analytics): update the platforms listed for ics analytics to …
clemiller Jul 6, 2026
6e6bf51
Merge pull request #485 from mitre-attack/update-ics-analytic-platforms
seansica Jul 6, 2026
40f4029
feat: add live reload REST API dev workflow
scotluns May 20, 2026
d7358e0
chore: remove live reload demo marker
scotluns May 20, 2026
6b705d1
feat: simplify development container startup and document configuration
jondricek Oct 12, 2026
b7447d2
fix(release-tracks): retract workflow references when deleting objects
seansica Oct 12, 2026
c9a856b
fix(release-tracks): generate draft bundle IDs with UUIDv4
seansica Oct 12, 2026
3452fb3
fix(release-tracks): exempt inactive revisions from ADM admission checks
seansica Oct 12, 2026
4fcd301
fix(authoring): preserve stored creation provenance on revisions
seansica Oct 12, 2026
e532063
fix(authoring): keep provenance preservation prevention-only
seansica Oct 13, 2026
af65162
fix(authoring): block clients from setting created_by_ref; inherit fr…
seansica Oct 13, 2026
32f2ca1
Merge pull request #478 from mitre-attack/feat/live-reload-dev-workflow
seansica Oct 16, 2026
c60cca4
chore(sync): merge main into next
jondricek Oct 16, 2026
98c75d1
fix: update allowed values for ICS analytics
jondricek Oct 19, 2026
90d9ada
chore(sync): merge latest main into next
jondricek Oct 19, 2026
1865287
test(allowed-values): isolate option lifecycle from seed defaults
jondricek Oct 20, 2026
448a8b8
test(sync): restore next allowed-values test fixture
jondricek Oct 21, 2026
0346773
Merge pull request #518 from mitre-attack/sync/main-to-next-20261016
jondricek Oct 22, 2026
1ef02c6
test(allowed-values): isolate option lifecycle from seed defaults
jondricek Oct 22, 2026
68b4266
ci: rerun checks after next history repair
jondricek Oct 22, 2026
58362be
chore(sync): merge next into beta
jondricek Oct 22, 2026
17f1979
Merge pull request #519 from mitre-attack/sync/next-to-beta-20261022
jondricek Oct 22, 2026
4bfb2d6
chore: require Node 22 for local development
jondricek Oct 22, 2026
401a371
Merge pull request #520 from mitre-attack/next
jondricek Oct 22, 2026
50a4388
chore(sync): merge beta into alpha preserving validation policy
jondricek Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.14.0
22
28 changes: 20 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22
FROM node:22 AS base

# Define build arguments
ARG VERSION=dev
Expand Down Expand Up @@ -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" ]
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" ]
10 changes: 10 additions & 0 deletions app/api/definitions/components/stix-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 1 addition & 11 deletions app/config/allowed-values.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
Expand Down
2 changes: 2 additions & 0 deletions app/lib/event-constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
22 changes: 17 additions & 5 deletions app/repository/_base.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}
Expand Down
37 changes: 37 additions & 0 deletions app/repository/release-tracks/release-track-dynamic.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/repository/release-tracks/release-track-registry.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
68 changes: 49 additions & 19 deletions app/services/meta-classes/base.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'];

Expand Down Expand Up @@ -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}`,
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 2 additions & 8 deletions app/services/release-tracks/export-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
// =============================================================================
Expand Down Expand Up @@ -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()}`;
}

// =============================================================================
Expand Down
Loading
Loading