From c3d3d4ca573d40cf663590e59ff8054f8267a2e8 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:04:49 -0400 Subject: [PATCH 01/16] fix(ics-analytics): update the platforms listed for ics analytics to the correct values --- app/config/allowed-values.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/config/allowed-values.json b/app/config/allowed-values.json index 22bb412c..3649a9f3 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", "Embedded", "IaaS", "SaaS", "None"] } ] } From 40f40299ae4f220d13a76883b798df2b7281d8a5 Mon Sep 17 00:00:00 2001 From: Scot Lunsford Date: Wed, 20 May 2026 16:28:47 -0500 Subject: [PATCH 02/16] feat: add live reload REST API dev workflow --- Dockerfile | 32 +++- app/controllers/health-controller.js | 2 +- package-lock.json | 252 +++++++++++++++++++++++++++ package.json | 2 + 4 files changed, 279 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index f6fca030..528c34a7 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,31 @@ 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 all dependencies, including file-watching development tools. +RUN npm ci + +# Copy app source +COPY . . +RUN mkdir -p /usr/src/app-seed && \ + cp -R app /usr/src/app-seed/app && \ + cp -R bin /usr/src/app-seed/bin && \ + cp -R resources /usr/src/app-seed/resources + +CMD [ "sh", "-c", "if [ ! -f app/index.js ]; then cp -R /usr/src/app-seed/app/. app/; fi; if [ ! -f bin/www ]; then cp -R /usr/src/app-seed/bin/. bin/; fi; if [ ! -d resources ] || [ -z \"$(ls -A resources 2>/dev/null)\" ]; then mkdir -p resources && cp -R /usr/src/app-seed/resources/. resources/; fi; exec npm run start:dev" ] + +FROM base AS production + +# Install the app dependencies +RUN npm ci --omit=dev + +# Copy app source +COPY . . + +CMD [ "npm", "start" ] diff --git a/app/controllers/health-controller.js b/app/controllers/health-controller.js index b5cbffc8..7092a47b 100644 --- a/app/controllers/health-controller.js +++ b/app/controllers/health-controller.js @@ -4,7 +4,7 @@ const logger = require('../lib/logger'); exports.getPing = function (req, res) { - return res.status(204).send(); + return res.status(200).send('rest api watch worx!'); }; exports.getStatus = function (req, res) { diff --git a/package-lock.json b/package-lock.json index 12003c86..cc242107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "json-diff": "^1.0.6", "mocha": "^11.5.0", "mongodb-memory-server": "^10.1.4", + "nodemon": "^3.1.14", "openapi-schema-validator": "^12.1.3", "parse5": "^7.3.0", "parse5-query-domtree": "^1.0.2", @@ -2054,6 +2055,20 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -2289,6 +2304,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -5146,6 +5174,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5637,6 +5680,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5774,6 +5824,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7800,6 +7863,148 @@ "uuid": "bin/uuid" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -7835,6 +8040,16 @@ "dev": true, "license": "ISC" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", @@ -11231,6 +11446,13 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -12078,6 +12300,19 @@ "node": ">=4" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sinon": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-20.0.0.tgz", @@ -12934,6 +13169,16 @@ "node": ">=0.6" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -13098,6 +13343,13 @@ "node": ">= 0.8" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", diff --git a/package.json b/package.json index d3a814d9..856f0cc9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "prettier:fix": "npm run prettier -- --write", "format": "npm run prettier:fix && npm run lint:fix", "start": "node ./bin/www", + "start:dev": "nodemon --legacy-watch --watch app --watch bin --watch resources ./bin/www", "test": "npm run test:openapi && npm run test:config && npm run test:api && npm run test:middleware && npm run test:scheduler", "test:api": "mocha --timeout 20000 --recursive ./app/tests/api --exit", "test:config": "mocha --timeout 20000 --recursive ./app/tests/config --exit", @@ -92,6 +93,7 @@ "json-diff": "^1.0.6", "mocha": "^11.5.0", "mongodb-memory-server": "^10.1.4", + "nodemon": "^3.1.14", "openapi-schema-validator": "^12.1.3", "parse5": "^7.3.0", "parse5-query-domtree": "^1.0.2", From d7358e0b3d1cff3fedb30435525167d806ec2a30 Mon Sep 17 00:00:00 2001 From: Scot Lunsford Date: Wed, 20 May 2026 16:29:01 -0500 Subject: [PATCH 03/16] chore: remove live reload demo marker --- app/controllers/health-controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/health-controller.js b/app/controllers/health-controller.js index 7092a47b..b5cbffc8 100644 --- a/app/controllers/health-controller.js +++ b/app/controllers/health-controller.js @@ -4,7 +4,7 @@ const logger = require('../lib/logger'); exports.getPing = function (req, res) { - return res.status(200).send('rest api watch worx!'); + return res.status(204).send(); }; exports.getStatus = function (req, res) { From 6b705d1365658c6321ed8f1b01d1de3dac7f34d0 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Mon, 12 Oct 2026 07:41:23 -0500 Subject: [PATCH 04/16] feat: simplify development container startup and document configuration --- Dockerfile | 8 +- docs/admin/authentication/configuration.md | 5 + .../authentication/testing-verification.md | 3 + docs/admin/configuration.md | 11 + docs/legacy/docker.md | 5 +- package-lock.json | 252 ------------------ package.json | 2 - template.env | 30 ++- 8 files changed, 52 insertions(+), 264 deletions(-) diff --git a/Dockerfile b/Dockerfile index 528c34a7..de322061 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,17 +33,13 @@ ENV APP_VERSION=${VERSION} \ FROM base AS dev -# Install all dependencies, including file-watching development tools. +# Install development dependencies. RUN npm ci # Copy app source COPY . . -RUN mkdir -p /usr/src/app-seed && \ - cp -R app /usr/src/app-seed/app && \ - cp -R bin /usr/src/app-seed/bin && \ - cp -R resources /usr/src/app-seed/resources -CMD [ "sh", "-c", "if [ ! -f app/index.js ]; then cp -R /usr/src/app-seed/app/. app/; fi; if [ ! -f bin/www ]; then cp -R /usr/src/app-seed/bin/. bin/; fi; if [ ! -d resources ] || [ -z \"$(ls -A resources 2>/dev/null)\" ]; then mkdir -p resources && cp -R /usr/src/app-seed/resources/. resources/; fi; exec npm run start:dev" ] +CMD [ "node", "./bin/www" ] FROM base AS production 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 4d9de28b..cbd03176 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: 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/package-lock.json b/package-lock.json index cc242107..12003c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,6 @@ "json-diff": "^1.0.6", "mocha": "^11.5.0", "mongodb-memory-server": "^10.1.4", - "nodemon": "^3.1.14", "openapi-schema-validator": "^12.1.3", "parse5": "^7.3.0", "parse5-query-domtree": "^1.0.2", @@ -2055,20 +2054,6 @@ "dev": true, "license": "MIT" }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -2304,19 +2289,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -5174,21 +5146,6 @@ "node": ">=14.14" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5680,13 +5637,6 @@ "node": ">= 4" } }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5824,19 +5774,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7863,148 +7800,6 @@ "uuid": "bin/uuid" } }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/nodemon/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/nodemon/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/nodemon/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -8040,16 +7835,6 @@ "dev": true, "license": "ISC" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", @@ -11446,13 +11231,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -12300,19 +12078,6 @@ "node": ">=4" } }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sinon": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-20.0.0.tgz", @@ -13169,16 +12934,6 @@ "node": ">=0.6" } }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -13343,13 +13098,6 @@ "node": ">= 0.8" } }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", diff --git a/package.json b/package.json index 856f0cc9..d3a814d9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "prettier:fix": "npm run prettier -- --write", "format": "npm run prettier:fix && npm run lint:fix", "start": "node ./bin/www", - "start:dev": "nodemon --legacy-watch --watch app --watch bin --watch resources ./bin/www", "test": "npm run test:openapi && npm run test:config && npm run test:api && npm run test:middleware && npm run test:scheduler", "test:api": "mocha --timeout 20000 --recursive ./app/tests/api --exit", "test:config": "mocha --timeout 20000 --recursive ./app/tests/config --exit", @@ -93,7 +92,6 @@ "json-diff": "^1.0.6", "mocha": "^11.5.0", "mongodb-memory-server": "^10.1.4", - "nodemon": "^3.1.14", "openapi-schema-validator": "^12.1.3", "parse5": "^7.3.0", "parse5-query-domtree": "^1.0.2", 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. From b7447d27b3443a52eaea7cd1c22b8fb71e815816 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:26:55 -0400 Subject: [PATCH 05/16] fix(release-tracks): retract workflow references when deleting objects Clean candidate, staged, and quarantine references across snapshot history before permitted object deletion. Preserve member and manifest guards, reconcile surviving latest selectors, and support idempotent cleanup retries. Add deletion regression coverage and document cleanup and repair semantics. --- app/lib/event-constants.js | 2 + app/repository/_base.repository.js | 22 +- .../release-track-dynamic.repository.js | 37 ++ .../release-track-registry.repository.js | 14 + app/services/meta-classes/base.service.js | 38 +- .../release-tracks/object-deletion-service.js | 79 ++++ .../workflow-object-deletion.spec.js | 409 ++++++++++++++++++ docs/admin/release-track-reconciliation.md | 18 + .../release-tracks/backref-reconciliation.md | 37 +- docs/user/release-tracks/object-backrefs.md | 21 +- 10 files changed, 658 insertions(+), 19 deletions(-) create mode 100644 app/services/release-tracks/object-deletion-service.js create mode 100644 app/tests/api/release-tracks/workflow-object-deletion.spec.js diff --git a/app/lib/event-constants.js b/app/lib/event-constants.js index 0b4354a4..628c4516 100644 --- a/app/lib/event-constants.js +++ b/app/lib/event-constants.js @@ -170,4 +170,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 40dee3b4..123d01a1 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() @@ -608,13 +618,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 ac6e5db0..b90b7912 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -35,6 +35,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'); @@ -1181,6 +1182,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()) { @@ -1201,13 +1226,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 @@ -1384,6 +1412,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/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/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/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/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 From c9a856b5d400b440c96751ced2c25b30e9f7e762 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:35:40 -0400 Subject: [PATCH 06/16] fix(release-tracks): generate draft bundle IDs with UUIDv4 Replace deterministic UUIDv5 draft bundle generation with random UUIDv4 IDs while preserving stored release bundle IDs. Update bundle regression coverage and export documentation. --- app/services/release-tracks/export-service.js | 10 ++-------- app/tests/api/release-tracks/content-manifests.spec.js | 1 - .../api/release-tracks/release-tracks-bundle.spec.js | 4 +++- docs/developer/release-tracks/bundle-export.md | 4 ++-- .../release-tracks/sealed-content-manifests.md | 5 ++--- docs/user/release-tracks/output-formats.md | 3 ++- 6 files changed, 11 insertions(+), 16 deletions(-) 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/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/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..af4accd5 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -81,9 +81,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/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 From 3452fb3fbbd78499d58f9fc484d6267392ed3005 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:46:14 -0400 Subject: [PATCH 07/16] fix(release-tracks): exempt inactive revisions from ADM admission checks Align shared release-track admission with the import lifecycle policy for revoked and deprecated revisions. Preserve exact-revision existence checks, active-content validation, membership, manifests, and exported inactive content. Add lifecycle admission regression coverage and document the exemption. --- .../release-tracks/reviewed-state-service.js | 26 ++-- .../api/release-tracks/reviewed-state.spec.js | 145 ++++++++++++++++++ .../sealed-content-manifests.md | 9 ++ docs/user/release-tracks/release-workflow.md | 26 ++-- 4 files changed, 186 insertions(+), 20 deletions(-) diff --git a/app/services/release-tracks/reviewed-state-service.js b/app/services/release-tracks/reviewed-state-service.js index 394a9389..7de738ba 100644 --- a/app/services/release-tracks/reviewed-state-service.js +++ b/app/services/release-tracks/reviewed-state-service.js @@ -28,17 +28,21 @@ async function ensureReviewed(entries) { object_ref: document.stix.id, object_modified: new Date(document.stix.modified).toISOString(), }; - const result = await attackObjectsService.validateComposedObject( - { - // MongoDB dates, including type-specific dates, must reach ADM as strings. - // Validate a copy; never replace stored STIX with the parsed schema output. - stix: JSON.parse(JSON.stringify(document.stix)), - workspace: { workflow: { state: 'reviewed' } }, - }, - { bypassRules }, - ); - errors.push(...result.errors.map((issue) => ({ ...issue, ...reference }))); - warnings.push(...result.warnings.map((issue) => ({ ...issue, ...reference }))); + // Match import policy for historical inactive content. Existence was checked + // above; admission still retains the revision and updates its legacy state. + if (document.stix.revoked !== true && document.stix.x_mitre_deprecated !== true) { + const result = await attackObjectsService.validateComposedObject( + { + // MongoDB dates, including type-specific dates, must reach ADM as strings. + // Validate a copy; never replace stored STIX with the parsed schema output. + stix: JSON.parse(JSON.stringify(document.stix)), + workspace: { workflow: { state: 'reviewed' } }, + }, + { bypassRules }, + ); + errors.push(...result.errors.map((issue) => ({ ...issue, ...reference }))); + warnings.push(...result.warnings.map((issue) => ({ ...issue, ...reference }))); + } reviewedEntries.push(reference); } diff --git a/app/tests/api/release-tracks/reviewed-state.spec.js b/app/tests/api/release-tracks/reviewed-state.spec.js index e32af3a6..e011b10f 100644 --- a/app/tests/api/release-tracks/reviewed-state.spec.js +++ b/app/tests/api/release-tracks/reviewed-state.spec.js @@ -9,6 +9,7 @@ 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'); function campaign(complete = true) { return { @@ -83,6 +84,150 @@ 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); + }); + } + + 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/docs/developer/release-tracks/sealed-content-manifests.md b/docs/developer/release-tracks/sealed-content-manifests.md index af4accd5..8646b3ea 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -31,6 +31,15 @@ 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 applies full ADM validation to + active revisions, but exempts revisions with `revoked === true` or + `x_mitre_deprecated === true`, matching the import lifecycle policy. + This exception 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. The shared gate covers virtual materialization and release + commit, so inactive historical content is not rejected at a later 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 diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index 4490d960..3339003d 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -29,17 +29,25 @@ 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. +validates the selected **active** object revisions as `reviewed`, using the +existing full ADM schemas, configuration, and validation bypass rules. +Revisions with `stix.revoked === true` or `stix.x_mitre_deprecated === true` +are exempt from ADM admission checks, matching the import policy for +historical inactive content. They 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 validates active members, +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 From 4fcd301e9eab5b9c0e8e3ce10040d4f0bab9696d Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:41:51 -0400 Subject: [PATCH 08/16] fix(authoring): preserve stored creation provenance on revisions --- .../definitions/components/stix-common.yml | 9 ++ app/services/meta-classes/base.service.js | 22 +++- app/tests/api/campaigns/campaigns.spec.js | 118 ++++++++++++++++++ .../api/relationships/relationships.spec.js | 17 ++- .../create-object-identity.spec.js | 22 +++- ...x-versioning-and-embedded-relationships.md | 14 ++- 6 files changed, 190 insertions(+), 12 deletions(-) diff --git a/app/api/definitions/components/stix-common.yml b/app/api/definitions/components/stix-common.yml index 72b8e24f..5d1e9fdc 100644 --- a/app/api/definitions/components/stix-common.yml +++ b/app/api/definitions/components/stix-common.yml @@ -38,12 +38,21 @@ components: created: type: string format: date-time + description: >- + Creation timestamp. On authoring POSTs for an existing object, the + server preserves this value from the stored latest revision. + 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 this value, + including its absence, from the stored latest revision rather than + accepting a client override or attributing creation to the current + editing organization. Bundle imports retain source attribution. revoked: type: boolean example: false diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index b90b7912..22ecb682 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -104,8 +104,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 a new object's 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 @@ -362,10 +362,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']; @@ -655,14 +655,24 @@ class BaseService extends ServiceWithHooks { ? await this.retrieveOrganizationIdentityRef() : null; - // Check for an existing object (may differ from existingVersion if stix.id was just generated) + // The stored latest revision is authoritative for creation provenance and lifecycle state. let existingObject; if (data.stix.id) { existingObject = await this.repository.retrieveLatestByStixId(data.stix.id); } if (existingObject) { - // New version of an existing object — carry forward revoked status, set modified_by + // Carry forward the latest revision's creation provenance and revoked status. + // This prevents new drift; it does not recover provenance from older revisions. + 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; + } else { + delete data.stix.created_by_ref; + } data.stix.revoked = existingObject.stix.revoked ?? false; if (organizationIdentityRef) { data.stix.x_mitre_modified_by_ref = organizationIdentityRef; diff --git a/app/tests/api/campaigns/campaigns.spec.js b/app/tests/api/campaigns/campaigns.spec.js index a24fa178..fa43b2b9 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,123 @@ 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 of ['present', 'absent']) { + it(`preserves source provenance on import and uses only the latest revision with creator ${latestCreator}`, 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-01T00: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', + }, + }; + 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); + + // A stale client supplies the original creator even when the latest has none. + const revision = cloneForCreate(original.body); + revision.stix.modified = '2020-04-01T00:00:00.000Z'; + const saved = await request(app) + .post('/api/campaigns') + .send(revision) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + expect(saved.body.stix.created).toBe(imported.stix.created); + expect(saved.body.stix.created_by_ref).toBe(imported.stix.created_by_ref); + if (latestCreator === 'absent') { + 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); + }); + } + 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..f45d11f3 100644 --- a/app/tests/api/relationships/relationships.spec.js +++ b/app/tests/api/relationships/relationships.spec.js @@ -300,10 +300,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 +318,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 +344,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 () { 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..9885d9fc 100644 --- a/app/tests/api/system-configuration/create-object-identity.spec.js +++ b/app/tests/api/system-configuration/create-object-identity.spec.js @@ -129,6 +129,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 +146,12 @@ 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 () { const tactic2 = cloneForCreate(tactic1); const timestamp = new Date().toISOString(); tactic2.stix.modified = timestamp; + tactic2.stix.created = timestamp; + tactic2.stix.created_by_ref = newIdentity.stix.id; const body = tactic2; const res = await request(app) .post('/api/tactics') @@ -162,7 +166,7 @@ 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(tactic1.stix.created); expect(tactic.stix.modified).toBeDefined(); expect(tactic.stix.created_by_ref).toBeDefined(); expect(tactic.stix.x_mitre_modified_by_ref).toBeDefined(); @@ -170,6 +174,20 @@ describe('Create Object with Organization Identity API', function () { expect(tactic.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); // Should match the new identity }); + 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 () { await database.closeConnection(); }); diff --git a/docs/developer/stix-versioning-and-embedded-relationships.md b/docs/developer/stix-versioning-and-embedded-relationships.md index 28b683b7..8d500813 100644 --- a/docs/developer/stix-versioning-and-embedded-relationships.md +++ b/docs/developer/stix-versioning-and-embedded-relationships.md @@ -22,6 +22,15 @@ 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 + stored latest revision, regardless of whether the client omits or changes them. + `x_mitre_modified_by_ref` identifies the current editing organization; changing + that organization does not change the original creator. +- Missing provenance in the latest stored revision is not reconstructed from older + history or filled with the current organization. This preservation rule prevents + new loss; it does not repair existing content. +- 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 +94,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. From e532063a4bff3568a10163b989f7f080d8d07702 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:44:31 -0400 Subject: [PATCH 09/16] fix(authoring): keep provenance preservation prevention-only Carry creation provenance from the latest stored revision without historical recovery or organization backfill. Fetch only that revision and retain normal authoring and import behavior. Cover absent and divergent imported provenance, historical STIX preservation, and normal lifecycle synchronization; document the prevention-only contract. --- .../definitions/components/stix-common.yml | 13 +++--- app/services/meta-classes/base.service.js | 18 ++------ app/tests/api/campaigns/campaigns.spec.js | 41 +++++++++++++++-- .../api/relationships/relationships.spec.js | 43 +++++++++++++++++ .../create-object-identity.spec.js | 46 +++++++++++++++++++ ...x-versioning-and-embedded-relationships.md | 14 +++--- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/app/api/definitions/components/stix-common.yml b/app/api/definitions/components/stix-common.yml index 5d1e9fdc..6e50517b 100644 --- a/app/api/definitions/components/stix-common.yml +++ b/app/api/definitions/components/stix-common.yml @@ -39,8 +39,9 @@ components: type: string format: date-time description: >- - Creation timestamp. On authoring POSTs for an existing object, the - server preserves this value from the stored latest revision. + 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 @@ -49,10 +50,10 @@ components: type: string example: 'identity--6444f546-6900-4456-b3b1-015c88d70dab' description: >- - Original creator identity. Authoring revisions preserve this value, - including its absence, from the stored latest revision rather than - accepting a client override or attributing creation to the current - editing organization. Bundle imports retain source attribution. + 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/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index 22ecb682..9b2e07e2 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -104,7 +104,7 @@ class BaseService extends ServiceWithHooks { } /** - * Whether create() should stamp a new object's created_by_ref and each + * Whether create() should stamp new or missing 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 @@ -549,10 +549,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}`, ); @@ -655,15 +653,9 @@ class BaseService extends ServiceWithHooks { ? await this.retrieveOrganizationIdentityRef() : null; - // The stored latest revision is authoritative for creation provenance and lifecycle state. - let existingObject; - if (data.stix.id) { - existingObject = await this.repository.retrieveLatestByStixId(data.stix.id); - } - + const existingObject = existingVersion; if (existingObject) { - // Carry forward the latest revision's creation provenance and revoked status. - // This prevents new drift; it does not recover provenance from older revisions. + // Preserve the latest revision's creation provenance; do not repair history. data.stix.created = existingObject.stix.created instanceof Date ? existingObject.stix.created.toISOString() diff --git a/app/tests/api/campaigns/campaigns.spec.js b/app/tests/api/campaigns/campaigns.spec.js index fa43b2b9..fd5ab165 100644 --- a/app/tests/api/campaigns/campaigns.spec.js +++ b/app/tests/api/campaigns/campaigns.spec.js @@ -510,7 +510,7 @@ describe('Campaigns API', function () { } for (const latestCreator of ['present', 'absent']) { - it(`preserves source provenance on import and uses only the latest revision with creator ${latestCreator}`, async function () { + it(`preserves imported latest provenance without recovery when creator is ${latestCreator}`, async function () { const original = await request(app) .post('/api/campaigns') .send({ @@ -518,7 +518,7 @@ describe('Campaigns API', function () { stix: { ...initialObjectData.stix, created: '2020-01-01T00:00:00.000Z', - modified: '2020-01-01T00:00:00.000Z', + modified: '2020-01-15T00:00:00.000Z', }, }) .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) @@ -531,6 +531,7 @@ describe('Campaigns API', function () { ...original.body.stix, created: '2020-02-01T00:00:00.000Z', modified: '2020-03-01T00:00:00.000Z', + aliases: ['Imported campaign alias'], }, }; if (latestCreator === 'present') { @@ -548,14 +549,28 @@ describe('Campaigns API', function () { .expect(200); expect(importedRevision.body.stix).toEqual(imported.stix); - // A stale client supplies the original creator even when the latest has none. - const revision = cloneForCreate(original.body); + const track = await request(app) + .post('/api/release-tracks/new') + .send({ name: `Campaign provenance ${latestCreator}`, 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; + revision.stix.created_by_ref = 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.aliases).toEqual([imported.stix.name, ...imported.stix.aliases]); expect(saved.body.stix.created).toBe(imported.stix.created); expect(saved.body.stix.created_by_ref).toBe(imported.stix.created_by_ref); if (latestCreator === 'absent') { @@ -576,6 +591,24 @@ describe('Campaigns API', function () { .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); }); } diff --git a/app/tests/api/relationships/relationships.spec.js b/app/tests/api/relationships/relationships.spec.js index f45d11f3..785f0191 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'; @@ -705,6 +706,48 @@ describe('Relationships API', function () { expect(relationships.length).toBe(0); }); + it('ordinary POST preserves latest relationship provenance without recovering older values', 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).not.toHaveProperty('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/system-configuration/create-object-identity.spec.js b/app/tests/api/system-configuration/create-object-identity.spec.js index 9885d9fc..742b0d61 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'; @@ -174,6 +175,51 @@ describe('Create Object with Organization Identity API', function () { expect(tactic.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); // Should match the new identity }); + it('POST does not backfill an existing object with the current organization identity', 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; + revision.stix.created_by_ref = placeholderIdentity.stix.id; + 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); + }); + + 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; diff --git a/docs/developer/stix-versioning-and-embedded-relationships.md b/docs/developer/stix-versioning-and-embedded-relationships.md index 8d500813..41d4dbe1 100644 --- a/docs/developer/stix-versioning-and-embedded-relationships.md +++ b/docs/developer/stix-versioning-and-embedded-relationships.md @@ -23,12 +23,14 @@ workspace metadata. - 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 - stored latest revision, regardless of whether the client omits or changes them. - `x_mitre_modified_by_ref` identifies the current editing organization; changing - that organization does not change the original creator. -- Missing provenance in the latest stored revision is not reconstructed from older - history or filled with the current organization. This preservation rule prevents - new loss; it does not repair existing content. + 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; changing + that organization does not overwrite an existing creator. +- 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. From af6516264debc1c11f6e814bff4af707036e6b00 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:49:58 -0400 Subject: [PATCH 10/16] fix(authoring): block clients from setting created_by_ref; inherit from previous revision --- app/services/meta-classes/base.service.js | 6 +- app/tests/api/campaigns/campaigns.spec.js | 23 ++++++-- .../api/relationships/relationships.spec.js | 4 +- .../create-object-identity.spec.js | 55 ++++++++++++++++++- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index 9b2e07e2..9ce6bd42 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -104,7 +104,7 @@ class BaseService extends ServiceWithHooks { } /** - * Whether create() should stamp new or missing created_by_ref and each + * 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 @@ -655,15 +655,13 @@ class BaseService extends ServiceWithHooks { const existingObject = existingVersion; if (existingObject) { - // Preserve the latest revision's creation provenance; do not repair history. + // 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; - } else { - delete data.stix.created_by_ref; } data.stix.revoked = existingObject.stix.revoked ?? false; if (organizationIdentityRef) { diff --git a/app/tests/api/campaigns/campaigns.spec.js b/app/tests/api/campaigns/campaigns.spec.js index fd5ab165..cab29b8d 100644 --- a/app/tests/api/campaigns/campaigns.spec.js +++ b/app/tests/api/campaigns/campaigns.spec.js @@ -509,8 +509,12 @@ describe('Campaigns API', function () { }); } - for (const latestCreator of ['present', 'absent']) { - it(`preserves imported latest provenance without recovery when creator is ${latestCreator}`, async function () { + 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({ @@ -551,7 +555,7 @@ describe('Campaigns API', function () { const track = await request(app) .post('/api/release-tracks/new') - .send({ name: `Campaign provenance ${latestCreator}`, type: 'standard' }) + .send({ name: `Campaign provenance ${latestCreator} ${suppliedCreator}`, type: 'standard' }) .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); await request(app) @@ -564,7 +568,11 @@ describe('Campaigns API', function () { const revision = cloneForCreate(importedRevision.body); revision.stix.modified = '2020-04-01T00:00:00.000Z'; revision.stix.created = original.body.stix.created; - revision.stix.created_by_ref = original.body.stix.created_by_ref; + 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) @@ -572,8 +580,11 @@ describe('Campaigns API', function () { .expect(201); expect(saved.body.stix.aliases).toEqual([imported.stix.name, ...imported.stix.aliases]); expect(saved.body.stix.created).toBe(imported.stix.created); - expect(saved.body.stix.created_by_ref).toBe(imported.stix.created_by_ref); - if (latestCreator === 'absent') { + 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); diff --git a/app/tests/api/relationships/relationships.spec.js b/app/tests/api/relationships/relationships.spec.js index 785f0191..674b93bf 100644 --- a/app/tests/api/relationships/relationships.spec.js +++ b/app/tests/api/relationships/relationships.spec.js @@ -706,7 +706,7 @@ describe('Relationships API', function () { expect(relationships.length).toBe(0); }); - it('ordinary POST preserves latest relationship provenance without recovering older values', async function () { + 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), @@ -736,7 +736,7 @@ describe('Relationships API', function () { .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); expect(saved.body.stix.created).toBe(intermediate.stix.created); - expect(saved.body.stix).not.toHaveProperty('created_by_ref'); + 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]) { 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 742b0d61..c8adb9f1 100644 --- a/app/tests/api/system-configuration/create-object-identity.spec.js +++ b/app/tests/api/system-configuration/create-object-identity.spec.js @@ -175,7 +175,7 @@ describe('Create Object with Organization Identity API', function () { expect(tactic.stix.x_mitre_modified_by_ref).toBe(newIdentity.stix.id); // Should match the new identity }); - it('POST does not backfill an existing object with the current organization identity', async function () { + 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), @@ -192,7 +192,6 @@ describe('Create Object with Organization Identity API', function () { const revision = cloneForCreate(imported); revision.stix.modified = '2021-01-01T00:00:00.000Z'; revision.stix.created = revision.stix.modified; - revision.stix.created_by_ref = placeholderIdentity.stix.id; const saved = await request(app) .post('/api/tactics') .send(revision) @@ -206,6 +205,58 @@ describe('Create Object with Organization Identity API', function () { .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 () { From 98c75d1788c4d3e14600041a25eb02e696055822 Mon Sep 17 00:00:00 2001 From: Jared Ondricek <90368810+jondricek@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:21:24 -0500 Subject: [PATCH 11/16] fix: update allowed values for ICS analytics --- app/config/allowed-values.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config/allowed-values.json b/app/config/allowed-values.json index 3649a9f3..d7552100 100644 --- a/app/config/allowed-values.json +++ b/app/config/allowed-values.json @@ -27,7 +27,7 @@ }, { "domainName": "ics-attack", - "allowedValues": ["Windows", "Linux", "Network", "Embedded", "IaaS", "SaaS", "None"] + "allowedValues": ["Windows", "Linux", "Network Devices", "Embedded", "None"] } ] } From 1865287c94efaf896582c6350cac2ab50f373564 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Tue, 20 Oct 2026 16:12:11 -0500 Subject: [PATCH 12/16] test(allowed-values): isolate option lifecycle from seed defaults --- app/tests/api/system-configuration/allowed-values.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/tests/api/system-configuration/allowed-values.spec.js b/app/tests/api/system-configuration/allowed-values.spec.js index 9b82c720..47718bf2 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 = { From 448a8b8a71b44872fd9b9a7a065211f328f9a7e7 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Wed, 21 Oct 2026 12:27:02 -0500 Subject: [PATCH 13/16] test(sync): restore next allowed-values test fixture Remove the sync-specific fixture adjustment from PR #518 as requested. Leave test repairs for next; the seeded Network Devices assertion is expected to fail until addressed there. --- app/tests/api/system-configuration/allowed-values.spec.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/tests/api/system-configuration/allowed-values.spec.js b/app/tests/api/system-configuration/allowed-values.spec.js index 47718bf2..9b82c720 100644 --- a/app/tests/api/system-configuration/allowed-values.spec.js +++ b/app/tests/api/system-configuration/allowed-values.spec.js @@ -225,11 +225,6 @@ 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 = { From 1ef02c6a67c8dbbef8016fdf4c59f5915c649582 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 22 Oct 2026 12:34:41 -0500 Subject: [PATCH 14/16] test(allowed-values): isolate option lifecycle from seed defaults Remove the test option during setup so the lifecycle assertions do not depend on bundled defaults. --- app/tests/api/system-configuration/allowed-values.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/tests/api/system-configuration/allowed-values.spec.js b/app/tests/api/system-configuration/allowed-values.spec.js index 9b82c720..47718bf2 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 = { From 68b426697394703c845e805e17e859c7c8a18847 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 22 Oct 2026 12:44:19 -0500 Subject: [PATCH 15/16] ci: rerun checks after next history repair Start a normal push range after replacing the unreleased merge history. The previous push referenced a commit absent from the runner checkout. From 4bfb2d6a971a62e26a7ba61cc9434b8370ab0714 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 22 Oct 2026 15:42:55 -0500 Subject: [PATCH 16/16] chore: require Node 22 for local development --- .nvmrc | 2 +- package.json | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index 7d41c735..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.14.0 +22 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",