From da82701c7ef25523401f2b54068fd5427d57781d Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:20:09 -0400 Subject: [PATCH 1/4] fix: update github links to reflect org transfer --- .nocommit/backend-field-test.js | 365 ++++++++ .nocommit/docs/99_IMPLEMENTATION_PLAN.md | 401 +++++++++ .../99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md | 842 ++++++++++++++++++ .../docs/99_POST_IMPL_TESTING_OBSERVATIONS.md | 349 ++++++++ .../99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md | 638 +++++++++++++ .../docs/99_SERVICE_LAYER_IMPL_PROMPTS.md | 50 ++ .nocommit/field-requirements-probe.js | 457 ++++++++++ .nocommit/plans/workflow-response-ontology.md | 106 +++ .nocommit/prompts/adm-validation-explainer.md | 29 + .nocommit/prompts/fix-import-pipeline.md | 117 +++ ...-prefix-and-adm-validation-bypass-rules.md | 105 +++ ...s-for-revoke-and-subtechnique-workflows.md | 115 +++ .../prompts/organization-identity-syncing.md | 69 ++ .nocommit/prompts/refactor-export-service.md | 66 ++ ...nclude_candidates_in_snapshots property.md | 36 + ...tre-version-from-existing-relationships.md | 72 ++ .nocommit/prompts/revoke-workflow-bug.md | 48 + .nocommit/prompts/revoke-workflow.md | 269 ++++++ .nocommit/prompts/synthetic-data-generator.md | 625 +++++++++++++ ...nique-sub-parent-conversion-partial-fix.md | 23 + ...echnique-sub-parent-conversion-workflow.md | 19 + .nocommit/prompts/validation-bypass-rules.md | 424 +++++++++ .../prompts/validation-bypasses-impl-plan.md | 165 ++++ .nocommit/prompts/virtual tracks.md | 102 +++ .nocommit/run-field-probe.js | 383 ++++++++ .nocommit/test-mongoose-serialization.js | 136 +++ ADM-test-migration-handoff.md | 173 ++++ CONTRIBUTING.md | 2 +- Dockerfile | 4 +- README.md | 2 +- USAGE.md | 6 +- app/lib/default-bypass-rules.json.original | 94 ++ codex-prompt.md | 9 + docs/README.md | 6 +- .../crud-regression-test-taxonomy.md | 276 ++++++ 35 files changed, 6573 insertions(+), 10 deletions(-) create mode 100644 .nocommit/backend-field-test.js create mode 100644 .nocommit/docs/99_IMPLEMENTATION_PLAN.md create mode 100644 .nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md create mode 100644 .nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md create mode 100644 .nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md create mode 100644 .nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md create mode 100644 .nocommit/field-requirements-probe.js create mode 100644 .nocommit/plans/workflow-response-ontology.md create mode 100644 .nocommit/prompts/adm-validation-explainer.md create mode 100644 .nocommit/prompts/fix-import-pipeline.md create mode 100644 .nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md create mode 100644 .nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md create mode 100644 .nocommit/prompts/organization-identity-syncing.md create mode 100644 .nocommit/prompts/refactor-export-service.md create mode 100644 .nocommit/prompts/remove the include_candidates_in_snapshots property.md create mode 100644 .nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md create mode 100644 .nocommit/prompts/revoke-workflow-bug.md create mode 100644 .nocommit/prompts/revoke-workflow.md create mode 100644 .nocommit/prompts/synthetic-data-generator.md create mode 100644 .nocommit/prompts/technique-sub-parent-conversion-partial-fix.md create mode 100644 .nocommit/prompts/technique-sub-parent-conversion-workflow.md create mode 100644 .nocommit/prompts/validation-bypass-rules.md create mode 100644 .nocommit/prompts/validation-bypasses-impl-plan.md create mode 100644 .nocommit/prompts/virtual tracks.md create mode 100644 .nocommit/run-field-probe.js create mode 100644 .nocommit/test-mongoose-serialization.js create mode 100644 ADM-test-migration-handoff.md create mode 100644 app/lib/default-bypass-rules.json.original create mode 100644 codex-prompt.md create mode 100644 docs/developer/crud-regression-test-taxonomy.md diff --git a/.nocommit/backend-field-test.js b/.nocommit/backend-field-test.js new file mode 100644 index 00000000..e24ccab1 --- /dev/null +++ b/.nocommit/backend-field-test.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node + +'use strict'; + +/** + * Backend Field Requirements Test + * + * This script specifically tests the fields that are typically set by the backend + * to identify which ones the frontend needs to start including when posting + * "awaiting-review" objects. + * + * Focus areas: + * 1. Core STIX fields: type, spec_version, created, modified + * 2. Backend-managed fields: x_mitre_attack_spec_version, x_mitre_domains, x_mitre_modified_by_ref + * 3. STIX identity fields: created_by_ref, object_marking_refs + */ + +const supertest = require('supertest'); +const { v4: uuidv4 } = require('uuid'); + +// Import your Express app +const app = require('./app/app'); + +// Test configuration +const TEST_CONFIG = { + // Standard test identity and marking refs used by the backend + IDENTITY_REF: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + MARKING_REF: 'marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168', + ATTACK_SPEC_VERSION: '3.3.0', + DOMAINS: ['enterprise-attack'] +}; + +/** + * Backend-managed fields that we want to test + */ +const BACKEND_FIELDS_TO_TEST = [ + 'x_mitre_attack_spec_version', + 'x_mitre_domains', + 'x_mitre_modified_by_ref', + 'created_by_ref', + 'object_marking_refs' +]; + +/** + * Core STIX fields that should always be required + */ +const CORE_STIX_FIELDS = [ + 'type', + 'spec_version', + 'created', + 'modified' +]; + +/** + * Create base valid objects for testing + */ +function createBaseObjects() { + const now = new Date().toISOString(); + + const baseStixObject = { + type: 'attack-pattern', // Will be overridden per object type + spec_version: '2.1', + id: '', // Will be set per test + created: now, + modified: now, + name: 'Test Object', + description: 'Test description', + x_mitre_attack_spec_version: TEST_CONFIG.ATTACK_SPEC_VERSION, + x_mitre_version: '1.0', + x_mitre_domains: TEST_CONFIG.DOMAINS, + x_mitre_modified_by_ref: TEST_CONFIG.IDENTITY_REF, + created_by_ref: TEST_CONFIG.IDENTITY_REF, + object_marking_refs: [TEST_CONFIG.MARKING_REF], + external_references: [{ + source_name: 'mitre-attack', + external_id: 'T9999', + url: 'https://attack.mitre.org/techniques/T9999' + }] + }; + + return { + technique: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'T9999' + }, + stix: { + ...baseStixObject, + type: 'attack-pattern', + id: 'attack-pattern--' + uuidv4(), + x_mitre_is_subtechnique: false + } + }, + + tactic: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'TA9999' + }, + stix: { + ...baseStixObject, + type: 'x-mitre-tactic', + id: 'x-mitre-tactic--' + uuidv4(), + x_mitre_shortname: 'test-tactic', + external_references: [{ + source_name: 'mitre-attack', + external_id: 'TA9999', + url: 'https://attack.mitre.org/tactics/TA9999' + }] + } + }, + + campaign: { + workspace: { + workflow: { state: 'awaiting-review' } + }, + stix: { + ...baseStixObject, + type: 'campaign', + id: 'campaign--' + uuidv4(), + aliases: ['Test Campaign'], + first_seen: now, + last_seen: now, + x_mitre_first_seen_citation: '(Citation: Test)', + x_mitre_last_seen_citation: '(Citation: Test)', + revoked: false, + external_references: [{ + source_name: 'Test', + description: 'Test reference' + }] + } + } + }; +} + +/** + * Test field requirements for a specific object type + */ +async function testObjectFieldRequirements(objectType, endpoint, baseObject) { + console.log(`\nšŸ” Testing ${objectType} (${endpoint})`); + + const results = { + objectType, + endpoint, + coreFieldResults: {}, + backendFieldResults: {}, + validationErrors: [] + }; + + // Test 1: Core STIX fields - these should ALWAYS be required + console.log(`\n Testing core STIX fields...`); + for (const field of CORE_STIX_FIELDS) { + try { + const testObject = JSON.parse(JSON.stringify(baseObject)); + delete testObject.stix[field]; + + const response = await makeRequest(endpoint, testObject); + results.coreFieldResults[field] = { + required: response.status === 400, + status: response.status, + error: response.status === 400 ? response.body : null + }; + + console.log(` ${field}: ${response.status === 400 ? 'āŒ Required' : 'āœ… Optional'}`); + } catch (error) { + results.coreFieldResults[field] = { error: error.message }; + console.log(` ${field}: šŸ’„ Error - ${error.message}`); + } + } + + // Test 2: Backend-managed fields - these are the problematic ones + console.log(`\n Testing backend-managed fields...`); + for (const field of BACKEND_FIELDS_TO_TEST) { + try { + const testObject = JSON.parse(JSON.stringify(baseObject)); + delete testObject.stix[field]; + + const response = await makeRequest(endpoint, testObject); + results.backendFieldResults[field] = { + required: response.status === 400, + status: response.status, + error: response.status === 400 ? response.body : null + }; + + const status = response.status === 400 ? 'āŒ Required (PROBLEM!)' : 'āœ… Optional'; + console.log(` ${field}: ${status}`); + + if (response.status === 400) { + results.validationErrors.push({ + field, + error: response.body + }); + } + } catch (error) { + results.backendFieldResults[field] = { error: error.message }; + console.log(` ${field}: šŸ’„ Error - ${error.message}`); + } + } + + // Test 3: Full object with "awaiting-review" status + console.log(`\n Testing complete awaiting-review object...`); + try { + const response = await makeRequest(endpoint, baseObject); + console.log(` Complete object: ${response.status < 400 ? 'āœ… Valid' : 'āŒ Invalid'}`); + if (response.status >= 400) { + console.log(` Error: ${JSON.stringify(response.body, null, 2)}`); + } + } catch (error) { + console.log(` Complete object: šŸ’„ Error - ${error.message}`); + } + + return results; +} + +/** + * Make HTTP request with proper headers + */ +async function makeRequest(endpoint, data) { + return supertest(app) + .post(endpoint) + .send(data) + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer test-token') // Adjust auth as needed + .timeout(5000); +} + +/** + * Generate analysis report + */ +function generateAnalysisReport(allResults) { + console.log('\n' + '='*80); + console.log('šŸ“Š BACKEND FIELD REQUIREMENTS ANALYSIS'); + console.log('='*80); + + // Analyze core STIX field requirements + console.log(`\nšŸ”’ CORE STIX FIELD ANALYSIS:`); + const coreFieldAnalysis = {}; + CORE_STIX_FIELDS.forEach(field => { + const results = allResults.map(r => r.coreFieldResults[field]?.required).filter(Boolean); + const requiredCount = results.filter(Boolean).length; + const totalCount = results.length; + coreFieldAnalysis[field] = { requiredCount, totalCount }; + console.log(` ${field}: Required in ${requiredCount}/${totalCount} schemas`); + }); + + // Analyze backend field requirements - THIS IS THE KEY SECTION + console.log(`\nāš ļø BACKEND FIELD ANALYSIS (PROBLEMATIC FIELDS):`); + const problematicFields = []; + + BACKEND_FIELDS_TO_TEST.forEach(field => { + const results = allResults.map(r => r.backendFieldResults[field]); + const requiredResults = results.filter(r => r?.required); + const totalTested = results.filter(r => r && !r.error).length; + + if (requiredResults.length > 0) { + problematicFields.push({ + field, + requiredIn: requiredResults.length, + totalTested, + schemas: allResults + .filter(r => r.backendFieldResults[field]?.required) + .map(r => r.objectType) + }); + + console.log(` āŒ ${field}: Required in ${requiredResults.length}/${totalTested} schemas`); + console.log(` Affects: ${problematicFields[problematicFields.length - 1].schemas.join(', ')}`); + } else { + console.log(` āœ… ${field}: Optional in all schemas`); + } + }); + + // Frontend recommendations + console.log(`\nšŸŽÆ FRONTEND RECOMMENDATIONS:`); + + if (problematicFields.length === 0) { + console.log(` āœ… Good news! No backend-managed fields are required for "awaiting-review" objects.`); + } else { + console.log(` āš ļø The frontend needs to include these fields when posting "awaiting-review" objects:`); + problematicFields.forEach(pf => { + console.log(`\n šŸ“‹ ${pf.field}:`); + console.log(` - Required in: ${pf.schemas.join(', ')}`); + console.log(` - Recommended value: Check backend configuration`); + + // Provide specific recommendations + if (pf.field === 'x_mitre_attack_spec_version') { + console.log(` - Suggested value: "${TEST_CONFIG.ATTACK_SPEC_VERSION}"`); + } else if (pf.field === 'x_mitre_domains') { + console.log(` - Suggested value: ${JSON.stringify(TEST_CONFIG.DOMAINS)}`); + } else if (pf.field === 'x_mitre_modified_by_ref') { + console.log(` - Suggested value: "${TEST_CONFIG.IDENTITY_REF}"`); + } else if (pf.field === 'created_by_ref') { + console.log(` - Suggested value: "${TEST_CONFIG.IDENTITY_REF}"`); + } else if (pf.field === 'object_marking_refs') { + console.log(` - Suggested value: ["${TEST_CONFIG.MARKING_REF}"]`); + } + }); + } + + // Validation errors summary + const allValidationErrors = allResults.flatMap(r => r.validationErrors); + if (allValidationErrors.length > 0) { + console.log(`\nšŸ’„ VALIDATION ERRORS ENCOUNTERED:`); + allValidationErrors.forEach(err => { + console.log(` - ${err.field}: ${JSON.stringify(err.error)}`); + }); + } + + return { coreFieldAnalysis, problematicFields, allValidationErrors }; +} + +/** + * Main execution + */ +async function main() { + console.log('šŸš€ Starting Backend Field Requirements Test'); + console.log('='*80); + + const baseObjects = createBaseObjects(); + const testCases = [ + { type: 'technique', endpoint: '/api/techniques', object: baseObjects.technique }, + { type: 'tactic', endpoint: '/api/tactics', object: baseObjects.tactic }, + { type: 'campaign', endpoint: '/api/campaigns', object: baseObjects.campaign } + ]; + + const allResults = []; + + for (const testCase of testCases) { + try { + const results = await testObjectFieldRequirements( + testCase.type, + testCase.endpoint, + testCase.object + ); + allResults.push(results); + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.error(`šŸ’„ Failed to test ${testCase.type}:`, error.message); + } + } + + // Generate comprehensive analysis + const analysis = generateAnalysisReport(allResults); + + console.log('\nāœ… Backend field requirements test complete!'); + return analysis; +} + +// Export for potential use as module +module.exports = { + createBaseObjects, + testObjectFieldRequirements, + generateAnalysisReport, + BACKEND_FIELDS_TO_TEST, + CORE_STIX_FIELDS +}; + +// Run if called directly +if (require.main === module) { + main().catch(error => { + console.error('šŸ’„ Test failed:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/.nocommit/docs/99_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..69cb96b0 --- /dev/null +++ b/.nocommit/docs/99_IMPLEMENTATION_PLAN.md @@ -0,0 +1,401 @@ +# Release Tracks V2 - Implementation Design + +## Architecture Overview + +``` + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ release-tracks-routes.js │ (auto-discovered) + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ release-tracks-controller.js │ + │ (Zod validation, response formatting) │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ release-tracks-service.js │ + │ (Facade / Orchestrator) │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ snapshot │ standard │ version │ virtual │ export │ + │ -service │ -track │ -ing │ -track │ -service │ + │ │ -service │ -service │ -service │ │ + │ │ │ │ │ │ + │ │ workflow │ │ │ │ + │ │ -service │ │ │ │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ registry.repo │ │ dynamic.repo │ + │ (releaseTrack │ │ (per-track collections │ + │ Registry coll) │ │ via ModelFactory) │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ ModelFactory │ + │ (dynamic Mongoose │ + │ model cache) │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Key Architectural Decision: Collection-per-Track + +Each release track gets its own MongoDB collection (named `release-track--`). A central `releaseTrackRegistry` collection indexes all tracks for discovery/listing. This requires a **ModelFactory** that dynamically creates and caches Mongoose models at runtime -- the single most novel infrastructure component. + +### Relationship to Existing V2 Stubs + +The existing `collections-controller-v2.js` and `collections-routes-v2.js` serve endpoints under `/api/collections/`. These will be **deleted** and replaced by the new `/api/release-tracks/` routes. The existing stubs can serve as reference for patterns (Zod validation, error handling) but the new implementation starts fresh under the `release-tracks` namespace. Clean break -- no dual-routing. + +--- + +## File Layout + +``` +app/ + models/release-tracks/ + release-track-registry-model.js ← Mongoose schema for registry collection + release-track-snapshot-schema.js ← Reusable schema for per-track snapshot docs + model-factory.js ← Dynamic Mongoose model creation/caching + + repository/release-tracks/ + release-track-registry.repository.js ← CRUD on releaseTrackRegistry collection + release-track-dynamic.repository.js ← CRUD on per-track collections via ModelFactory + + routes/ + release-tracks-routes.js ← All ~30 endpoints (auto-discovered) + + controllers/ + release-tracks-controller.js ← Request parsing, validation, delegation + + services/release-tracks/ + release-tracks-service.js ← Facade: delegates to sub-services + snapshot-service.js ← Snapshot creation, clone, delete + standard-track-service.js ← Candidate/staged/member management + workflow-service.js ← Auto-promotion, candidacy threshold logic + versioning-service.js ← Bump/tag, version history, preview + virtual-track-service.js ← Composition resolution, deduplication + export-service.js ← bundle/workbench/filesystemstore serialization + ephemeral-service.js ← Stateless domain bundle generation + + lib/release-tracks/ + release-track-validators.js ← Zod schemas for all request bodies + version-utils.js ← MAJOR.MINOR parse/compare/increment + deduplication-strategies.js ← 4 strategies for virtual track conflicts + resolution-strategies.js ← 3 strategies for component track resolution + conflict-resolution-policies.js ← 4 policies for tier promotion conflicts + snapshot-differ.js ← Diff/preview between snapshots + + scheduler/ + virtual-track-snapshot-task.js ← Auto-discovered by scheduler/index.js + + exceptions/index.js ← Add: ReleaseConflictError, NoTaggedSnapshotsError, + InvalidComponentTypeError, TrackNotFoundError + + lib/event-constants.js ← Add: release-track::created, ::released, + ::candidate-added, ::object-staged, etc. + + lib/error-handler.js ← Add: HTTP status mappings for new exceptions +``` + +--- + +## Data Model + +### Registry Collection (`releaseTrackRegistry`) + +```javascript +{ + track_id: "release-track--", // unique, indexed -- also the Mongo collection name + type: "standard" | "virtual", + name: "Enterprise ATT&CK", + description: "...", + + // Denormalized for fast listing (updated on each snapshot/tag) + latest_snapshot_modified: Date, + latest_tagged_version: String | null, + snapshot_count: Number, + tagged_release_count: Number, + + // Virtual tracks only + snapshot_schedule: { mode, cron?, dates? }, + + created_at: Date, + updated_at: Date +} +``` + +### Snapshot Document (one per doc in per-track collection) + +```javascript +{ + id: "release-track--", // same across all snapshots in this collection + type: "standard" | "virtual", + modified: Date, // unique per snapshot, compound index with id + version: String | null, // null = draft, "X.Y" = tagged + + name: String, + description: String, + created: Date, // when the track was first created + created_by_ref: String, + object_marking_refs: [String], + + // --- Standard track tiers --- + members: [{ object_ref, object_modified }], + staged: [{ object_ref, object_modified, object_status, object_staged_at, object_staged_by }], + candidates: [{ object_ref, object_modified, object_status, object_added_at, object_added_by }], + + // --- Virtual track tiers --- + quarantine: [{ object_ref, object_modified, source_track_id, source_track_name, + source_snapshot_version, conflict_reason }], + + // --- Virtual track composition --- + composition: { + component_tracks: [{ track_id, resolution_strategy, priority, version?, snapshot?, filters? }], + deduplication: { strategy } + }, + composition_resolution: { resolved_at, component_snapshots[], deduplication{}, summary{} }, + + // --- Shared --- + config: { + candidacy_threshold: "reviewed", + auto_promote: true, + promotion_conflicts: { candidates_to_staged, staged_to_members } + }, + version_history: [{ version, tagged_at, tagged_by, snapshot_id, summary, component_versions? }] +} +``` + +### Object Documents (existing attackObjects) + +**Deferred**: The `workspace.referenced_by` reverse index (tracking which release tracks reference each object version) is **not** included in the initial scaffolding. Queries like "which tracks contain this object?" will require scanning track collections rather than a reverse lookup. This will be added as a follow-up phase once the core release track system is stable. + +--- + +## Data Flow: Key Operations + +### 1. Create Standard Track + +``` +POST /new → controller → releaseTracksService.createTrack() + → Generate UUID → "release-track--" + → registryRepo.create({ track_id, name, type: "standard", ... }) + → modelFactory.getModel(track_id) → creates Mongoose model + Mongo collection + → dynamicRepo.saveSnapshot(track_id, { id, modified: now, version: null, members: [], ... }) + → modelFactory.ensureIndexes(track_id) + → eventBus.emit('release-track::created') + → Return initial snapshot +``` + +### 2. Add Candidates + Auto-Promotion + +``` +POST /:id/candidates → controller → standardTrackService.addCandidates() + → dynamicRepo.getLatestSnapshot(trackId) + → For each object_ref: + → Validate object exists in attackObjects collection + → Resolve modified (use latest if omitted) + → Check duplicates in candidates[] + → Create candidate entry { object_ref, object_modified, status: "work-in-progress" } + → workflowService.checkAutoPromotion(): + If status meets config.candidacy_threshold → move to staged[] + If conflict with existing staged entry → apply conflict resolution policy + → Clone snapshot with new modified timestamp, save + → Update registry (latest_snapshot_modified, snapshot_count) + → eventBus.emit('release-track::candidate-added') + → Return { added, autoPromoted, errors } +``` + +### 3. Bump/Tag + +``` +POST /:id/bump → controller → versioningService.bumpLatest() + → dynamicRepo.getLatestSnapshot(trackId) + → Validate version === null (else throw AlreadyReleasedError) + → versionUtils.calculateNextVersion(version_history, bumpType) + → workflowService.promoteStagedToMembers(): + Resolve dynamic pins ("latest" → actual modified timestamp) + For each staged entry: check conflict with members → apply policy + If policy=abort and conflicts exist → throw ReleaseConflictError + Move staged → members, clear staged + → If dry_run: return computed result without persisting + → dynamicRepo.tagSnapshotInPlace(trackId, modified, { version, historyEntry }) + → Update registry (latest_tagged_version, tagged_release_count) + → eventBus.emit('release-track::released') + → Return tagged snapshot with release_summary +``` + +### 4. Virtual Track Snapshot Creation + +``` +POST /:id/snapshots/create → controller → virtualTrackService.createSnapshot() + → dynamicRepo.getLatestSnapshot(trackId) + → Validate type === "virtual" + → For each component_tracks entry: + → resolutionStrategies.resolve(component, dynamicRepo) → component snapshot + → Validate snapshot is tagged (version !== null) + → Validate component is standard (not virtual) + → Collect members from resolved snapshot + → Apply filters (object_types, domains) + → deduplicationStrategies.deduplicate(allObjects, config) → { members, quarantine, report } + → Build snapshot: { modified: now, version: null, members, quarantine, composition_resolution } + → dynamicRepo.saveSnapshot(trackId, snapshot) + → Update registry + → Return snapshot with composition_resolution +``` + +### 5. Export as STIX Bundle + +``` +GET /:id?format=bundle → controller → exportService.exportAsBundle() + → Resolve snapshot (latest, or by version/modified) + → Build x-mitre-collection SDO from snapshot metadata + → Batch-fetch member objects from attackObjects collection + → Strip workspace data, keep only stix.* properties + → Return { type: "bundle", id: "bundle--", objects: [collectionSDO, ...memberObjects] } +``` + +--- + +## Parallel Work Streams + +Seven independent streams with a clean dependency graph: + +``` + WS1 (Infrastructure) ←── Foundation, no dependencies + │ + ā”œā”€ā”€ WS2 (Routes + Controller) ←── Independent (uses service stubs) + │ + ā”œā”€ā”€ WS3 (Standard Track Core) ←── depends on WS1 + │ │ + │ ā”œā”€ā”€ WS4 (Workflow Engine) ←── depends on WS1 + │ │ │ + │ │ └── WS5 (Versioning) ←── depends on WS1, optionally WS4 + │ │ + │ └── WS7 (Export Engine) ←── depends on WS1 + │ + └── WS6 (Virtual Track Engine) ←── depends on WS1 only +``` + +### WS1: Infrastructure Layer (Foundation) +**Scope**: MongoDB schemas, ModelFactory, both repositories +**Files**: `models/release-tracks/*`, `repository/release-tracks/*` +**Tests**: Unit tests for ModelFactory caching, registry CRUD, dynamic repo CRUD + +### WS2: Route + Controller Shell +**Scope**: All ~30 endpoints wired with auth/validation, returning 501 until services ready +**Files**: `routes/release-tracks-routes.js`, `controllers/release-tracks-controller.js`, `lib/release-tracks/release-track-validators.js` +**Tests**: Route registration, auth gating, Zod validation (400 on bad input) + +### WS3: Standard Track Core (Create, Read, Delete, Clone) +**Scope**: Track lifecycle -- create, list, retrieve, clone, delete, metadata/contents update, ephemeral bundles +**Files**: `services/release-tracks/release-tracks-service.js` (facade), `snapshot-service.js`, `ephemeral-service.js` +**Tests**: Full lifecycle tests against in-memory MongoDB + +### WS4: Workflow Engine (Candidates, Staging, Auto-Promotion) +**Scope**: Three-tier lifecycle, status transitions, auto-promotion, conflict resolution +**Files**: `services/release-tracks/standard-track-service.js`, `workflow-service.js`, `lib/release-tracks/conflict-resolution-policies.js` +**Tests**: Promotion flows, threshold configs, conflict scenarios + +### WS5: Versioning Engine (Bump, Tag, Preview, Dry-Run) +**Scope**: Version calculation, in-place tagging, version history, preview/diff, dry-run +**Files**: `services/release-tracks/versioning-service.js`, `lib/release-tracks/version-utils.js`, `lib/release-tracks/snapshot-differ.js` +**Tests**: Version arithmetic, tag validation, preview accuracy + +### WS6: Virtual Track Engine (Composition, Resolution, Deduplication) +**Scope**: Virtual track CRUD, composition resolution, deduplication, quarantine, scheduling +**Files**: `services/release-tracks/virtual-track-service.js`, `lib/release-tracks/resolution-strategies.js`, `lib/release-tracks/deduplication-strategies.js`, `scheduler/virtual-track-snapshot-task.js` +**Tests**: Resolution strategy correctness, dedup strategy correctness, quarantine flows + +### WS7: Export Engine (Bundle, FileSystemStore, Workbench Formats) +**Scope**: Serialize snapshots to output formats, fetch STIX objects, strip workspace data +**Files**: `services/release-tracks/export-service.js` +**Tests**: Format correctness, large bundle handling + +--- + +## Interface Contracts + +### ModelFactory +```javascript +getModel(trackId: string): mongoose.Model // Get or create cached model +removeModel(trackId: string): void // Cleanup on track delete +ensureIndexes(trackId: string): Promise // Create indexes on new collection +``` + +### RegistryRepository +```javascript +create(data): Promise // Register new track +findByTrackId(trackId): Promise // Lookup single track +findAll(options): Promise<{data, pagination}> // List with filtering/pagination +updateByTrackId(trackId, updates): Promise // Update denormalized fields +deleteByTrackId(trackId): Promise // Remove registry entry +``` + +### DynamicRepository +```javascript +getLatestSnapshot(trackId): Promise +getSnapshotByModified(trackId, modified): Promise +getLatestTaggedSnapshot(trackId): Promise +getSnapshotByVersion(trackId, version): Promise +getAllSnapshots(trackId, options): Promise +saveSnapshot(trackId, snapshotData): Promise +tagSnapshotInPlace(trackId, modified, versionData): Promise +updateSnapshot(trackId, modified, updateOps): Promise +deleteSnapshot(trackId, modified): Promise +deleteAllSnapshots(trackId): Promise +dropCollection(trackId): Promise // Drops entire Mongo collection +``` + +### Facade Service (consumed by controller) +```javascript +// Track management +listTracks(options), createTrack(data), getLatestSnapshot(trackId, options), +getSnapshotByModified(trackId, modified, options), updateMetadata(trackId, updates), +updateContents(trackId, contents), cloneTrack(trackId, options), +deleteTrack(trackId), deleteSnapshot(trackId, modified), +createTrackFromBundle(bundleData) + +// Candidates +addCandidates(trackId, objectRefs, userId), listCandidates(trackId, options), +removeCandidate(trackId, objectRef), reviewCandidates(trackId, reviewData), +promoteCandidates(trackId, objectRefs), updateCandidateVersion(trackId, objectRef, data) + +// Staged +listStaged(trackId), demoteStaged(trackId, objectRefs) + +// Versioning +bumpLatest(trackId, options), bumpSpecific(trackId, modified, options), previewBump(trackId) + +// Config +getConfig(trackId), updateConfig(trackId, config) + +// Virtual +updateComposition(trackId, composition), createVirtualSnapshot(trackId, options), +previewVirtualSnapshot(trackId) + +// Export +exportSnapshot(trackId, modified, format), getEphemeralBundle(domain, format) +``` + +--- + +## Integration Points with Existing Codebase + +| Concern | How to Integrate | +|---------|-----------------| +| **Route auto-discovery** | File named `release-tracks-routes.js` in `app/routes/` is auto-registered | +| **Auth middleware** | Use existing `authn.authenticate` + `authz.requireRole()` | +| **Error handling** | Add 4 new exception classes to `app/exceptions/index.js`; add HTTP mappings to `app/lib/error-handler.js` | +| **Event system** | Add ~10 event constants to `app/lib/event-constants.js`; emit from services | +| **Scheduler** | File named `virtual-track-snapshot-task.js` in `app/scheduler/` is auto-discovered | +| **STIX object lookup** | Use existing services (techniques, groups, etc.) for object validation and bulk retrieval | +| **Validation** | Zod schemas in `release-track-validators.js`; ADM schema for bundle import | + +--- + +## Verification Plan + +1. **Unit tests**: Each utility module (`version-utils`, `deduplication-strategies`, `resolution-strategies`, `conflict-resolution-policies`) tested in isolation +2. **Repository tests**: ModelFactory + DynamicRepository against `mongodb-memory-server` +3. **Service integration tests**: Each sub-service tested with real MongoDB, mocked dependencies +4. **API integration tests**: Full HTTP round-trips via supertest for all ~30 endpoints +5. **End-to-end scenario tests**: Multi-step workflows (create track → add candidates → review → bump → export) diff --git a/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..cdc0ee73 --- /dev/null +++ b/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md @@ -0,0 +1,842 @@ +# Member Sync Strategies — Implementation Plan + +> **Purpose**: Implementation guide for the Member Sync Strategies feature documented in [08_MEMBER_SYNC_STRATEGIES.md](./08_MEMBER_SYNC_STRATEGIES.md). This plan follows the architecture established in [99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md](./99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md). + +--- + +## Status Tracker + +| Phase | Description | Status | +|-------|-------------|--------| +| A | Schema Updates (Mongoose + Zod + Defaults) | TODO | +| B | Member Sync Service (Core Logic) | TODO | +| C | Event Integration (STIX Object Hooks) | TODO | +| D | Testing & Verification | TODO | + +--- + +## Architecture Integration + +### Where Member Sync Fits + +``` +Controller + └─▶ release-tracks-service.js (Facade) + ā”œā”€ā–¶ snapshot-service.js ← Phase 1 (existing) + ā”œā”€ā–¶ standard-track-service.js ← Phase 2 (existing) + ā”œā”€ā–¶ workflow-service.js ← Phase 3 (existing) + ā”œā”€ā–¶ versioning-service.js ← Phase 4 (existing) + ā”œā”€ā–¶ virtual-track-service.js ← Phase 5 (existing) + └─▶ member-sync-service.js ← NEW (this plan) + +STIX Object Services (techniques, groups, malware, etc.) + └─▶ EventBus.emit('stix-object::modified') + └─▶ member-sync-service.handleObjectModified() +``` + +### Design Decision: Dedicated Service vs. Workflow Extension + +**Choice**: Create a new `member-sync-service.js` rather than extending `workflow-service.js`. + +**Rationale**: +1. **Single Responsibility**: Workflow service handles candidacy thresholds and auto-promotion. Member sync is a distinct concern (event-driven enrollment from external modifications). +2. **Event-Driven Nature**: Member sync listens to STIX object events, while workflow service operates on release track events. +3. **Testability**: A dedicated service can be tested in isolation with mocked events. +4. **Future Flexibility**: Member sync may evolve independently (e.g., per-object exclusions, batch processing). + +### Dependencies + +``` +member-sync-service.js + ā”œā”€ā–¶ registryRepo (find tracks where object is a member) + ā”œā”€ā–¶ dynamicRepo (read/write snapshots) + ā”œā”€ā–¶ snapshotService (cloneSnapshot helper) + ā”œā”€ā–¶ workflowService (auto-promotion after enrollment) + └─▶ EventBus (subscribe to stix-object events) +``` + +--- + +## Phase A: Schema Updates + +### Scope + +Update all schema layers to support the `member_sync` configuration: +1. Mongoose schema (database storage) +2. Zod schemas (request/response validation) +3. Default configuration in `createTrack()` + +### A.1: Mongoose Schema Update + +**File**: `app/models/release-tracks/release-track-snapshot-schema.js` + +Add `member_sync` to the `config` subdocument: + +```javascript +config: { + // Existing fields... + candidacy_threshold: { + type: String, + enum: ['work-in-progress', 'awaiting-review', 'reviewed'], + default: 'reviewed' + }, + auto_promote: { + type: Boolean, + default: true + }, + promotion_conflicts: { + candidates_to_staged: { + type: String, + enum: ['always_overwrite', 'always_reject', 'prefer_latest'], + default: 'prefer_latest' + }, + staged_to_members: { + type: String, + enum: ['always_overwrite', 'always_reject', 'prefer_latest', 'abort'], + default: 'abort' + } + }, + + // NEW: Member Sync Configuration + member_sync: { + strategy: { + type: String, + enum: ['track_latest', 'manual'], + default: 'track_latest' + }, + supplant: { + behavior: { + type: String, + enum: ['replace', 'queue', 'ignore'], + default: 'replace' + }, + status_policy: { + type: String, + enum: ['reset', 'preserve'], + default: 'reset' + } + } + } +} +``` + +### A.2: Zod Schema Updates + +**File**: `app/lib/release-tracks/release-track-schemas.js` + +Add validation schemas for member sync configuration: + +```javascript +// Member sync supplant behavior +const memberSyncSupplantBehaviorSchema = z.enum(['replace', 'queue', 'ignore']); + +// Member sync status policy +const memberSyncStatusPolicySchema = z.enum(['reset', 'preserve']); + +// Member sync strategy +const memberSyncStrategySchema = z.enum(['track_latest', 'manual']); + +// Complete member sync config +const memberSyncConfigSchema = z.object({ + strategy: memberSyncStrategySchema.optional(), + supplant: z.object({ + behavior: memberSyncSupplantBehaviorSchema.optional(), + status_policy: memberSyncStatusPolicySchema.optional() + }).optional() +}).optional(); + +// Update the track config schema to include member_sync +const trackConfigSchema = z.object({ + candidacy_threshold: z.enum(['work-in-progress', 'awaiting-review', 'reviewed']).optional(), + auto_promote: z.boolean().optional(), + promotion_conflicts: z.object({ + candidates_to_staged: z.enum(['always_overwrite', 'always_reject', 'prefer_latest']).optional(), + staged_to_members: z.enum(['always_overwrite', 'always_reject', 'prefer_latest', 'abort']).optional() + }).optional(), + member_sync: memberSyncConfigSchema // NEW +}); + +// Export for use in validators +module.exports = { + // ... existing exports + memberSyncConfigSchema, + memberSyncStrategySchema, + memberSyncSupplantBehaviorSchema, + memberSyncStatusPolicySchema +}; +``` + +### A.3: Default Configuration + +**File**: `app/services/release-tracks/snapshot-service.js` + +In `createTrack()`, set explicit defaults for member sync: + +```javascript +exports.createTrack = async function(data) { + // ... existing code ... + + const initialSnapshot = { + // ... existing fields ... + config: { + candidacy_threshold: 'reviewed', + auto_promote: true, + promotion_conflicts: { + candidates_to_staged: 'prefer_latest', + staged_to_members: 'abort' + }, + // NEW: Default member sync config + member_sync: { + strategy: 'track_latest', + supplant: { + behavior: 'replace', + status_policy: 'reset' + } + } + } + }; + + // ... rest of function ... +}; +``` + +The Mongoose schema defaults (defined in A.1) will also apply if `config.member_sync` is not explicitly provided. + +### A.4: Verification (Phase A) + +```bash +# Unit tests for Zod schemas +npm test -- --grep "member_sync schema" + +# Verify Mongoose schema accepts new config +# Create a track and check config is populated +curl -X POST http://localhost:3000/api/release-tracks/new \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test Track","type":"standard"}' + +# Verify config includes member_sync with defaults +curl http://localhost:3000/api/release-tracks//config +``` + +--- + +## Phase B: Member Sync Service + +### Scope + +Implement the core member sync logic in a dedicated service that: +1. Determines if/how to enroll a new object revision +2. Handles supplant behavior (replace/queue/ignore) +3. Applies status policy (reset/preserve) +4. Creates new draft snapshots + +### B.1: New File: `app/services/release-tracks/member-sync-service.js` + +**Dependencies**: +```javascript +'use strict'; + +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const snapshotService = require('./snapshot-service'); +const workflowService = require('./workflow-service'); +const logger = require('../../lib/logger'); +``` + +### B.2: Core Method: `handleObjectModified()` + +This is the main entry point, called when a STIX object is created or modified. + +```javascript +/** + * Handle a STIX object modification event. + * Identifies release tracks where the object is a member and applies + * the configured member sync strategy. + * + * @param {Object} event - The modification event + * @param {string} event.objectRef - The STIX ID of the modified object + * @param {Date} event.newModified - The new modified timestamp + * @param {Date} event.oldModified - The previous modified timestamp (if update) + * @param {string} event.modifiedBy - User who made the modification + * @returns {Promise} Array of affected release track snapshots + */ +exports.handleObjectModified = async function(event) { + const { objectRef, newModified, modifiedBy } = event; + + // 1. Find all release tracks where this object is in members + const affectedTracks = await findTracksWithObjectInMembers(objectRef); + + if (affectedTracks.length === 0) { + logger.debug(`[member-sync] No release tracks contain ${objectRef} in members`); + return []; + } + + logger.debug(`[member-sync] Found ${affectedTracks.length} tracks with ${objectRef} in members`); + + // 2. Process each track according to its member_sync config + const results = []; + for (const trackInfo of affectedTracks) { + try { + const result = await processMemberSync(trackInfo.trackId, { + objectRef, + newModified, + modifiedBy + }); + if (result) results.push(result); + } catch (err) { + logger.error(`[member-sync] Error processing track ${trackInfo.trackId}: ${err.message}`); + // Continue processing other tracks; don't let one failure stop all + } + } + + return results; +}; +``` + +### B.3: Helper: `findTracksWithObjectInMembers()` + +Query all release tracks to find where the object is a member. + +```javascript +/** + * Find all release tracks where the given object is in the members array. + * + * @param {string} objectRef - The STIX ID to search for + * @returns {Promise>} + */ +async function findTracksWithObjectInMembers(objectRef) { + // Get all track IDs from registry + const allTracks = await registryRepo.findAll({ limit: 10000 }); + const results = []; + + for (const trackInfo of allTracks.data) { + // Skip virtual tracks (they don't have the same member sync semantics) + if (trackInfo.type === 'virtual') continue; + + const snapshot = await dynamicRepo.getLatestSnapshot(trackInfo.track_id); + if (!snapshot) continue; + + // Check if object is in members + const memberEntry = snapshot.members?.find(m => m.object_ref === objectRef); + if (memberEntry) { + results.push({ + trackId: trackInfo.track_id, + memberEntry, + snapshot + }); + } + } + + return results; +} +``` + +**Performance Note**: This naive approach queries all tracks. For large deployments, consider: +1. Adding a reverse index (`workspace.referenced_by` on objects as noted in the design doc) +2. Caching track membership +3. Using MongoDB aggregation with `$lookup` + +For MVP, the naive approach is acceptable given expected track counts. + +### B.4: Core Logic: `processMemberSync()` + +Apply the configured strategy to a single release track. + +```javascript +/** + * Process member sync for a single release track. + * + * @param {string} trackId + * @param {Object} event + * @returns {Promise} New snapshot if changes made, null otherwise + */ +async function processMemberSync(trackId, event) { + const { objectRef, newModified, modifiedBy } = event; + + // Get latest snapshot with defaults applied + const snapshot = await snapshotService.getLatestSnapshot(trackId); + const config = snapshot.config.member_sync; + + // Check strategy + if (config.strategy === 'manual') { + logger.debug(`[member-sync] Track ${trackId} uses manual strategy, skipping auto-enrollment`); + return null; + } + + // strategy === 'track_latest' + // Check if object already exists in candidates or staged + const existingInCandidates = snapshot.candidates?.find(c => c.object_ref === objectRef); + const existingInStaged = snapshot.staged?.find(s => s.object_ref === objectRef); + const existingEntry = existingInStaged || existingInCandidates; + const existingTier = existingInStaged ? 'staged' : (existingInCandidates ? 'candidates' : null); + + // Determine action based on supplant.behavior + let action = null; + if (!existingEntry) { + // No existing entry → simple enrollment + action = { type: 'enroll', tier: 'candidates' }; + } else { + // Existing entry → apply supplant behavior + switch (config.supplant.behavior) { + case 'replace': + action = { + type: 'replace', + removeTier: existingTier, + removeEntry: existingEntry, + targetTier: config.supplant.status_policy === 'preserve' ? existingTier : 'candidates' + }; + break; + case 'queue': + action = { type: 'enroll', tier: 'candidates' }; + break; + case 'ignore': + logger.debug(`[member-sync] Track ${trackId}: ignoring ${objectRef} (existing entry in ${existingTier})`); + return null; + } + } + + if (!action) return null; + + // Build the new candidate/staged entry + const now = new Date(); + const newEntry = { + object_ref: objectRef, + object_modified: new Date(newModified), + object_added_at: now, + object_added_by: modifiedBy || 'system' + }; + + // Determine status + if (action.type === 'replace' && config.supplant.status_policy === 'preserve') { + newEntry.object_status = action.removeEntry.object_status; + if (action.removeEntry.object_staged_at) { + newEntry.object_staged_at = now; + newEntry.object_staged_by = modifiedBy || 'system'; + } + } else { + newEntry.object_status = 'work-in-progress'; + } + + // Build updated tier arrays + let newCandidates = [...(snapshot.candidates || [])]; + let newStaged = [...(snapshot.staged || [])]; + + // Remove old entry if replacing + if (action.type === 'replace') { + if (action.removeTier === 'candidates') { + newCandidates = newCandidates.filter(c => + !(c.object_ref === objectRef && + c.object_modified.getTime() === action.removeEntry.object_modified.getTime()) + ); + } else if (action.removeTier === 'staged') { + newStaged = newStaged.filter(s => + !(s.object_ref === objectRef && + s.object_modified.getTime() === action.removeEntry.object_modified.getTime()) + ); + } + } + + // Add new entry to target tier + const targetTier = action.targetTier || action.tier; + if (targetTier === 'staged') { + newStaged.push(newEntry); + } else { + newCandidates.push(newEntry); + } + + // Clone snapshot with updated tiers + const newSnapshot = await snapshotService.cloneSnapshot(trackId, snapshot, { + candidates: newCandidates, + staged: newStaged + }); + + logger.info(`[member-sync] Track ${trackId}: ${action.type} ${objectRef} → ${targetTier}`); + + // Check if auto-promotion should occur (new entry in candidates that meets threshold) + if (targetTier === 'candidates' && snapshot.config.auto_promote) { + const promoted = await workflowService.evaluateAutoPromotion(trackId, newSnapshot); + if (promoted) { + logger.info(`[member-sync] Track ${trackId}: auto-promoted ${objectRef} to staged`); + return promoted; + } + } + + return newSnapshot; +} +``` + +### B.5: Facade Wiring + +**File**: `app/services/release-tracks/release-tracks-service.js` + +Add export for the member sync handler (used by event integration): + +```javascript +const memberSyncService = require('./member-sync-service'); + +// Expose for event handlers +exports.handleObjectModified = memberSyncService.handleObjectModified; +``` + +### B.6: Verification (Phase B) + +Unit tests for member-sync-service: + +```javascript +// app/tests/unit/services/release-tracks/member-sync-service.spec.js + +describe('member-sync-service', () => { + describe('handleObjectModified', () => { + it('should auto-enroll when strategy is track_latest', async () => { + // Setup: create track with track_latest config + // Modify object + // Assert: new snapshot has object in candidates + }); + + it('should not enroll when strategy is manual', async () => { + // Setup: create track with manual config + // Modify object + // Assert: no new snapshot created + }); + + it('should replace existing candidate when behavior is replace', async () => { + // Setup: track with existing candidate entry + // Modify object (new version) + // Assert: old version removed, new version added + }); + + it('should queue alongside existing when behavior is queue', async () => { + // Setup: track with existing candidate entry + // Modify object + // Assert: both versions present + }); + + it('should ignore when existing entry and behavior is ignore', async () => { + // Setup: track with existing candidate entry + // Modify object + // Assert: no change + }); + + it('should reset status when status_policy is reset', async () => { + // Setup: track with reviewed entry in staged + // Modify object with replace + reset + // Assert: new entry is work-in-progress in candidates + }); + + it('should preserve status when status_policy is preserve', async () => { + // Setup: track with reviewed entry in staged + // Modify object with replace + preserve + // Assert: new entry is reviewed in staged + }); + }); +}); +``` + +--- + +## Phase C: Event Integration + +### Scope + +Connect the member sync service to STIX object modification events so that sync happens automatically when objects are created or updated. + +### C.1: Identify Event Sources + +STIX objects can be modified via: +1. Direct CRUD endpoints (`POST /api/techniques`, `PUT /api/techniques/:id`, etc.) +2. Bundle imports (`POST /api/collection-bundles`) +3. Other internal operations + +The existing codebase uses an EventBus pattern (see `app/lib/event-constants.js`). We need to: +1. Identify where STIX modifications occur +2. Emit events with the required data +3. Subscribe to these events in member-sync-service + +### C.2: Add Event Constants + +**File**: `app/lib/event-constants.js` + +Add new event constant: + +```javascript +module.exports = { + // ... existing constants ... + + // STIX Object Events (for member sync) + STIX_OBJECT_CREATED: 'stix-object::created', + STIX_OBJECT_MODIFIED: 'stix-object::modified', + STIX_OBJECT_DELETED: 'stix-object::deleted' +}; +``` + +### C.3: Emit Events from STIX Services + +The STIX object services (techniques, groups, malware, etc.) need to emit events when objects are created or modified. This requires modifications to the base service layer or individual services. + +**Option A**: Modify each STIX service individually (tedious but explicit) +**Option B**: Add a post-save hook at the repository layer (cleaner) + +**Recommended**: Option B with a dedicated event emitter utility. + +**File**: Create `app/lib/stix-object-events.js` + +```javascript +'use strict'; + +const EventEmitter = require('events'); +const eventConstants = require('./event-constants'); +const logger = require('./logger'); + +const stixObjectEvents = new EventEmitter(); + +/** + * Emit a STIX object modification event. + * Called after STIX objects are created or updated. + * + * @param {Object} params + * @param {string} params.objectRef - STIX ID of the object + * @param {Date} params.newModified - New modified timestamp + * @param {Date} [params.oldModified] - Previous modified timestamp (for updates) + * @param {string} [params.modifiedBy] - User who made the change + * @param {string} params.eventType - 'created' or 'modified' + */ +exports.emitObjectModified = function(params) { + const event = { + objectRef: params.objectRef, + newModified: params.newModified, + oldModified: params.oldModified, + modifiedBy: params.modifiedBy, + timestamp: new Date() + }; + + const eventName = params.eventType === 'created' + ? eventConstants.STIX_OBJECT_CREATED + : eventConstants.STIX_OBJECT_MODIFIED; + + logger.debug(`[stix-events] Emitting ${eventName} for ${params.objectRef}`); + stixObjectEvents.emit(eventName, event); + + // Also emit generic 'modified' for member sync (handles both create and update) + stixObjectEvents.emit(eventConstants.STIX_OBJECT_MODIFIED, event); +}; + +/** + * Subscribe to STIX object modification events. + */ +exports.onObjectModified = function(handler) { + stixObjectEvents.on(eventConstants.STIX_OBJECT_MODIFIED, handler); +}; + +exports.emitter = stixObjectEvents; +``` + +### C.4: Integrate Event Emission into STIX Services + +This is the most invasive change. Each STIX service that creates/updates objects needs to emit events. + +**Example**: `app/services/techniques-service.js` + +```javascript +const stixObjectEvents = require('../lib/stix-object-events'); + +// In the create/update methods, after saving: +exports.create = async function(data, options) { + // ... existing creation logic ... + const savedObject = await techniquesRepository.save(technique); + + // Emit event for member sync + stixObjectEvents.emitObjectModified({ + objectRef: savedObject.stix.id, + newModified: new Date(savedObject.stix.modified), + modifiedBy: options.userAccountId, + eventType: 'created' + }); + + return savedObject; +}; + +exports.updateFull = async function(id, modified, data, options) { + // ... existing update logic ... + const savedObject = await techniquesRepository.save(technique); + + stixObjectEvents.emitObjectModified({ + objectRef: savedObject.stix.id, + newModified: new Date(savedObject.stix.modified), + oldModified: modified, + modifiedBy: options.userAccountId, + eventType: 'modified' + }); + + return savedObject; +}; +``` + +**Alternative**: Use Mongoose middleware (post-save hooks) on the base schema to emit events automatically. This is cleaner but requires schema-level changes. + +### C.5: Subscribe in Member Sync Service + +**File**: `app/services/release-tracks/member-sync-service.js` + +Add initialization function to subscribe to events: + +```javascript +const stixObjectEvents = require('../../lib/stix-object-events'); + +/** + * Initialize the member sync service. + * Subscribes to STIX object modification events. + * Should be called once during application startup. + */ +exports.initialize = function() { + stixObjectEvents.onObjectModified(async (event) => { + try { + await exports.handleObjectModified(event); + } catch (err) { + logger.error(`[member-sync] Error handling object modification: ${err.message}`, err); + } + }); + + logger.info('[member-sync] Member sync service initialized'); +}; +``` + +### C.6: Application Startup + +**File**: `app/index.js` or `app/server.js` (wherever initialization happens) + +```javascript +const memberSyncService = require('./services/release-tracks/member-sync-service'); + +// During application startup +memberSyncService.initialize(); +``` + +### C.7: Verification (Phase C) + +Integration tests: + +```javascript +// app/tests/integration/member-sync.spec.js + +describe('Member Sync Integration', () => { + it('should auto-enroll when object in members is modified', async () => { + // 1. Create a release track with track_latest config + // 2. Add an object to members (via bump/release) + // 3. Modify the object via the STIX endpoint + // 4. Verify new snapshot has object in candidates + }); + + it('should handle bulk import with multiple member objects', async () => { + // 1. Create release track with multiple members + // 2. Import bundle that updates several members + // 3. Verify all updated members appear in candidates + }); +}); +``` + +--- + +## Phase D: Testing & Verification + +### D.1: Unit Tests + +| File | Tests | +|------|-------| +| `app/tests/unit/services/release-tracks/member-sync-service.spec.js` | All strategy/behavior combinations | +| `app/tests/unit/lib/stix-object-events.spec.js` | Event emission and subscription | + +### D.2: Integration Tests + +| File | Tests | +|------|-------| +| `app/tests/integration/release-tracks/member-sync.spec.js` | End-to-end flows | +| `app/tests/api/release-tracks/config.spec.js` | API for updating member_sync config | + +### D.3: Manual Verification + +```bash +# 1. Create a release track +curl -X POST http://localhost:3000/api/release-tracks/new \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test Track","type":"standard"}' + +# 2. Add a technique as a candidate and release it +# (This requires the full workflow: add candidates, review, stage, bump) + +# 3. Verify config shows track_latest +curl http://localhost:3000/api/release-tracks//config + +# 4. Modify the technique +curl -X PUT http://localhost:3000/api/techniques/ \ + -H 'Content-Type: application/json' \ + -d '{"name":"Updated Technique", ...}' + +# 5. Check that the new version appears in candidates +curl http://localhost:3000/api/release-tracks//candidates + +# 6. Test ignore behavior: Update config to use 'ignore' +curl -X PUT http://localhost:3000/api/release-tracks//config \ + -H 'Content-Type: application/json' \ + -d '{"member_sync":{"supplant":{"behavior":"ignore"}}}' + +# 7. Modify technique again +# 8. Verify no new entry added (ignore in effect) +``` + +--- + +## Files Summary + +| File | Phase | Action | +|------|-------|--------| +| `app/models/release-tracks/release-track-snapshot-schema.js` | A | **Modify** (add member_sync to config) | +| `app/lib/release-tracks/release-track-schemas.js` | A | **Modify** (add Zod schemas) | +| `app/services/release-tracks/snapshot-service.js` | A | **Modify** (set defaults on create) | +| `app/services/release-tracks/member-sync-service.js` | B | **Create** | +| `app/services/release-tracks/release-tracks-service.js` | B | **Modify** (wire member sync export) | +| `app/lib/event-constants.js` | C | **Modify** (add STIX object events) | +| `app/lib/stix-object-events.js` | C | **Create** | +| `app/services/*.js` (STIX services) | C | **Modify** (emit events on create/update) | +| `app/index.js` or `app/server.js` | C | **Modify** (initialize member sync) | + +--- + +## Implementation Notes + +### Performance Considerations + +1. **Track Scanning**: The naive `findTracksWithObjectInMembers()` scans all tracks. For deployments with many tracks: + - Add a reverse index (deferred in design doc) + - Cache membership maps + - Use MongoDB aggregation + +2. **Event Processing**: STIX object modifications should not block on member sync completion. Consider: + - Asynchronous processing (fire-and-forget) + - Queue-based processing for high-volume scenarios + +3. **Snapshot Creation**: Each auto-enrollment creates a new snapshot. For bulk imports: + - Batch multiple enrollments into a single snapshot + - Process at end of import transaction + +### Edge Cases + +1. **Object in Multiple Tracks**: When an object is a member of multiple tracks, each track is processed independently according to its own config. + +2. **Virtual Tracks**: Member sync does not apply to virtual tracks (they pull from component track members at snapshot creation time). + +3. **Concurrent Modifications**: If the same object is modified multiple times in rapid succession, each modification triggers member sync. The snapshot system handles concurrency (each creates a new snapshot). + +4. **Circular Events**: Member sync creates new snapshots, but these should not trigger additional STIX object events (no risk of infinite loops since we're modifying release tracks, not STIX objects). + +--- + +## Dependencies on Other Phases + +| This Phase | Depends On | +|------------|------------| +| A (Schema) | None | +| B (Service) | A (Schema), Phase 1 (snapshot-service), Phase 3 (workflow-service for auto-promotion) | +| C (Events) | B (Service), existing STIX services | +| D (Testing) | A, B, C complete | + +**Note**: Phase B depends on `workflow-service.evaluateAutoPromotion()` from Phase 3 of the main implementation plan. If Phase 3 is not yet complete, the auto-promotion call in member-sync-service can be stubbed or the feature can be implemented without auto-promotion initially. diff --git a/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md b/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md new file mode 100644 index 00000000..2f42fefc --- /dev/null +++ b/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md @@ -0,0 +1,349 @@ +# Observations + +```httpie +echo '{ + "from": "work-in-progress", + "to": "awaiting-review", + "object_refs": [ + "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", + "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8" + ] +}' | \ + http POST http://localhost:3000/api/release-tracks/release-track--e4e3b098-5755-4525-814b-26fc16beab87/candidates/review \ + authorization:'Basic dGF4aWktc2VydmVyOnNlY3JldC1zcXVpcnJlbA==' \ + content-type:application/json \ + cookie:'{{AUTH_COOKIES}}' +``` + +The 'Bulk transition candidate workflow status' endpoint (and possibly others) will blindly create new release-track snapshots/documents even when no objects (members, staged, candidates) are modified. +We should change the behavior to only create a new document when something actually changes. +A new snapshot/document should NOT be created if no keys/properties are modified. Thus, no new snapshots should be identical. + +Additionally, when a new snapshot/document is created, a summary of the change should be captured in `version_history`. +The first element in the `version_history` list should explain what triggered the document to be created. +The purpose is to minimize guesswork in generating delta descriptions between snapshots. For instance, a developer may be trying to ascertain how a release track evolved over time; and without the proposed change summary, the developer would have to compute document diffs for each successive pair of snapshots that exists for a given release track. +A given snapshot's `version_history` should thus describe every operation, since the release track's inception, that has been made up to the given snapshot, constituting a causal events chain, or histogram, of how the snapshot came to be. + +--- + +**RESOLVED:** + +When I trigger the tag/bump operation for a snapshot which contains a conflict that blocks the release from occurring, the response body only shows the first error that triggered a conflict. Here is an example response body: +```json +{ + "track_id": "release-track--e4e3b098-5755-4525-814b-26fc16beab87", + "snapshot_modified": "2026-02-03T19:20:04.591Z", + "is_already_tagged": false, + "current_version": null, + "next_version_minor": "19.1", + "next_version_major": "20.0", + "staged_count": 3, + "members_count": 2, + "candidates_count": 0, + "conflict_error": "Conflict on attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61: abort policy prevents promotion" +} +``` +In the above response body, we see 1 object that prevents promotion: +``` +"conflict_error": "Conflict on attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61: abort policy prevents promotion" +``` +But there are potentially many conflicts! For example, consider the following snapshot (represented here as a Mongo Document): +``` +{ + "_id": { + "$oid": "69824a64ee981bdd17cefc7a" + }, + "id": "release-track--e4e3b098-5755-4525-814b-26fc16beab87", + "type": "standard", + "modified": { + "$date": "2026-02-03T19:20:04.591Z" + }, + "version": null, + "name": "Updated Track Name", + "description": "Updated description", + "created": { + "$date": "2026-02-03T16:05:44.429Z" + }, + "created_by_ref": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1", + "members": [ + { + "object_ref": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", + "object_modified": { + "$date": "2025-04-15T19:58:01.218Z" + } + }, + { + "object_ref": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", + "object_modified": { + "$date": "2025-04-15T19:58:03.170Z" + } + } + ], + "staged": [ + { + "object_ref": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", + "object_modified": { + "$date": "2025-04-15T19:58:01.218Z" + }, + "object_status": "work-in-progress", + "object_staged_at": { + "$date": "2026-02-03T19:20:04.591Z" + }, + "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" + }, + { + "object_ref": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", + "object_modified": { + "$date": "2025-04-15T19:58:03.170Z" + }, + "object_status": "work-in-progress", + "object_staged_at": { + "$date": "2026-02-03T19:20:04.591Z" + }, + "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" + }, + { + "object_ref": "attack-pattern--097924ce-a9a9-4039-8591-e0deedfb8722", + "object_modified": { + "$date": "2025-04-16T21:26:10.077Z" + }, + "object_status": "work-in-progress", + "object_staged_at": { + "$date": "2026-02-03T19:20:04.591Z" + }, + "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" + } + ], + "candidates": [], + "config": { + "candidacy_threshold": "reviewed", + "auto_promote": true, + "promotion_conflicts": { + "candidates_to_staged": "prefer_latest", + "staged_to_members": "abort" + } + }, + "version_history": [ + { + "version": "19.0", + "tagged_at": { + "$date": "2026-02-03T18:26:11.653Z" + }, + "tagged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1", + "snapshot_id": { + "$date": "2026-02-03T17:16:02.895Z" + }, + "summary": { + "members_count": 2, + "promoted_count": 2, + "staged_count": 2, + "candidate_count": 0 + } + } + ], + "__v": 0 +} +``` + +The response body correctly shows that STIX object "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61" is in conflict with the `config.promotion_conflicts.staged_to_members` policy. But it fails to show the sme for STIX object "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8". + +The response body should contain a comprehensive list of all conflicts that prevent promotion, not just the first one that the service detected. + +To resolve this: + +1. First read all of the concept/specification documentation for the "release track" feature in `docs/COLLECTIONS_V2/*.md`. Determine the optimal place to document this new behavior. Do we need to update any data structures in `06_ENTITIES.md`? Do we need to amend any intended conflict resolution behavior at `05_RELEASE_WORKFLOW.md#4-abort-taggingrelease-operations-only`? Importantly, before we modify the code, we must ensure that the concept documentation, which is the ultimate source of truth, is up-to-date. +2. Make the appropriate changes to the code base. For an overview of the release-track service architecture, see `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md`. + +--- + +**OBSERVATION**: + +There might appear to be a bug in the bulk status transition endpoint -- but it's not! + +```{{host}}/api/release-tracks/:id/candidates/review``` + +When you transition all candidates with object status `work-in-progress` to `reviewed` in a release track whose `config.candidacy_threshold` is `reviewed` and `config.auto_promote` is `true`, the operation will succeed and, unintuitively, trigger TWO document creations in the database. + +The first document that is created fulfills the endpoint operation. All target candidates have statuses changed from `work-in-progress` to `reviewed`. But importantly, they're still candidates! + +The second document that is created fulfills the auto-promotion configuration. Because the objects surpassed the candidacy threshold, the system will automatically promote them from `candidates` to `staged`. This coincides with yet another document creation. + +Importantly, the second document is the one that gets returned to the user in the response body. + +--- + +During development period (before STIX freeze): + +```json5 +{ + "candidacy_threshold": "awaiting-review", + "auto_promote": true, + + "promotion_conflicts": { + "candidates_to_staged": "prefer_latest", + "staged_to_members": "abort" + } +} +``` + +This will allow developers to continually push and update which objects are staged for the next release. Objects only get staged for the upcomign release if and when its marked as `reviewed`. + +Notably, the "release track owner" (concept not implemented yet) may make the `candidacy_threshold` more or less permissive. For example, it can be set to `awaiting-review` to allow objects that have not undergone a full review to still be queued up for the next release; or it can be set to `reviewed` to restrict the "staged" zone to *only* include objects that have beenf fully reviewed. + +Once the team is approximately 1 month out from the release, the colloquial "STIX freeze" period begins. During this period, the team should be more restrictive about which changes can be queued up for the imminent release. Thus, I recommend the following release track config: + +```json5 +{ + "candidacy_threshold": "reviewed", + "auto_promote": false, + + "promotion_conflicts": { + "candidates_to_staged": "abort", + "staged_to_members": "abort" + } +} +``` + +What changed: + +1. `candidacy_threshold` was changed from `awaiting-review` to `reviewed` +2. `auto_promote` was disabled +3. `promotion_conflicts.candidates_to_staged` was set to `abort` + +This change will block `candidates` from moving into `staged`. + +For last minute changes that need to get included in `staged`, users will need to set `promotion_conflicts.candidates_to_staged` to something other than `abort`. Additionally they should either: +(1) use the "Promote Candidates" endpoint (`{{host}}/api/release-tracks/:id/candidates/promote`) to manually promote candidates to staged... +-OR- +(2) ...enable `auto_promote`, BUT be cautious/avoid marking candidates as `reviewed` during the STIX freeze, lest they be included in the release. + + +--- + +**DOCUMENTED IN [08_MEMBER_SYNC_STRATEGIES.md](./08_MEMBER_SYNC_STRATEGIES.md)**: +(but not implemented yet) + +I want to make a change to how object candidacy is managed. At present, there are two "modes" for adding a candidate. Both are via the 'Add Candidates' endpoint (`POST {{host}}/api/release-tracks/:id/candidates`). + +You can either add specific object versions via object `id` and `modified` keys, or you can add the latest version of an object, irrespective of its `modified` key. A request body might resemble the following: +```json5 +{ + "object_refs": [ + // a static reference to a specific object revision + { + "id": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", + "modified": "$date" + }, + // a dynamic reference to the latest object revision + { + "id": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", + "modified": "latest" + }, + // a dynamic reference to the latest object revision + "attack-pattern--097924ce-a9a9-4039-8591-e0deedfb8722" + ] +} +``` +The latest revision of any given object can be dyanmically referenced by either passing it's `id` as a string literal element, or by setting `modified` to `latest`, e.g.,`{ id, modified: "latest" }`. + +The problem with this approach is that it fails to account for what happens after a release occurs: When a release occurs, all `staged` objects are merged into `members`, and `staged` is emptied. Users continue making revisions to `members`, but because `staged` has been emptied, there are no more dynamic references ensuring that future revisions get pulled in to the next release. Thus, users must remember to "queue up" their object revisions by hitting the 'Add Candidates' endpoint for each object they edit after the previous release. Intuitively, a user might expect that once an object is included in a release track's `members` list, all future revisions will automatically get queued up in `candidates`! Unfortunately, this is not the case. + +Thus, I would like to modify the system to support different "sync strategies" for object references in release tracks. Let's talk through various dimensions of the sync strategy, grounded with some mock/pseudo structures. + +Here is the initial state: + +```yaml +# We have one object with 2 revisions stored in the database: +objects: + - id: attack-pattern1 + modified: 2025-01-01 # We'll refer to this revision as v25 + - id: attack-pattern1 + modified: 2026-01-01 # We'll refer to this revision as v26 +# We have one release track: +release-track1: + # The release track has 1 snapshot: + snapshots: + # The following snapshot is a release snapshot. In it, attack-pattern1 version-2025 (hereinafter "v25" ) has been released + - type: release + candidates: [] + staged: [] + members: + - obj_ref: attack-pattern1 + obj_modified: 2025-01-01 +``` + +We need to first consider: what happens when v26 is created? + +A. Automatically add the v26 revision to `candidates` (resulting in a new draft snapshot) +B. Do nothing (i.e., expect the user to manually add v26 to `candidates`) + +Option A would yield a new draft snapshot like the following: + +```yaml +# We have one release track: +release-track1: + # The release track has 2 snapshots: + snapshots: + # The following snapshot is a release snapshot. In it, attack-pattern1 version-2025 (hereinafter "v25" ) has been released + - type: release + candidates: [] + staged: [] + members: + - obj_ref: attack-pattern1 + obj_modified: 2025-01-01 + - type: draft + candidates: + - obj_ref: attack-pattern1 + obj_modified: 2026-01-01 + staged: [] + members: + - obj_ref: attack-pattern1 + obj_modified: 2025-01-01 +``` + +We also need to consider the scenario where a revision already exists in `candidates` or `staged` at the time a new object revision is created: + +```yaml +# We have one object with 3 revisions stored in the database: +objects: + - id: attack-pattern1 + modified: 2025-01-01 # We'll refer to this revision as v25 + - id: attack-pattern1 + modified: 2026-01-01 # We'll refer to this revision as v26 + - id: attack-pattern1 + modified: 2027-01-01 # We'll refer to this revision as v27 +# We have one release track: +release-track1: + # The release track has 2 snapshots: + snapshots: + # The following snapshot is a release snapshot. In it, v25 has been released + - type: release + candidates: [] + staged: [] + members: + - obj_ref: attack-pattern1 + obj_modified: 2025-01-01 + # The following snapshot is a draft snapshot. In it, v26 has been reviewed and auto-promoted to `staged` + - type: draft + candidates: [] + staged: + - obj_ref: attack-pattern1 + obj_modified: 2026-01-01 + obj_status: 'reviewed' + members: + - obj_ref: attack-pattern1 + obj_modified: 2025-01-01 +``` + +We again need to consider what should happen when v27 is created? The dimensions of consideration are: + +1. Should v26 supplant v27 or not? +2. If v27 supplants v26, should it be added to `candidates` or `staged`? +3. If v27 supplants v26, should it be marked as `work-in-progress` (status reset) or `reviewed` (status preserved)? + +Tangential to these considerations is: + +1. How do we map the aforementioned dimensions to configuration options? +2. Where do we map such configuration options? If we store them on each object ref (in `candidates`, `staged`, and `members`), then we have maximum flexibility, but perhaps too much overhead. If we configure these options globally for the entire release track, we have minimal flexibility, but optimal predictability and ease of use. + +Please reason through this problem and propose a comprehensive solution. Don't worry about the code for now. The main priority is to settle on the desired functionality and document it in `docs/COLLECTIONS_V2/`. We'll deal with coding/implementation after we've fully planned the change. \ No newline at end of file diff --git a/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..f17416e6 --- /dev/null +++ b/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md @@ -0,0 +1,638 @@ +# Release Tracks Service Layer — Comprehensive Implementation Plan + +> **Purpose**: Cross-session handoff document. Each phase is self-contained with enough +> context for a new Claude session to pick up implementation without re-reading the full +> codebase. Update the **Status** markers as phases are completed. + +--- + +## Status Tracker + +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Snapshot Service + Track CRUD + Facade wiring | **DONE** | +| 2 | Standard Track Service (Candidates + Staged) | TODO | +| 3 | Workflow Service (Auto-promotion) | TODO | +| 4 | Versioning Service (Bump/Tag/Preview) | TODO | +| 5 | Virtual Track Service (Composition/Dedup) | TODO | +| 6 | Export Service + Ephemeral Service | TODO (deferred) | + +--- + +## Architecture + +``` +Controller (Zod validation, app/controllers/release-tracks-controller.js) + └─▶ release-tracks-service.js (Facade — delegates only, zero business logic) + ā”œā”€ā–¶ snapshot-service.js Phase 1 Snapshot CRUD, clone, metadata/contents + ā”œā”€ā–¶ standard-track-service.js Phase 2 Candidate/staged/member tier management + ā”œā”€ā–¶ workflow-service.js Phase 3 Auto-promotion, candidacy threshold + ā”œā”€ā–¶ versioning-service.js Phase 4 Bump/tag, version history, preview + ā”œā”€ā–¶ virtual-track-service.js Phase 5 Composition resolution, deduplication + ā”œā”€ā–¶ export-service.js Phase 6 bundle/workbench/filesystemstore (deferred) + └─▶ ephemeral-service.js Phase 6 Stateless domain bundles (deferred) + +All sub-services import these singletons: + registryRepo ← app/repository/release-tracks/release-track-registry.repository.js + dynamicRepo ← app/repository/release-tracks/release-track-dynamic.repository.js + modelFactory ← app/models/release-tracks/model-factory.js +``` + +### Sub-service dependency graph + +``` +snapshot-service (no sub-service deps) +standard-track-service (imports snapshot-service for cloneSnapshot) +workflow-service (imports standard-track-service for tier mutations) +versioning-service (imports snapshot-service, workflow-service) +virtual-track-service (imports snapshot-service for component reads) +export-service (no sub-service deps; cross-reads STIX repos) [deferred] +ephemeral-service (no sub-service deps; cross-reads STIX repos) [deferred] +``` + +--- + +## Design Decisions (settled) + +| Decision | Choice | Rationale | +|---|---|---| +| Module style | Functional `exports.method` (not class) | Matches existing facade + collection-bundles-service pattern | +| `modified: "latest"` resolution in addCandidates | Implement now | Build lightweight object-resolver that queries STIX repos via existing service-map (like import-bundle.js) | +| Bump/tag compound update | Extend `tagSnapshotInPlace` in dynamic repo | Add optional `additionalUpdateOps` param so staged→members promotion is atomic with version set | +| Config get/update | Inline in snapshot-service | Too simple for a separate file | +| Snapshot immutability | All mutations clone → new snapshot (new `modified`) | Exception: tagging is the ONLY in-place update | + +--- + +## Key Reference Files + +| File | Role | +|---|---| +| `app/services/release-tracks/release-tracks-service.js` | Facade (31 stub methods to wire) | +| `app/controllers/release-tracks-controller.js` | Defines exact call signatures the facade must satisfy | +| `app/repository/release-tracks/release-track-registry.repository.js` | Registry CRUD: `create`, `findByTrackId`, `findAll`, `updateByTrackId`, `deleteByTrackId` | +| `app/repository/release-tracks/release-track-dynamic.repository.js` | Snapshot CRUD: `getLatestSnapshot`, `getSnapshotByModified`, `getLatestTaggedSnapshot`, `getSnapshotByVersion`, `getAllSnapshots`, `saveSnapshot`, `tagSnapshotInPlace`, `updateSnapshot`, `deleteSnapshot`, `deleteAllSnapshots`, `dropCollection` | +| `app/models/release-tracks/model-factory.js` | `getModel`, `removeModel`, `ensureIndexes` | +| `app/models/release-tracks/release-track-snapshot-schema.js` | Mongoose schema: members, staged, candidates, quarantine, composition, config, version_history | +| `app/models/release-tracks/release-track-registry-model.js` | Registry schema: track_id, type, name, description, counters, schedule | +| `app/lib/release-tracks/release-track-schemas.js` | All Zod schemas (controller validation + domain types) | +| `app/lib/release-tracks/release-track-validators.js` | Mongoose custom validators wrapping Zod | +| `app/exceptions/index.js` | All exception classes (AlreadyReleasedError, InvalidVersionError, ReleaseConflictError, etc.) | +| `app/services/stix/collection-bundles-service/import-bundle.js` | Reference for cross-service STIX object resolution (serviceMap pattern) | + +--- + +## Shared Lib Utilities + +### `app/lib/release-tracks/version-utils.js` (Phase 4) + +```js +exports.parseVersion = (str) => { major, minor } +exports.compareVersions = (a, b) => -1 | 0 | 1 +exports.calculateNextVersion = (versionHistory, bumpType, explicitVersion) => string +exports.validateVersionProgression = (newVersion, versionHistory) => void | throw InvalidVersionError +``` + +### `app/lib/release-tracks/conflict-resolution.js` (Phase 2) + +```js +exports.applyConflictPolicy = (existingTier, incomingEntries, policy) => { merged, rejected } +// policy: 'always_overwrite' | 'always_reject' | 'prefer_latest' | 'abort' +// 'abort' throws ReleaseConflictError if any conflict exists +// Conflict = same object_ref but different object_modified in target tier +``` + +### `app/lib/release-tracks/deduplication-strategies.js` (Phase 5) + +```js +exports.deduplicate = (allMembers, strategy, componentMeta) => { members, quarantined, report } +// strategy: 'prioritize_latest_object' | 'prioritize_latest_snapshot' | 'prioritize_higher_priority' | 'quarantine' +``` + +### `app/lib/release-tracks/object-resolver.js` (Phase 2) + +```js +exports.resolveLatestModified = async (objectRef) => Date +// Uses STIX service-map to find the latest version of an object by its STIX ID. +// Similar pattern to import-bundle.js serviceMap. +``` + +--- + +## Phase 1: Snapshot Service + Track CRUD + +### Scope + +Implement `snapshot-service.js` and wire the facade for: track listing, creation, retrieval, +metadata/contents updates, cloning, and deletion. + +### New file: `app/services/release-tracks/snapshot-service.js` + +**Dependencies**: `registryRepo`, `dynamicRepo`, `modelFactory`, `uuid` (npm), `logger`, exceptions + +#### Method specifications + +##### `createTrack(data)` → snapshot +- Generate `trackId = 'release-track--' + uuid.v4()` +- `now = new Date()` +- Build initial snapshot document: + ``` + { id: trackId, type: data.type, modified: now, version: null, + name: data.name, description: data.description || '', + created: now, created_by_ref: data.userAccountId, + object_marking_refs: data.object_marking_refs, + members: [], + staged: data.type === 'standard' ? [] : undefined, + candidates: data.type === 'standard' ? [] : undefined, + quarantine: data.type === 'virtual' ? [] : undefined, + composition: data.type === 'virtual' ? data.composition : undefined, + config: {}, version_history: [] } + ``` +- `await modelFactory.ensureIndexes(trackId)` +- `snapshot = await dynamicRepo.saveSnapshot(trackId, initialSnapshot)` +- Create registry entry: + ``` + await registryRepo.create({ + track_id: trackId, type: data.type, name: data.name, + description: data.description, + latest_snapshot_modified: now, snapshot_count: 1, + tagged_release_count: 0, created_at: now, updated_at: now, + snapshot_schedule: data.type === 'virtual' ? data.snapshot_schedule : undefined }) + ``` +- Return snapshot + +##### `listTracks(options)` → `{ data, pagination }` +- Delegate to `registryRepo.findAll(options)` +- `options`: `{ type?, search?, limit?, offset? }` + +##### `getLatestSnapshot(trackId, _options)` → snapshot +- `snapshot = await dynamicRepo.getLatestSnapshot(trackId)` +- If null, throw `TrackNotFoundError(trackId)` +- (Phase 6 will use `_options.format` for export formatting; for now, return raw snapshot) +- Return snapshot + +##### `getSnapshotByModified(trackId, modified, _options)` → snapshot +- `snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified)` +- If null, throw `NotFoundError({ details: '...' })` +- Return snapshot + +##### `cloneSnapshot(trackId, sourceSnapshot, overrides)` → snapshot (internal helper) +- Deep-clone: `const clone = JSON.parse(JSON.stringify(sourceSnapshot))` +- Remove Mongoose metadata: `delete clone._id; delete clone.__v` +- Set `clone.modified = new Date()` +- Set `clone.version = null` (clones are always drafts) +- Apply overrides: merge `overrides` into clone (name, description, etc.) +- `saved = await dynamicRepo.saveSnapshot(trackId, clone)` +- Update registry: `await registryRepo.updateByTrackId(trackId, { latest_snapshot_modified: clone.modified, updated_at: new Date(), $inc-equivalent... })` + - Note: registryRepo.updateByTrackId uses `$set` not `$inc`. Compute new count: call `syncRegistryCounters(trackId)` or pass computed values. +- Return saved + +##### `cloneTrack(trackId, options)` → snapshot +- Load source: `source = await getLatestSnapshot(trackId)` +- Generate new track ID: `newTrackId = 'release-track--' + uuid.v4()` +- `now = new Date()` +- Clone snapshot for new track: + ``` + const clone = JSON.parse(JSON.stringify(source)) + delete clone._id; delete clone.__v + clone.id = newTrackId + clone.modified = now + clone.version = null + clone.name = options.name || source.name + ' (copy)' + clone.created = now + clone.created_by_ref = options.userAccountId + clone.version_history = [] + ``` +- `await modelFactory.ensureIndexes(newTrackId)` +- `saved = await dynamicRepo.saveSnapshot(newTrackId, clone)` +- Create registry entry for new track +- Return saved + +##### `cloneFromSnapshot(trackId, modified, options)` → snapshot +- Same as `cloneTrack` but source = `getSnapshotByModified(trackId, modified)` + +##### `updateMetadata(trackId, updates, _userId)` → snapshot +- `source = await getLatestSnapshot(trackId)` +- Build overrides from `updates`: only `{ name?, description?, object_marking_refs? }` +- Return `cloneSnapshot(trackId, source, overrides)` + +##### `updateMetadataByModified(trackId, modified, updates, _userId)` → snapshot +- `source = await getSnapshotByModified(trackId, modified)` +- Same as above + +##### `updateContents(trackId, contents, _userId)` → snapshot +- `source = await getLatestSnapshot(trackId)` +- Map `contents.x_mitre_contents` to member entries: + ``` + const members = contents.x_mitre_contents.map(c => ({ + object_ref: c.obj_ref, + object_modified: new Date(c.obj_modified) + })) + ``` +- Return `cloneSnapshot(trackId, source, { members })` + +##### `updateContentsByModified(trackId, modified, contents, _userId)` → snapshot +- Same but from specific snapshot + +##### `getConfig(trackId)` → config +- `snapshot = await getLatestSnapshot(trackId)` +- Return `snapshot.config` + +##### `updateConfig(trackId, config, _userId)` → snapshot +- `source = await getLatestSnapshot(trackId)` +- Merge config: `const mergedConfig = { ...source.config, ...config }` +- Handle nested `promotion_conflicts`: `mergedConfig.promotion_conflicts = { ...source.config?.promotion_conflicts, ...config.promotion_conflicts }` +- Return `cloneSnapshot(trackId, source, { config: mergedConfig })` + +##### `deleteTrack(trackId)` → void +- Verify exists: `registry = await registryRepo.findByTrackId(trackId)` +- If null, throw `TrackNotFoundError(trackId)` +- `await dynamicRepo.dropCollection(trackId)` (also cleans up model cache) +- `await registryRepo.deleteByTrackId(trackId)` + +##### `deleteSnapshot(trackId, modified)` → void +- Verify snapshot exists: `snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified)` +- If null, throw `NotFoundError` +- `await dynamicRepo.deleteSnapshot(trackId, modified)` +- `await syncRegistryCounters(trackId)` + +##### `syncRegistryCounters(trackId)` → void (internal) +- `all = await dynamicRepo.getAllSnapshots(trackId, { projection: 'modified version' })` +- Compute: `snapshot_count`, `tagged_release_count`, `latest_snapshot_modified`, `latest_tagged_version` +- `await registryRepo.updateByTrackId(trackId, { ...computed, updated_at: new Date() })` + +### Facade wiring (Phase 1) + +Replace these stubs in `release-tracks-service.js`: + +```js +const snapshotService = require('./snapshot-service'); + +exports.listTracks = (options) => snapshotService.listTracks(options); +exports.createTrack = (data) => snapshotService.createTrack(data); +exports.getLatestSnapshot = (trackId, options) => snapshotService.getLatestSnapshot(trackId, options); +exports.getSnapshotByModified = (trackId, modified, options) => snapshotService.getSnapshotByModified(trackId, modified, options); +exports.updateMetadata = (trackId, updates, userId) => snapshotService.updateMetadata(trackId, updates, userId); +exports.updateMetadataByModified = (trackId, modified, updates, userId) => snapshotService.updateMetadataByModified(trackId, modified, updates, userId); +exports.updateContents = (trackId, contents, userId) => snapshotService.updateContents(trackId, contents, userId); +exports.updateContentsByModified = (trackId, modified, contents, userId) => snapshotService.updateContentsByModified(trackId, modified, contents, userId); +exports.cloneTrack = (trackId, options) => snapshotService.cloneTrack(trackId, options); +exports.cloneFromSnapshot = (trackId, modified, options) => snapshotService.cloneFromSnapshot(trackId, modified, options); +exports.deleteTrack = (trackId) => snapshotService.deleteTrack(trackId); +exports.deleteSnapshot = (trackId, modified) => snapshotService.deleteSnapshot(trackId, modified); +exports.getConfig = (trackId) => snapshotService.getConfig(trackId); +exports.updateConfig = (trackId, config, userId) => snapshotService.updateConfig(trackId, config, userId); +``` + +Methods that remain as `NotImplementedError` after Phase 1: +- `createTrackFromBundle`, `importTrack`, `getEphemeralBundle` +- All candidate/staged/versioning/virtual methods (Phases 2-5) + +### Verification (Phase 1) + +```bash +# Run existing tests (should not break) +npm test + +# Manual verification via curl (requires running server): +# 1. Create standard track +curl -X POST http://localhost:3000/api/release-tracks/new \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test Track","type":"standard"}' + +# 2. List tracks +curl http://localhost:3000/api/release-tracks + +# 3. Get latest snapshot (use track ID from step 1) +curl http://localhost:3000/api/release-tracks/ + +# 4. Update metadata +curl -X POST http://localhost:3000/api/release-tracks//meta \ + -d '{"name":"Renamed Track"}' + +# 5. Clone track +curl -X POST http://localhost:3000/api/release-tracks//clone + +# 6. Delete track +curl -X DELETE http://localhost:3000/api/release-tracks/ +``` + +--- + +## Phase 2: Standard Track Service (Candidates + Staged) + +### Prerequisites +- Phase 1 complete +- Create `app/lib/release-tracks/conflict-resolution.js` +- Create `app/lib/release-tracks/object-resolver.js` + +### New file: `app/services/release-tracks/standard-track-service.js` + +**Dependencies**: `dynamicRepo`, `snapshotService` (for `cloneSnapshot`, `getLatestSnapshot`), `objectResolver`, `conflictResolution`, `logger`, exceptions + +#### Method specifications + +##### `addCandidates(trackId, objectRefs, userId)` → snapshot +- `source = await snapshotService.getLatestSnapshot(trackId)` +- Validate track type is 'standard' +- `now = new Date()` +- For each ref in `objectRefs`: + - Normalize: if string, convert to `{ id: ref, modified: undefined }` + - If `modified` is `'latest'` or undefined: `modified = await objectResolver.resolveLatestModified(entry.id)` + - Else: parse as Date + - Validate no duplicate in `source.candidates` (same `object_ref` + `object_modified`) + - Build: `{ object_ref: entry.id, object_modified: modified, object_status: 'work-in-progress', object_added_at: now, object_added_by: userId }` +- Clone snapshot with `candidates = [...source.candidates, ...newEntries]` +- Return via `snapshotService.cloneSnapshot(trackId, source, { candidates: merged })` + +##### `listCandidates(trackId, options)` → `{ candidates }` +- Load latest snapshot +- Filter `snapshot.candidates` by `options.status` if provided +- Return filtered candidates + +##### `removeCandidate(trackId, objectRef)` → void +- Load latest snapshot +- Find candidate(s) where `object_ref === objectRef` +- If none found, throw `NotFoundError` +- Clone snapshot with those candidates removed + +##### `reviewCandidates(trackId, reviewData, userId)` → snapshot +- `reviewData = { from, to, object_refs? }` +- Validate `from` < `to` (forward-only: wip→awaiting→reviewed) +- Load latest snapshot +- Find matching candidates: status === `from`, optionally filtered to `object_refs` +- Update their `object_status` to `to` +- Clone snapshot with updated candidates +- If `source.config.auto_promote === true`, call `workflowService.evaluateAutoPromotion(trackId, newSnapshot)` (Phase 3; for now, skip auto-promotion) +- Return snapshot + +##### `promoteCandidates(trackId, objectRefs, userId)` → snapshot +- Load latest snapshot +- Find candidates matching `objectRefs` +- Build staged entries: `{ object_ref, object_modified, object_status, object_staged_at: now, object_staged_by: userId }` +- Apply `config.promotion_conflicts.candidates_to_staged` policy via `conflictResolution.applyConflictPolicy(source.staged, newStagedEntries, policy)` +- Clone snapshot: remove promoted from candidates, add to staged +- Return snapshot + +##### `updateCandidateVersion(trackId, objectRef, data)` → snapshot +- `data = { old_modified, new_modified }` +- Load latest snapshot +- Find candidate where `object_ref === objectRef` and `object_modified === data.old_modified` +- If not found, throw `NotFoundError` +- Update `object_modified` to `data.new_modified` +- Clone snapshot + +##### `listStaged(trackId)` → `{ staged }` +- Load latest snapshot, return `snapshot.staged` + +##### `demoteStaged(trackId, objectRefs, userId)` → snapshot +- `objectRefs = [{ id, modified }]` +- Load latest snapshot +- For each ref: find in staged tier, remove, create candidate entry preserving `object_status` +- Clone snapshot with updated staged/candidates + +##### `listObjectVersions(trackId, objectRef)` → versions +- Load latest snapshot +- Search all tiers (members, staged, candidates) for `object_ref === objectRef` +- Return `[{ tier: 'members'|'staged'|'candidates', object_modified, object_status? }]` + +### `app/lib/release-tracks/object-resolver.js` + +```js +// Pattern from import-bundle.js serviceMap +const types = require('../../lib/types'); +const serviceMap = { /* same mapping as import-bundle.js */ }; + +exports.resolveLatestModified = async function(objectRef) { + const type = objectRef.split('--')[0]; + const service = serviceMap[type]; + if (!service) throw new BadRequestError({ message: `Unknown object type: ${type}` }); + // Use service.retrieveById or equivalent to get all versions, pick latest modified + const result = await service.retrieveById(objectRef, { versions: 'all' }); + if (!result || result.length === 0) throw new NotFoundError({ details: `Object ${objectRef} not found` }); + // Return the most recent modified timestamp + return new Date(result[0].stix.modified); // assuming sorted desc +}; +``` + +### `app/lib/release-tracks/conflict-resolution.js` + +```js +const { ReleaseConflictError } = require('../../exceptions'); + +exports.applyConflictPolicy = function(existingTier, incomingEntries, policy) { + const merged = [...existingTier]; + const rejected = []; + + for (const incoming of incomingEntries) { + const conflictIdx = merged.findIndex(e => e.object_ref === incoming.object_ref); + if (conflictIdx === -1) { + merged.push(incoming); // No conflict + continue; + } + + const incumbent = merged[conflictIdx]; + switch (policy) { + case 'always_overwrite': merged[conflictIdx] = incoming; break; + case 'always_reject': rejected.push(incoming); break; + case 'prefer_latest': + if (new Date(incoming.object_modified) > new Date(incumbent.object_modified)) { + merged[conflictIdx] = incoming; + } else { + rejected.push(incoming); + } + break; + case 'abort': + throw new ReleaseConflictError(`Conflict on ${incoming.object_ref}: abort policy`); + } + } + return { merged, rejected }; +}; +``` + +### Facade wiring (Phase 2) + +```js +const standardTrackService = require('./standard-track-service'); + +exports.addCandidates = (trackId, objectRefs, userId) => standardTrackService.addCandidates(trackId, objectRefs, userId); +exports.listCandidates = (trackId, options) => standardTrackService.listCandidates(trackId, options); +exports.removeCandidate = (trackId, objectRef) => standardTrackService.removeCandidate(trackId, objectRef); +exports.reviewCandidates = (trackId, reviewData, userId) => standardTrackService.reviewCandidates(trackId, reviewData, userId); +exports.promoteCandidates = (trackId, objectRefs, userId) => standardTrackService.promoteCandidates(trackId, objectRefs, userId); +exports.updateCandidateVersion = (trackId, objectRef, data) => standardTrackService.updateCandidateVersion(trackId, objectRef, data); +exports.listStaged = (trackId) => standardTrackService.listStaged(trackId); +exports.demoteStaged = (trackId, objectRefs, userId) => standardTrackService.demoteStaged(trackId, objectRefs, userId); +exports.listObjectVersions = (trackId, objectRef) => standardTrackService.listObjectVersions(trackId, objectRef); +``` + +--- + +## Phase 3: Workflow Service + +### New file: `app/services/release-tracks/workflow-service.js` + +```js +const STATUS_RANK = { 'work-in-progress': 0, 'awaiting-review': 1, 'reviewed': 2 }; + +exports.meetsThreshold = (status, threshold) => STATUS_RANK[status] >= STATUS_RANK[threshold]; + +exports.evaluateAutoPromotion = async function(trackId, snapshot) { + if (!snapshot.config.auto_promote) return null; + const threshold = snapshot.config.candidacy_threshold || 'reviewed'; + const qualifying = snapshot.candidates.filter(c => exports.meetsThreshold(c.object_status, threshold)); + if (qualifying.length === 0) return null; + + // Promote qualifying candidates to staged + // Uses standardTrackService internally or does inline tier mutation + clone + // ... (detailed implementation in Phase 3) +}; +``` + +### Integration point + +`standard-track-service.reviewCandidates` calls `workflowService.evaluateAutoPromotion` after status update. + +--- + +## Phase 4: Versioning Service + +### Prerequisites +- Create `app/lib/release-tracks/version-utils.js` +- Extend `dynamicRepo.tagSnapshotInPlace` to accept `additionalUpdateOps` + +### Repository change: `release-track-dynamic.repository.js` + +Extend `tagSnapshotInPlace(trackId, modified, versionData)`: +```js +// Current: only $set version + $push version_history +// Extended: accept versionData.additionalOps = { members: [...], staged: [], ... } +// Merge into the atomic findOneAndUpdate call +``` + +### New file: `app/lib/release-tracks/version-utils.js` + +```js +exports.parseVersion = (str) => { + const [major, minor] = str.split('.').map(Number); + return { major, minor }; +}; + +exports.calculateNextVersion = (versionHistory, bumpType, explicitVersion) => { + if (explicitVersion) { + // Validate > all existing versions + return explicitVersion; + } + if (versionHistory.length === 0) return '1.0'; + const latest = versionHistory[versionHistory.length - 1].version; + const { major, minor } = exports.parseVersion(latest); + return bumpType === 'major' ? `${major + 1}.0` : `${major}.${minor + 1}`; +}; +``` + +### New file: `app/services/release-tracks/versioning-service.js` + +##### `bumpLatest(trackId, options)` → snapshot +- Load latest snapshot +- Validate `snapshot.version === null` → else throw `AlreadyReleasedError(snapshot.version)` +- Calculate version: `versionUtils.calculateNextVersion(snapshot.version_history, options.type, options.version)` +- Validate monotonic: `versionUtils.validateVersionProgression(version, snapshot.version_history)` +- Promote staged → members: `conflictResolution.applyConflictPolicy(snapshot.members, stagedAsMembers, config.promotion_conflicts.staged_to_members)` +- Build `versionHistoryEntry = { version, tagged_at: now, tagged_by: options.userAccountId, snapshot_id: snapshot.modified, summary: { members_count, promoted_count, staged_count, candidate_count } }` +- If `options.dry_run`, return preview object without persisting +- Call extended `dynamicRepo.tagSnapshotInPlace(trackId, snapshot.modified, { version, versionHistoryEntry, additionalOps: { members: merged, staged: [] } })` +- Update registry: `latest_tagged_version`, increment `tagged_release_count` +- Return updated snapshot + +##### `bumpByModified(trackId, modified, options)` → snapshot +- Same as above but loads specific snapshot + +##### `previewBump(trackId, _format)` → preview +- Load latest snapshot, compute version + staged→members diff, return structured preview + +### Facade wiring (Phase 4) + +```js +const versioningService = require('./versioning-service'); +exports.bumpLatest = (trackId, options) => versioningService.bumpLatest(trackId, options); +exports.bumpByModified = (trackId, modified, options) => versioningService.bumpByModified(trackId, modified, options); +exports.previewBump = (trackId, format) => versioningService.previewBump(trackId, format); +``` + +--- + +## Phase 5: Virtual Track Service + +### Prerequisites +- Create `app/lib/release-tracks/deduplication-strategies.js` + +### New file: `app/lib/release-tracks/deduplication-strategies.js` + +```js +exports.deduplicate = function(allMembers, strategy, componentMeta) { + // allMembers = [{ object_ref, object_modified, _source_track_id, _source_priority }] + // Group by object_ref, resolve duplicates per strategy + // Returns { members: [], quarantined: [], report: { before, after, duplicates, conflicts } } +}; +``` + +### New file: `app/services/release-tracks/virtual-track-service.js` + +##### `updateComposition(trackId, composition, userId)` → snapshot +- Validate track is virtual type +- Validate all component tracks exist and are standard (not virtual → throw `InvalidComponentTypeError`) +- Validate no duplicate track_ids in component list +- Load latest snapshot, clone with updated `composition` + +##### `createVirtualSnapshot(trackId, options)` → snapshot +- Load latest snapshot, read `composition` +- For each component: resolve snapshot via strategy (latest_tagged / specific_version / specific_snapshot) +- Validate resolved snapshot is tagged (throw `NoTaggedSnapshotsError` if not) +- Extract members, apply filters (object_types, domains) +- Deduplicate across all components +- Build `composition_resolution` metadata +- Save new snapshot with resolved members + quarantine + resolution metadata + +##### `previewVirtualSnapshot(trackId)` → preview +- Same as above but return preview without saving + +### Facade wiring (Phase 5) + +```js +const virtualTrackService = require('./virtual-track-service'); +exports.updateComposition = (...) => virtualTrackService.updateComposition(...); +exports.createVirtualSnapshot = (...) => virtualTrackService.createVirtualSnapshot(...); +exports.previewVirtualSnapshot = (...) => virtualTrackService.previewVirtualSnapshot(...); +``` + +--- + +## Phase 6: Export + Ephemeral (Deferred) + +| Item | Reason for deferral | +|---|---| +| `export-service.js` | Requires cross-service reads to hydrate STIX object refs into full objects | +| `ephemeral-service.js` | Requires querying all STIX repos by domain; orthogonal to core | +| `createTrackFromBundle` | Requires STIX bundle parsing via existing collection-bundles infra | +| `importTrack` | Already returns NotImplementedError in controller | +| Format-aware snapshot retrieval | `queryOptions.format` param ignored until export-service exists | + +--- + +## All Files Created/Modified (Complete) + +| File | Phase | Action | +|---|---|---| +| `app/services/release-tracks/snapshot-service.js` | 1 | **Create** | +| `app/services/release-tracks/release-tracks-service.js` | 1+ | **Modify** (progressive stub replacement) | +| `app/lib/release-tracks/conflict-resolution.js` | 2 | **Create** | +| `app/lib/release-tracks/object-resolver.js` | 2 | **Create** | +| `app/services/release-tracks/standard-track-service.js` | 2 | **Create** | +| `app/services/release-tracks/workflow-service.js` | 3 | **Create** | +| `app/lib/release-tracks/version-utils.js` | 4 | **Create** | +| `app/repository/release-tracks/release-track-dynamic.repository.js` | 4 | **Modify** (extend tagSnapshotInPlace) | +| `app/services/release-tracks/versioning-service.js` | 4 | **Create** | +| `app/lib/release-tracks/deduplication-strategies.js` | 5 | **Create** | +| `app/services/release-tracks/virtual-track-service.js` | 5 | **Create** | +| `app/services/release-tracks/export-service.js` | 6 | **Create** (deferred) | +| `app/services/release-tracks/ephemeral-service.js` | 6 | **Create** (deferred) | diff --git a/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md b/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md new file mode 100644 index 00000000..ffd7396b --- /dev/null +++ b/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md @@ -0,0 +1,50 @@ +Service Layer Phased Impl Prompts + +The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. + +I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. + +I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. + +Phase 1 through 3 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): + +- Phase 4: versioning-service.js + version-utils.js + extend tagSnapshotInPlace +- Phase 5: virtual-track-service.js + deduplication-strategies.js +- Phase 6: export-service.js + ephemeral-service.js + +Please commence phase 4. + + +--- + +The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. + +I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. + +I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. + +Phase 1 through 4 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): + +- Phase 5: virtual-track-service.js + deduplication-strategies.js +- Phase 6: export-service.js + ephemeral-service.js + +Please commence phase 5. + + +--- + +The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. + +I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. + +I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. + +Phase 1 through 5 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): + +- Phase 6: Export + Ephemeral + - `export-service.js`: Requires cross-service reads to hydrate STIX object refs into full objects. For cross-service operations, please use the existing event-bus architecture (see `docs/EVENT_BUS_ARCHITECTURE.md` and `docs/CROSS_SERVICE_READS_PATTERN.md` for details) + - `ephemeral-service.js`: Requires querying all STIX repos by domain; orthogonal to core + - `createTrackFromBundle`: Requires STIX bundle parsing. However, I don't want to rely on the existing collection-bundles infrastructure. Instead, I want to to implement from scratch. The new infrastructure will supplant the existing collection-bundles infrastructure once its been tested, validated, and shipped. + - Format-aware snapshot retrieval: `queryOptions.format` param ignored until export-service exists + +Please commence phase 6. \ No newline at end of file diff --git a/.nocommit/field-requirements-probe.js b/.nocommit/field-requirements-probe.js new file mode 100644 index 00000000..7ada1514 --- /dev/null +++ b/.nocommit/field-requirements-probe.js @@ -0,0 +1,457 @@ +#!/usr/bin/env node + +'use strict'; + +/** + * Field Requirements Probe for ATT&CK Workbench API + * + * This script systematically tests which fields are required when posting + * "awaiting-review" objects to identify which fields the frontend needs to include. + * + * It tests each STIX object type by: + * 1. Creating a base valid object with "awaiting-review" status + * 2. Removing one field at a time to see which ones cause validation failures + * 3. Generating a report of required vs optional fields + */ + +const request = require('supertest'); +const database = require('./app/lib/database-in-memory'); +const databaseConfiguration = require('./app/lib/database-configuration'); +const login = require('./app/tests/shared/login'); + +const logger = require('./app/lib/logger'); +logger.level = 'debug'; +const { + techniqueSchema, + tacticSchema, + campaignSchema, + groupSchema, + mitigationSchema, + relationshipSchema, + identitySchema, + collectionSchema, + matrixSchema, + dataSourceSchema, + dataComponentSchema, + detectionStrategySchema, + assetSchema, + analyticSchema, +} = require('@mitre-attack/attack-data-model'); + +// Schema to route mapping +const SCHEMA_TO_ENDPOINT = { + techniqueSchema: '/api/techniques', + tacticSchema: '/api/tactics', + campaignSchema: '/api/campaigns', + groupSchema: '/api/groups', + mitigationSchema: '/api/mitigations', + relationshipSchema: '/api/relationships', + identitySchema: '/api/identities', + collectionSchema: '/api/collections', + matrixSchema: '/api/matrices', + dataSourceSchema: '/api/data-sources', + dataComponentSchema: '/api/data-components', + detectionStrategySchema: '/api/detection-strategies', + assetSchema: '/api/assets', + analyticSchema: '/api/analytics', +}; + +const SCHEMAS = { + techniqueSchema, + tacticSchema, + campaignSchema, + groupSchema, + mitigationSchema, + relationshipSchema, + identitySchema, + collectionSchema, + matrixSchema, + dataSourceSchema, + dataComponentSchema, + detectionStrategySchema, + assetSchema, + analyticSchema, +}; + +/** + * Generate base template objects for each STIX type + */ +function generateBaseTemplates() { + const now = new Date().toISOString(); + + return { + techniqueSchema: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'T9999', + collections: [], + }, + stix: { + type: 'attack-pattern', + spec_version: '2.1', + id: 'attack-pattern--' + require('uuid').v4(), + created: now, + modified: now, + name: 'Test Technique', + description: 'Test technique description', + x_mitre_is_subtechnique: false, + x_mitre_domains: ['enterprise-attack'], + x_mitre_attack_spec_version: '3.3.0', + x_mitre_version: '1.0', + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'T9999', + url: 'https://attack.mitre.org/techniques/T9999', + }, + ], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + }, + }, + + tacticSchema: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'TA9999', + collections: [], + }, + stix: { + type: 'x-mitre-tactic', + spec_version: '2.1', + id: 'x-mitre-tactic--' + require('uuid').v4(), + created: now, + modified: now, + name: 'Test Tactic', + description: 'Test tactic description', + x_mitre_shortname: 'test-tactic', + x_mitre_domains: ['enterprise-attack'], + x_mitre_attack_spec_version: '3.3.0', + x_mitre_version: '1.0', + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'TA9999', + url: 'https://attack.mitre.org/tactics/TA9999', + }, + ], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + }, + }, + + campaignSchema: { + workspace: { + workflow: { state: 'awaiting-review' }, + collections: [], + }, + stix: { + type: 'campaign', + spec_version: '2.1', + id: 'campaign--' + require('uuid').v4(), + created: now, + modified: now, + name: 'Test Campaign', + description: 'Test campaign description', + aliases: ['Test Campaign'], + first_seen: now, + last_seen: now, + x_mitre_first_seen_citation: '(Citation: Test)', + x_mitre_last_seen_citation: '(Citation: Test)', + x_mitre_domains: ['enterprise-attack'], + x_mitre_attack_spec_version: '3.3.0', + x_mitre_version: '1.0', + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + external_references: [ + { + source_name: 'Test', + description: 'Test reference', + }, + ], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + revoked: false, + }, + }, + + // Add more templates as needed... + }; +} + +/** + * Extract all field paths from a nested object + */ +function extractFieldPaths(obj, prefix = '') { + const paths = []; + + for (const [key, value] of Object.entries(obj)) { + const currentPath = prefix ? `${prefix}.${key}` : key; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + paths.push(...extractFieldPaths(value, currentPath)); + } else { + paths.push(currentPath); + } + } + + return paths; +} + +/** + * Remove a field from an object by path (e.g., 'stix.name' or 'workspace.workflow.state') + */ +function removeFieldByPath(obj, path) { + const copy = JSON.parse(JSON.stringify(obj)); + const parts = path.split('.'); + let current = copy; + + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) return copy; + current = current[parts[i]]; + } + + delete current[parts[parts.length - 1]]; + return copy; +} + +/** + * Test field requirements for a specific schema + */ +async function probeSchemaFieldRequirements(schemaName, schema, baseTemplate, endpoint, app, passportCookie) { + console.log(`\nšŸ” Probing ${schemaName}...`); + + const results = { + schemaName, + endpoint, + totalFields: 0, + requiredFields: [], + optionalFields: [], + errors: [], + }; + + // Get all field paths from the base template + const fieldPaths = extractFieldPaths(baseTemplate); + results.totalFields = fieldPaths.length; + + console.log(` Found ${fieldPaths.length} fields to test`); + + // Test each field by removing it + for (const fieldPath of fieldPaths) { + try { + const testObject = removeFieldByPath(baseTemplate, fieldPath); + + const response = await request(app) + .post(endpoint) + .send(testObject) + .set('Content-Type', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); + + if (response.status === 400) { + // Field is required + results.requiredFields.push({ + field: fieldPath, + error: response.body.error, + details: response.body.details, + }); + console.log(` āŒ Required: ${fieldPath}`); + } else if (response.status >= 200 && response.status < 300) { + // Field is optional + results.optionalFields.push(fieldPath); + console.log(` āœ… Optional: ${fieldPath}`); + } else { + // Unexpected response + results.errors.push({ + field: fieldPath, + status: response.status, + error: response.body, + }); + console.log(` āš ļø Unexpected (${response.status}): ${fieldPath}`); + } + } catch (error) { + results.errors.push({ + field: fieldPath, + error: error.message, + }); + console.log(` šŸ’„ Error testing ${fieldPath}: ${error.message}`); + } + + // Small delay to avoid overwhelming the server + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + return results; +} + +/** + * Main execution function + */ +async function main() { + console.log('šŸš€ Starting Field Requirements Probe for ATT&CK Workbench API'); + console.log('='.repeat(80)); + + let app; + let passportCookie; + + try { + // Initialize the database and app (following test pattern) + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + + // Initialize the express app + app = await require('./app/index').initializeApp(); + + // Log into the app + passportCookie = await login.loginAnonymous(app); + + console.log('āœ… App initialized successfully'); + + const baseTemplates = generateBaseTemplates(); + const allResults = []; + + // Test each schema + for (const [schemaName, endpoint] of Object.entries(SCHEMA_TO_ENDPOINT)) { + if (!baseTemplates[schemaName]) { + console.log(`āš ļø Skipping ${schemaName} - no base template defined`); + continue; + } + + try { + const results = await probeSchemaFieldRequirements( + schemaName, + SCHEMAS[schemaName], + baseTemplates[schemaName], + endpoint, + app, + passportCookie + ); + allResults.push(results); + } catch (error) { + console.error(`šŸ’„ Failed to probe ${schemaName}:`, error.message); + } + } + + // Generate comprehensive report + generateReport(allResults); + + } finally { + // Clean up database connection + if (database) { + await database.closeConnection(); + } + } +} + +/** + * Generate comprehensive analysis report + */ +function generateReport(allResults) { + console.log('\n' + '='.repeat(80)); + console.log('šŸ“Š FIELD REQUIREMENTS ANALYSIS REPORT'); + console.log('='.repeat(80)); + + // Summary statistics + const totalSchemas = allResults.length; + const totalFieldsTested = allResults.reduce((sum, r) => sum + r.totalFields, 0); + const totalRequiredFields = allResults.reduce((sum, r) => sum + r.requiredFields.length, 0); + const totalOptionalFields = allResults.reduce((sum, r) => sum + r.optionalFields.length, 0); + + console.log(`\nšŸ“ˆ SUMMARY:`); + console.log(` Schemas tested: ${totalSchemas}`); + console.log(` Total fields tested: ${totalFieldsTested}`); + console.log(` Required fields: ${totalRequiredFields}`); + console.log(` Optional fields: ${totalOptionalFields}`); + + // Common required fields across all schemas + const commonRequired = findCommonFields( + allResults.map((r) => r.requiredFields.map((rf) => rf.field)), + ); + console.log(`\nšŸ”’ FIELDS REQUIRED ACROSS ALL SCHEMAS:`); + commonRequired.forEach((field) => console.log(` - ${field}`)); + + // Backend-set fields that are causing issues + const backendFields = allResults.flatMap((r) => + r.requiredFields.filter( + (rf) => + rf.field.includes('x_mitre_attack_spec_version') || + rf.field.includes('x_mitre_domains') || + rf.field.includes('x_mitre_modified_by_ref') || + rf.field.includes('created_by_ref') || + rf.field.includes('object_marking_refs'), + ), + ); + + console.log(`\nāš ļø BACKEND-SET FIELDS CAUSING VALIDATION FAILURES:`); + backendFields.forEach((field) => { + console.log(` - ${field.field} (${field.error})`); + }); + + // Detailed per-schema breakdown + console.log(`\nšŸ“‹ DETAILED BREAKDOWN BY SCHEMA:`); + allResults.forEach((result) => { + console.log(`\n ${result.schemaName} (${result.endpoint}):`); + console.log(` Required: ${result.requiredFields.length} fields`); + console.log(` Optional: ${result.optionalFields.length} fields`); + console.log(` Errors: ${result.errors.length} fields`); + + if (result.requiredFields.length > 0) { + console.log(` Required fields:`); + result.requiredFields.forEach((rf) => { + console.log(` - ${rf.field}`); + }); + } + }); + + // Frontend action items + console.log(`\nšŸŽÆ FRONTEND ACTION ITEMS:`); + console.log( + `\n The frontend should ensure these fields are included when posting "awaiting-review" objects:`, + ); + + const frontendRequiredFields = new Set(); + allResults.forEach((result) => { + result.requiredFields.forEach((rf) => { + // Filter out fields that should be handled by backend + if ( + !rf.field.includes('created') && + !rf.field.includes('modified') && + !rf.field.includes('id') && + !rf.field.includes('spec_version') && + !rf.field.includes('type') + ) { + frontendRequiredFields.add(rf.field); + } + }); + }); + + [...frontendRequiredFields].sort().forEach((field) => { + console.log(` - ${field}`); + }); + + console.log(`\nāœ… Probe complete! Check the detailed results above.`); +} + +/** + * Find fields that appear in all result sets + */ +function findCommonFields(fieldSets) { + if (fieldSets.length === 0) return []; + + return fieldSets[0].filter((field) => fieldSets.every((set) => set.includes(field))); +} + +// Run the probe +if (require.main === module) { + main().catch((error) => { + console.error('šŸ’„ Probe failed:', error); + process.exit(1); + }); +} + +module.exports = { + generateBaseTemplates, + probeSchemaFieldRequirements, + extractFieldPaths, + removeFieldByPath, +}; diff --git a/.nocommit/plans/workflow-response-ontology.md b/.nocommit/plans/workflow-response-ontology.md new file mode 100644 index 00000000..5aee4312 --- /dev/null +++ b/.nocommit/plans/workflow-response-ontology.md @@ -0,0 +1,106 @@ +# Plan: Universal Workflow Response Ontology + +## Problem + +Backend workflow endpoints (revoke, convert-to-subtechnique, convert-to-technique) orchestrate +multi-step operations but return inconsistent response shapes. The revoke endpoint returns a +bespoke `{ revokedObject, revokedByRelationship, relationshipsSummary }`. The convert endpoints +return only the bare technique object — side-effect documents (created/deprecated relationships) +are invisible to the caller. + +## Universal Response Schema + +Every workflow endpoint returns the same top-level shape: + +```json +{ + "workflow": "convert-to-subtechnique", + "primary": { /* the main object acted upon — full workspace+stix document */ }, + "sideEffects": { + "created": [ /* full documents */ ], + "modified": [ /* full documents */ ], + "deprecated": [ /* full documents */ ], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [] +} +``` + +| Field | Purpose | +|---|---| +| `workflow` | Discriminator string (`"revoke"`, `"convert-to-subtechnique"`, `"convert-to-technique"`) | +| `primary` | The one object the user directly acted on — always exactly one, always a full document | +| `sideEffects.created` | Full documents created as consequences (e.g., `revoked-by` or `subtechnique-of` relationships) | +| `sideEffects.modified` | Full documents modified as consequences (reserved for future use) | +| `sideEffects.deprecated` | Full documents where `x_mitre_deprecated` was set to `true` | +| `sideEffects.deleted` | Count + STIX IDs of hard-deleted documents (only IDs — the docs no longer exist) | +| `warnings` | Non-fatal issues (failed transfers, handler errors, etc.) | + +Counts are derived from array lengths. The `deleted` category is the exception because deleted +documents can't be returned in full. + +## Implementation Sequence + +### 1. WorkflowResult DTO — `app/lib/workflow-result.js` + +Builder class with: +- `setPrimary(doc)` +- `addCreated(docOrDocs)`, `addModified(docOrDocs)`, `addDeprecated(docOrDocs)` +- `addDeleted(stixIds)` +- `addWarning(msg)`, `addWarnings(msgs)` +- `mergeEventResults(eventResults)` — merges `{ created, deprecated, warnings }` from handlers +- `toJSON()` — defensively calls `.toObject()` on Mongoose docs + +### 2. EventBus — return handler results from `emit()` + +Modify `emit()` to collect and return fulfilled values from `Promise.allSettled`. Non-breaking — +no existing caller inspects the return value. + +### 3. RelationshipsService event handlers — return side-effect documents + +Each handler returns a `{ created, deprecated, warnings }` object: +- `handleTechniqueConvertedToSubtechnique` → `{ created: [rel] }` +- `handleSubtechniqueConvertedToTechnique` → `{ deprecated: [rel1, ...] }` +- `handleObjectRevoked` → `{ deprecated: [...] }` + +Catch blocks return `{ warnings: [...] }` instead of swallowing errors silently. + +### 4. Update `base.service.js` `revoke()` + +Replace bespoke return object with `WorkflowResult`. Merge event handler results via +`result.mergeEventResults()`. Remove the post-hoc cross-service read that counts deprecated +relationships (now derived from the returned array). + +### 5. Update `techniques-service.js` convert methods + +Both methods: create `WorkflowResult`, set primary, capture `EventBus.emit()` return value, +merge, return `result.toJSON()`. + +### 6. OpenAPI schema — `app/api/definitions/components/workflow-response.yml` + +Define reusable `workflow-response` and `side-effects` component schemas. Update +`techniques-paths.yml` to reference them for all three workflow endpoints. + +### 7. Tests and documentation + +- Unit tests for `WorkflowResult` +- Update integration tests for new response shape +- Developer doc at `docs/developer/workflow-response-pattern.md` +- Update user docs (revoke-workflow.md, technique-conversion-workflow.md) + +## Key Design Decisions + +- **Full documents in arrays, not just IDs** — the frontend never needs a follow-up GET. +- **`deleted` is count + IDs only** — deleted documents can't be returned in full. +- **Breaking change to revoke response is acceptable** — the endpoint is new. + `revokedObject` → `primary`, `revokedByRelationship` → `sideEffects.created[0]`. +- **Single PR** — contained change surface: one new file, four service mods, one EventBus + tweak, OpenAPI updates. + +## Mapping: Old Revoke Response → New Shape + +``` +revokedObject → primary +revokedByRelationship → sideEffects.created[0] +relationshipsSummary → (derived from sideEffects arrays + warnings) +``` diff --git a/.nocommit/prompts/adm-validation-explainer.md b/.nocommit/prompts/adm-validation-explainer.md new file mode 100644 index 00000000..d4bfbe19 --- /dev/null +++ b/.nocommit/prompts/adm-validation-explainer.md @@ -0,0 +1,29 @@ +I recently introduced a new feature to the ATT&CK Workbench REST API that uses the ATT&CK Data Model (ADM) library (`@mitre-attack/attack-data-model`) to validate request bodies for POST and PUT requests. The ADM provides a comprehensive set of Zod schemas for parsing/validating any ATT&CK type, whether fields and objects alike. Thus, it provides us an opportunity to add much more granular, robust data validation checks to ensure that Workbench does not permit users to create non-compliant objects. This validation takes the form of a middleware function called `validateWorkspaceStixdata`. Here is an example of it in action in the techniques routing module, `app/routes/techniques-routes.js`: +```javascript +'use strict'; + +const express = require('express'); + +const techniquesController = require('../controllers/techniques-controller'); +const authn = require('../lib/authn-middleware'); +const authz = require('../lib/authz-middleware'); +const { validateWorkspaceStixData } = require('../lib/validation-middleware'); + +const router = express.Router(); + +router + .route('/techniques') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + techniquesController.retrieveAll, + ) + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + validateWorkspaceStixData('attack-pattern'), + techniquesController.create, + ); +``` + +Behind the scenes, the middleware crafts a custom Zod schema using the underlying ADM-source schema. There are certain backend-controlled fields, like `x_mitre_attack_spec_version` that need to be omitted from the schema because users will never set them and thereby never includes them in the request body. There are also workflow states wherein users might create a draft of an object and curate it over time, in which case we don't want the validation middleware to prematurely throw if an otherwise required field is excluded while the object is a work-in-progress. Lastly, there are additional metadata fields we track in Workbench that aren't in-scope of ATT&CK or STIX. These things are all dynamically handled under the hood by the `validateWorkspaceStixData` middleware. \ No newline at end of file diff --git a/.nocommit/prompts/fix-import-pipeline.md b/.nocommit/prompts/fix-import-pipeline.md new file mode 100644 index 00000000..64d0eea8 --- /dev/null +++ b/.nocommit/prompts/fix-import-pipeline.md @@ -0,0 +1,117 @@ +We recently made a lot of changes to the core `create` and `updateFull` pipelines for STIX objects. See `BaseService` for details. + +One of the biggest changes was the introduction of the ATT&CK Data Model (ADM) for validating STIX objects. After the STIX object has been composed from the user's request body and right before it's actually created or updated via the repository, we now validate the composed object against the ADM. If there are validation errors, we throw a `ValidationError` with details about the validation errors and warnings (unless the `dryRun` option is set, in which case we just return the validation errors and warnings in the response without throwing an error). + +This change has broken the 'Import Collection Bundle' workflow. + +The 'Import Collection Bundle' workflow allows users to import a STIX bundle containing multiple objects. It is orchestrated by the `app/services/stix/collection-bundles-service/import-bundle.js` module, which processes each object in the bundle and creates it using the appropriate service. The function is attached to the `POST /collection-bundles` endpoint in the `app/routes/collection-bundles-routes.js` module and `app/controllers/collection-bundles-controller.js` controller. + +In `import-bundle.js`, we call each service's `create` method with the `import` option set to `true`: +```javascript +try { + // TODO should we bypass validation for imports? + // or possibly fail open on validation errors where we record the validation error on the object but still allow the import to proceed? + // for validation errors, the object may need to be placed into a quarantined state where it is visible but read-only except through a PUT operation that allows updates to be made to fix the validation errors + await service.create(newObject, { import: true }); +} catch (err) { + if (err.message === service.errors?.duplicateId || err instanceof DuplicateIdError) { + throw err; + } + // Record save error but continue import + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.saveError, + error_message: err.message, + }; + logger.verbose( + `Import Bundle Error: Unable to save object. id=${importObject.id}, modified=${importObject.modified}, ${err.message}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); +} +``` +The `import` option is used to indicate that the object is being created as part of an import operation. In the `BaseService`, we check for this option and run a different `create` pipeline that bypasses certain steps. In actuality, we have not given sufficient thought to how the `import` option should affect the `create` pipeline. + +To ground the discussion, let's consider the case of importing the ATT&CK Mobile bundle. 1997 new objects were added. 538 objects could not be imported. The import errors are captured in `import-errors.json`. In addition, I modified the `BaseService._createFromImport` method to log the validation errors and warnings right before throwing the `ValidationError`. The logged validation errors and warnings are captured in `import_bundle_runtime_logs.txt`. In here, we see the exact Zod validation errors that are occurring during the import. + +Let's look at at some examples. + +The following object is triggering one ADM error: +``` +{ + workspace: { + collections: [ + { + collection_ref: "x-mitre-collection--dac0d2d7-8653-445c-9bff-82f934c1e858", + collection_modified: "2025-11-13T14:00:00.188Z", + }, + ], + attack_id: "DET0680", + }, + stix: { + type: "x-mitre-detection-strategy", + spec_version: "2.1", + id: "x-mitre-detection-strategy--9935655b-cd9b-485f-84ea-1b3b4b765413", + created: "2025-10-21T15:10:28.402Z", + created_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + external_references: [ + { + source_name: "mitre-attack", + url: "https://attack.mitre.org/detectionstrategies/DET0680", + external_id: "DET0680", + }, + ], + object_marking_refs: [ + "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", + ], + modified: "2025-10-21T15:10:28.402Z", + name: "Detection of Security Software Discovery", + x_mitre_modified_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + x_mitre_version: "1.0", + x_mitre_attack_spec_version: "3.3.0", + x_mitre_domains: [ + "mobile-attack", + ], + x_mitre_analytic_refs: [ + "x-mitre-analytic--87d2ccc4-f82e-493d-9c6f-03303253aec2", + "x-mitre-analytic--9c721bd4-75df-4381-bd70-29679aa78a4b", + ], + x_mitre_deprecated: false, + }, +} +``` + +Here is the ADM error: +``` +[ + { + message: "x_mitre_contributors is Invalid input: expected array, received undefined", + path: [ + "x_mitre_contributors", + ], + code: "invalid_type", + input: undefined, + }, +] +``` + +This is an _actual_ issue. The detection strategy should absolutely have an `x_mitre_contributors` field. + +This begs the question: how should we handle it? + +One options is that we can permit the object to be imported, but block subsequent POST requests for the object and instead permit only PUT operations. For context, the POST operations results in new permutations of the object being created, i.e., when you POST an object, a new document is created in the database. PUT on the other hand modifies an existing document in-place. Thus, the idea here is that we can permit users to do in-place modifications to the imported document to make it compliant. + +In addition, it might be beneficial to capture the validation errors in the entity document. This would make it easy to query a list of objects in the database with known validation issues, and it would also make it easy for GET/retrieve requests to return responses that include the known errors so the user can be informed that the retrieved object is not fully STIX/ATT&CK compliant. + +Another issue is that we are validating the ATT&CK bundles which contain objects that pre-date the ADM; there are bound to be validation issues that we can simply ignore, at least until we can review them and decide whether to retroactively amend them or update the ATT&CK specification (the ADM) to accommodate them. Thus, we need to identify non-revoked and non-deprecated objects and create bypass rules for them. + +Additionally, we need to implement logic for bypassing ADM validation on revoked and deprecated objects when importing. We don't retroactively amend revoked objects or deprecated objects by design, so there's no need to validate them (or if we do validate them, we should fail open). + +Please devise a plan to address the following: + +1. Capture all of the validation errors that occur during bundle importation so we can review them. +2. Update the Mongoose models to enable storing validation errors to make querying for objects with known validation errors easier. Make sure to capture information about which ADM version was used. There are two values we should capture: the ATT&CK Specification version, and the ADM TypeScript library version. The former can be retrieved from a global const which is exported from the ADM library root path: `export const ATTACK_SPEC_VERSION = '3.3.0' as const;`. The latter can be inferred/retrieved from the ADM's `package.json`, or perhaps we can grab it from the server's `@mitre-attack/attack-data-model` dependency version. +3. During import, fail-open or skip validation for revoked objects. +4. During import, fail-open or skip validation for deprecated objects. +5. Amend the create pipeline to reject requests if object has known validation issues. Restrict writing of objects with known validation issues to PUT/updateFull operations only. + diff --git a/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md b/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md new file mode 100644 index 00000000..bcd95b8a --- /dev/null +++ b/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md @@ -0,0 +1,105 @@ +The backend is responsible for generating and setting the ATT&CK ID for new objects created in the system. The ATT&CK ID is a unique identifier that follows a specific format, typically consisting of a prefix (e.g., "T" for techniques, "S" for software) followed by a number (e.g., "T1234"). + +When a new object is created, the backend will check the type of the object and generate an appropriate ATT&CK ID based on the existing IDs in the system. This ensures that each object has a unique identifier that can be easily referenced and linked to other objects within the ATT&CK framework. + +The backend supports another feature called the namespace. You can optionally configure a namespace for your ATT&CK IDs to prevent conflicts with objects created by ATT&CK or other organizations and to uniquely identify any objects created by your organization. + +The namespace prefix will appear in upper-case letters and will be prepended to the ATT&CK ID of newly-created objects. The namespace range is a 4-digit number that specifies the lower-bound from which to start generating new object IDs. + +Your namespace prefix will be shown to anyone who downloads collections that you publish. Subsequent changes by others to those objects will not modify the prefix. Updates to your organization namespace prefix will not automatically update the contents of your knowledge base and will only apply to new objects. + +For example, a namespace prefix of "FOOBAR" with a namespace range of "1000" would generate ATT&CK IDs like "FOOBAR-T1000", "FOOBAR-T1001", and so on for new objects created within that namespace. This allows for clear identification of objects created by your organization while maintaining compatibility with the broader ATT&CK framework. + +Please modify the backend code to implement the generation of ATT&CK IDs with optional namespace support. If a namespace is configured, the generated ATT&CK IDs should include the namespace prefix and follow the specified format. If no namespace is configured, the backend should generate ATT&CK IDs in the standard format without a prefix. Notably, the backend already has the capability to generate ATT&CK IDs, so all that is needed is to modify the existing code to incorporate the namespace functionality. + +The ATT&CK ID is generated and set in `BaseService.create`: +``` +// Generate a new ATT&CK ID +attackId = await attackIdGenerator.generateAttackId( + this.type, + this.repository, + isSubtechnique, + parentTechniqueId, +); +logger.debug(`Generated new ATT&CK ID: ${attackId}`); +} + +data.workspace = data.workspace || {}; +data.workspace.attack_id = attackId; +``` + +`attackIdGenerator.generateAttackId` is the function responsible for generating the ATT&CK ID. It is deifned in `app/lib/attack-id-generator.js`. Please modify this function to incorporate the namespace functionality as described above. The function should check if a namespace is configured and generate the ATT&CK ID accordingly, ensuring that it follows the specified format and maintains uniqueness within the system. + +--- + +The behavior is implemented, but shed light on an issue with data validation. The backend currently validates objects using the ATT&CK Data Model (ADM) Zod schemas (via `@mitre-attack/attack-data-model`) during the creation process. These schemas strictly reflect the standard ATT&CK ID format (e.g., "T1234") and do not account for the optional namespace prefix (e.g., "FOOBAR-T1234"). As a result, when a namespace is configured and the backend generates ATT&CK IDs with the prefix, the validation process fails because the generated IDs do not match the expected format defined in the ADM Zod schemas. + +Example error: +``` +external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.### +``` + +We can't modify the ADM Zod schemas to accommodate the namespace prefix, as they are designed to reflect the standard ATT&CK ID format. Therefore, we need to implement a solution that allows for the generation of ATT&CK IDs with the namespace prefix while ensuring that the validation process can still succeed. + +I propose the following: + +Right after we perform validation, but before we throw a `ValidationError`, we should check if any of the validation errors are permissible. +``` +// ────────────────────────────────────────────── +// 5. VALIDATE WITH ADM +// ────────────────────────────────────────────── +const { errors, warnings } = this.validateComposedObject(data); + +if (errors.length > 0) { + for (const error of errors) { + if (!errorIsPermissible(error)) { + throw new ValidationError('ADM validation failed', { details: errors, warnings }); + } + } +} +``` + +We should add a new database collection to store permissible validation errors. This collection will allow us to define specific validation errors that can be ignored during the validation process. Each entry in this collection should include details about the error, such as the error message, the field it pertains to, and any conditions under which it should be considered permissible. For this to work, we'll need to implement a new Mongoose model that defines the collection and the entity that represents a document within that collection. + +Entity definition: +- fieldPath: + - description: The path to the field that the validation error pertains to + - type: [String] + - example: ["external_references", "0", "external_id"] +- errorCode: + - description: The Zod error code that should be considered permissible + - type: String + - example: "invalid_type", "invalid_value", etc. +- stixType: + - description: The STIX object type that the validation error pertains to + - type: String + - example: "attack-pattern", "course-of-action", etc. +- suppressError: + - description: A boolean flag indicating whether to suppress the error (i.e., consider it permissible). This makes it easy to toggle the permissibility of specific errors without needing to delete entries from the collection. + - type: Boolean + - example: true + +We will then implement a function `errorIsPermissible` that checks if a given validation error matches any of the entries in the permissible errors collection. This function will take the validation error as input, extract relevant details (such as the field path, error code, and STIX type), and query the permissible errors collection to determine if it should be ignored. + +Circling back to the namespace prefix issue, we can have the backend automatically create an entry in the permissible errors collection for the specific validation error related to the ATT&CK ID format whenever a namespace is configured. This entry would specify the field path (e.g., ["external_references", "0", "external_id"]), the error code (e.g., "invalid_format"), and the relevant STIX type (e.g., "attack-pattern"). By doing this, we can ensure that when the backend generates ATT&CK IDs with the namespace prefix, the validation process will recognize the specific error as permissible and allow it to pass without throwing a `ValidationError`. + +This is a complex solution that will involve create a new router, controller, service, repository, and Mongoose model for managing the permissible validation errors. We will need to implement CRUD operations for this new entity, as well as the logic for checking permissible errors during the validation process. However, this approach will provide a flexible and scalable way to handle validation errors that may arise from the use of namespaces or other custom configurations in the future. + +Please plan and implement the necessary changes to the backend codebase to support this solution, ensuring that the generation of ATT&CK IDs with optional namespace support is seamlessly integrated with the existing validation process while maintaining the integrity of the data and the overall functionality of the system. Note that the proposed entity definition and the `errorIsPermissible` function are just examples to illustrate the concept. The actual implementation may require additional fields or logic based on the specific requirements and constraints of the system. + +To get you started, here is the literal `errors` list that is returned from the `validateComposedObject` function, which contains the validation errors that we will be checking against the permissible errors collection: + +``` +[ + { + message: "external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.###.", + path: [ + "external_references", + 0, + "external_id", + ], + code: "custom", + input: undefined, + }, +] +``` \ No newline at end of file diff --git a/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md b/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md new file mode 100644 index 00000000..d0bba1c7 --- /dev/null +++ b/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md @@ -0,0 +1,115 @@ +When you post/create a new object with `workspace: {}` a TypeError will throw: +``` + message: "catch all: TypeError: Cannot set properties of undefined (setting 'created_by_user_account')", +``` + +The root cause is this line in `BaseService.create`: +``` + if (options.userAccountId) { + data.workspace.workflow.created_by_user_account = options.userAccountId; + } +``` + +Here's the `data` payload it's trying to process: +``` +{ + workspace: { + attack_id: "M1077", + }, + stix: { + type: "course-of-action", + spec_version: "2.1", + created: "2026-03-26T20:16:47.000Z", + modified: "2026-03-26T20:16:47.000Z", + id: "course-of-action--059ba11e-e3dc-49aa-84ca-88197f40d4eb", + created_by_ref: "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", + external_references: [ + { + source_name: "mitre-attack", + external_id: "M1077", + url: "https://attack.mitre.org/mitigations/M1077", + }, + ], + object_marking_refs: [ + "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", + ], + name: "Application Isolation and Sandboxing", + description: "Restrict the execution of code to a virtual environment on or in-transit to an endpoint system.", + labels: [ + "IEC 62443-3-3:2013 - SR 5.4", + "IEC 62443-4-2:2019 - CR 5.4", + "NIST SP 800-53 Rev. 5 - SI-3", + ], + x_mitre_modified_by_ref: "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", + x_mitre_deprecated: false, + x_mitre_domains: [ + "ics-attack", + ], + x_mitre_version: "1.0", + x_mitre_attack_spec_version: "3.3.0", + }, +} +``` + + +--- + +This error pops up during the revoke workflow due to the repository layer returning Date objects in returned entities. +``` +[ + { + message: "created is Invalid STIX timestamp format: must be an RFC3339 timestamp with a timezone specification of 'Z'.", + path: [ + "created", + ], + code: "invalid_type", + input: undefined, + }, + { + message: "modified is Invalid STIX timestamp format: must be an RFC3339 timestamp with a timezone specification of 'Z'.", + path: [ + "modified", + ], + code: "invalid_type", + input: undefined, + }, +] +``` + +The service layer needs to coerce them before running through ADM validation. +The proper solution would be to address this inside the repository layer. Essentially, the DAO should normalize dates when returning aggregation results. Since its the boundary between MongoDB and the application layer, consumers shouldn't need to know Mongoose return Date objects, let alone Mongoose artifacts like `__t` and `__v`. +However, updating the repository layer to consistently return STIX compliant date strings as opposed to Date objects would break a lot of things. At least 9 files would break, including critical paths: + + - relationships-service.js and reports-service.js — sort by Date subtraction: b.stix.modified - a.stix.modified + (works with Dates, not strings) + - sync-collection-indexes-task.js — calls .getTime() directly on stix.modified + - Release tracks layer (4+ files) — heavy use of .getTime() for conflict resolution, deduplication, export schemas + - detection-strategies-repository.js — direct > comparison on stix.modified Date objects + +The compromise solution: normalize dates in create()'s input pipeline. It's a single fix point, it doesn't change the repository contract, and it protects any future code path that feeds DB data back through create(). +``` +/** +* Coerces any STIX date fields that are JavaScript Date objects into ISO-8601 strings. +* +* Mongoose schemas define timestamp fields (created, modified, start_time, stop_time) +* as `{ type: Date }`, so documents retrieved from MongoDB carry JS Date objects. +* The ADM validation layer (Zod) expects RFC3339 strings. This method bridges that +* gap so that data originating from the repository can safely pass through create() +* without manual per-call-site coercion. +* +* @param {Object} data - The request data ({ stix, workspace }) +*/ +normalizeDateFields(data) { +const stix = data.stix; +if (!stix) return; + +const dateFields = ['created', 'modified', 'start_time', 'stop_time']; +for (const field of dateFields) { + if (stix[field] instanceof Date) { + stix[field] = stix[field].toISOString(); + } +} +} +``` + +We will likely address this more holistically using systems design principles in the future. For now, this is the most pragmatic solution. \ No newline at end of file diff --git a/.nocommit/prompts/organization-identity-syncing.md b/.nocommit/prompts/organization-identity-syncing.md new file mode 100644 index 00000000..2bdb2d98 --- /dev/null +++ b/.nocommit/prompts/organization-identity-syncing.md @@ -0,0 +1,69 @@ +There are two global settings: the namespace prefix, and the organization identity. + +The namespace prefix is used to generate attack IDs in a specific format. The configuration is comprised of two values: the prefix (e.g. "ACME") and the rangeStart (e.g., 1000). When generating attack IDs, the system will look for existing attack IDs with the specified prefix, find the maximum number used, and generate the next ID by incrementing that maximum. If no existing IDs are found, it will start from the rangeStart value. For example, if the prefix is "ACME" and the rangeStart is 1000, and the existing IDs are ACME-1000, ACME-1001, and ACME-1002, the next generated ID would be ACME-1003. If there are no existing IDs with the "ACME" prefix, the first generated ID would be ACME-1000. + +The organization identity is used to set the `created_by_ref` and `x_mitre_modified_by_ref` properties on all created and modified objects, respectively. This ensures that all objects are attributed to the organization identity. The organization identity is also used in the bypass rules for validation, specifically to bypass the rule that requires `x_mitre_modified_by_ref` to be set to a valid identity when the value is set to the organization identity. This is necessary because the ATT&CK Data Model (ADM) validation explicitly requires that the `x_mitre_modified_by_ref` property is set to the _MITRE_ organization identity (which happens to be `'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5'`) -- the ADM specifically reflects the official ATT&CK namespace, and therefore requires that any object that has `x_mitre_modified_by_ref` set must be set to the _MITRE_ organization identity. However, in the ATT&CK Workbench application, we want to allow organizations/teams/users to set `x_mitre_modified_by_ref` to their own organization identity (which is a common use case), so we need to have a bypass rule that allows objects with `x_mitre_modified_by_ref` set to the organization identity to bypass the validation rule that requires `x_mitre_modified_by_ref` to be set to a valid identity. Luckily, this scaffolding is already in place via the validation bypass rules. + +The issue that I would like to discuss is related to the organization identity. There are two relevant endpoints: + +1. `POST /api/config/organization-identity` - this endpoint is used to set the `organization_identity_ref` field in the one entity that lives int he `systemconfiguration` Mongo Collection. Here is a copy of the entity as shown in MongoDB Compass: + +```json +{ + "_id": { + "$oid": "6969691cd1aed3fbdf8f8007" + }, + "organization_identity_ref": "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", + "default_marking_definitions": [], + "organization_namespace": { + "range_start": 0, + "prefix": "SEAN" + }, + "__v": 0, + "anonymous_user_account_id": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" +} +``` + +2. `POST /api/identities` and `PUT /api/identities/:stixId/modified/:modified` - these endpoints are used to create and update STIX identity SDO objects in the `attackObjects` Mongo Collection. Identities are first class objects in STIX and are handled as such in the ATT&CK Workbench application. + +The issue is twofold: + +When the original creators of Workbench implemented the frontend, they wired up the frontend mechanism for setting the organization identity to the `POST /api/identities` endpoint. The `POST /api/config/organization-identity` endpoint is not currently wired up to the frontend, and in fact, it is not currently used at all in the backend. + +The problem with the current implementation is that if a user sets the organization identity via the `POST /api/identities` endpoint, there is no mechanism in place to set the `organization_identity_ref` field in the `systemconfiguration` collection to the newly created organization identity. This means that there is a disconnect between the organization identity that is created and the organization identity that is used in the application configuration. + +The solution: + +Despite being first class objects in STIX, identities are not fully supported as first class objects in the current frontend implementation. The frontend only supports creating identities via the aforementioned global organization identity mechanism, which provides two fields: "organization name" and "organization description", which map to the `name` and `description` fields of the identity SDO, respectively. A better solution would be to fully support identities as first class objects in the frontend, enabling users to create and manage identities in the frontend via the standard create and update workflows that are used for all other objects. + +Then, the `POST /api/config/organization-identity` endpoint can be refactored to simply set the `organization_identity_ref` field in the `systemconfiguration` collection to point to an existing identity object in the `attackObjects` collection, and the frontend can be wired up to use this endpoint when a user selects an existing identity to be the organization identity. This would ensure that there is a clear connection between the organization identity that is created and the organization identity that is used in the application configuration, and it would also provide a more robust and flexible mechanism for managing identities in the application. + +For our purposes, we only need to make changes to the backend, specifically the `POST /api/config/organization-identity` endpoint's service logic: + +```javascript +async setOrganizationIdentity(stixId) { + const systemConfig = await this.repository.retrieveOne(); + + if (systemConfig) { + systemConfig.organization_identity_ref = stixId; + await this.repository.constructor.saveDocument(systemConfig); + } else { + const systemConfigData = { organization_identity_ref: stixId }; + const newConfig = this.repository.createNewDocument(systemConfigData); + await this.repository.constructor.saveDocument(newConfig); + } +} +``` + +Notice that the above code assumes that the `stixId` being passed in is already the ID of an existing identity object in the `attackObjects` collection. This means that the frontend will need to ensure that it is passing in a valid `stixId` when calling this endpoint, and it will also need to ensure that the identity object with that `stixId` already exists in the `attackObjects` collection before calling this endpoint. + +We should refactor this to check if the provided `stixId` corresponds to an existing identity object in the `attackObjects` collection (or more specifically, in the `identities` repository), and if it does, then we can proceed to set the `organization_identity_ref` field in the `systemconfiguration` collection. If it does not, then we should throw an error indicating that the specified identity does not exist. + +Tangential to this, we should also consider how the organization identity affects downstream logic, specifically the inheritance logic for the `created_by_ref` and `x_mitre_modified_by_ref` properties. The intended behavior is that `x_mitre_modified_by_ref` should always be set to the organization identity. `created_by_ref` has two potential intended behaviors: it can either be set to the organization identity _if_ the object was not imported from an external source, _or_ it can be set to the organization identity if the object's provenance is the current organization. We thus need to start tracking the provenance of the entity in the `systemconfiguration` collection (currently, we edit the `organization_identity_ref` field in the one and only document in the `systemconfiguration` collection; it is an in-place update to that document, so we can't infer how the organization identity has changed over time based on the history of that document, because there is only one document and it is being updated in place. Therefore, we need to start tracking the provenance of the organization identity itself in order to determine whether a created object should have its `created_by_ref` set to the organization identity based on whether the object's provenance is the current organization or an external source.) + +Consider an example: + +1. A user creates an identity object in the frontend with the name "ACME Corporation" and description "A fictional company". This identity object is created via the `POST /api/identities` endpoint, and it is assigned a `stixId` of `identity--0001`. +2. The user creates a technique object with the name "Spear Phishing" and description "A technique used to target specific individuals". This technique object is created via the `POST /api/techniques` endpoint, and it has its `created_by_ref` set to `identity--0001` and its `x_mitre_modified_by_ref` set to `identity--0001`. +3. The user then changes the organization identity to `identity--0002` via the `POST /api/config/organization-identity` endpoint. This updates the `organization_identity_ref` field in the `systemconfiguration` collection to point to `identity--0002`. +4. This should propagate to all existing objects in the system, such that the technique object that was created in step 2 now has its `created_by_ref` and `x_mitre_modified_by_ref` properties updated to `identity--0002`, because the provenance of that object is now the current organization (since it was created by an identity that is part of the current organization). Importantly, only the latest version of the technique object should be updated, and it should NOT be an in-place update to the existing object version; rather, a new version of the technique object should be created with the updated `created_by_ref`. This is important for maintaining the integrity of the version history of the object, as well as for ensuring that the ADM validation rules are properly applied to the new version of the object with the updated organization identity. This propagation should only apply to objects that were created by identities that are part of the current organization; if an object was created by an identity that is not part of the current organization (e.g. an imported object with a `created_by_ref` that points to an identity that is not in the `attackObjects` collection), then that object's `created_by_ref` should not be updated when the organization identity changes, because the provenance of that object is not the current organization. This means that we need to have a way to determine whether an object's provenance is the current organization or an external source, which brings us back to the need to track the provenance of the organization identity itself in order to make this determination. \ No newline at end of file diff --git a/.nocommit/prompts/refactor-export-service.md b/.nocommit/prompts/refactor-export-service.md new file mode 100644 index 00000000..98c3a89c --- /dev/null +++ b/.nocommit/prompts/refactor-export-service.md @@ -0,0 +1,66 @@ +Please read the specification and concept documentation for the "release tracks" feature, which is actively in development, at `docs/COLLECTIONS_V2/`. Once you have a lay of the land, focus on helping me refactoring the export service (`app/services/release-tracks/export-service.js`), and update the application code and documentation (only if necessary). + +Context: +I am interested in using Zod v4 to encapsulate DTO transformation logic. The benefit being that we can define a Zod schema for each of the output formats (`workbench`, `bunde`, `filesystemstore`), which are self-contained, easy to read, and well supported. The input data would be the `snapshot` object, which is retrieved from the snapshot service and passed to the `exportSnapshot` function as the first positional argument (also labeled `snapshot`). + + +## Transforms + +Note: For bi-directional transforms, use codecs. + +Transforms are a special kind of schema that perform a unidirectional transformation. Instead of validating input, they accept anything and perform some transformation on the data. To define a transform: + +```javascript +const castToString = z.transform((val) => String(val)); + +castToString.parse("asdf"); // => "asdf" +castToString.parse(123); // => "123" +castToString.parse(true); // => "true" +``` + +Transform functions should never throw. Thrown errors are not caught by Zod. + +To perform validation logic inside a transform, use `ctx`. To report a validation issue, push a new issue onto `ctx.issues` (similar to the `.check()` API). + +```javascript +const coercedInt = z.transform((val, ctx) => { + try { + const parsed = Number.parseInt(String(val)); + return parsed; + } catch (e) { + ctx.issues.push({ + code: "custom", + message: "Not a number", + input: val, + }); + + // this is a special constant with type `never` + // returning it lets you exit the transform without impacting the inferred return type + return z.NEVER; + } +}); +``` + +Most commonly, transforms are used in conjunction with Pipes. This combination is useful for performing some initial validation, then transforming the parsed data into another form. + +```javascript +const stringToLength = z.string().pipe(z.transform(val => val.length)); +stringToLength.parse("hello"); // => 5 +``` + +## `.transform()` + +Piping some schema into a transform is a common pattern, so Zod provides a convenience `.transform()` method. + +```javascript +const stringToLength = z.string().transform(val => val.length); +``` + + +The source code is located in the `app/` folder. Additionally, the following documentation files may help contextualize some critical aspects of the software design: + +- `docs/EVENT_BUS_ARCHITECTURE.md` +- `docs/CROSS_SERVICE_READS_PATTERN.md` +- `docs/LIFECYCLE_HOOKS_GUIDE.md` +- `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` +- `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md` diff --git a/.nocommit/prompts/remove the include_candidates_in_snapshots property.md b/.nocommit/prompts/remove the include_candidates_in_snapshots property.md new file mode 100644 index 00000000..07333f95 --- /dev/null +++ b/.nocommit/prompts/remove the include_candidates_in_snapshots property.md @@ -0,0 +1,36 @@ +Please read the specification and concept documentation for the "release tracks" feature, which is actively in development, at `docs/COLLECTIONS_V2/`. Once you have a lay of the land, focus on helping me remove the `include_candidates_in_snapshots` property from the release-track schema, and update the application code and documentation accordingly. + +Context: +We don't need to statefully track retrieval/export filter properties in the release-track config. It makes more sense to treat such filters as stateless query parameters that users can set during export operations. In other words, when users retrieve a release-track snapshot (draft or release), they should be able to specify or constrain the resultant snapshot to their liking. + +There are several ways the user can export a release-track snapshot via simple Get/Retreive operations: + +- Get Latest Snapshot: `GET /api/release-tracks/:id` +- Get Specific Snapshot: `GET /api/release-tracks/:id/snapshots/:modified` + +Such retrieval endpoints should support two query parmeters: + +- `include`: Allows users to filter/specify which tiers of objects will be included in the output: + - If unset, only `members` are included + - If `include=candidates`, then members and candidates are included + - If `include=staged`, then members and staged are included + - If `include=all`, then members,staged, and candidates are included + +- `format`: Allows users to specify the output structure (DTO shape) + - `bundle` (default): produces a standard STIX 2.1 bundle + - `filesystemstore`: STIX FileSystemStore directory structure (will be implemented in a future release) + - `workbench`: Custom format with workflow metadata for UI. This is meant to structure the data in as convenient a way as possible for the frontend to hydrate/render the content + +Importantly, the `include` filter should NOT be supported by bump dry-runs operations (`POST /api/release-tracks/:id/bump` with `dry_run: true` in `req.body`), nor bump preview operations (`GET /api/release-tracks/:id/bump/preview`). Bump dry-runs and bump previews are meant to show the user what *will* happen when a bump occurs; filters will only confuse the user because they allow the user to ad-hoc transform the resultant release snapshot even though that ad-hoc transformation will not actually be reflected in the final release snapshot. + +Please start by updating the specification and concept documentation in `docs/COLLECTIONS_V2/`. This folder contains the source of truth for how the release tracks feature operates. + +Once the documentation reflects what we want, please familiarize yourself with the software design/architecture. The source code is located in the `app/` folder. Additionally, the following documentation files may help contextualize some critical aspects of the software design: + +- `docs/EVENT_BUS_ARCHITECTURE.md` +- `docs/CROSS_SERVICE_READS_PATTERN.md` +- `docs/LIFECYCLE_HOOKS_GUIDE.md` +- `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` +- `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md` + +Finally, once you fully understand the state of the software and have an implementation plan for removing `include_candidates_in_snapshots` from the release track schema and tightening up support for the `include` and `format` query parameters, you may commence the refactor. \ No newline at end of file diff --git a/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md b/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md new file mode 100644 index 00000000..99e9f90a --- /dev/null +++ b/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md @@ -0,0 +1,72 @@ +Please create a database migration script and place it in `migrations/` that retroactively removes the `x_mitre_version` +field from all non-deprecated relationship documents in the `relationships` Mongo collection. + +Context: Historically, we used to store `x_mitre_version` on relationships. But we've since changed the ATT&CK specification, specifying that the field is no longer permitted on SROs. There are old relationships lingering in some Workbench instances that still contain this field. If you try to amend those relationship documents using the standard POST workflow, the operation will fail due to ADM validation errors, because the ADM will detect the presence of `x_mitre_version` on the posted object (since we're just posting the preexisting object with whatever fields modified) and throw a `ValidationError`. + +Here's an example of an invalid relationship: + +```javascript +{ + workspace: { + workflow: { + state: "reviewed", + created_by_user_account: "identity--b5220818-f881-4f4a-9091-00a07cf2a576", + }, + validation: { + errors: [ + { + message: " is Unrecognized key: \"x_mitre_version\"", + path: [ + ], + code: "unrecognized_keys", + }, + ], + attack_spec_version: "3.3.0", + adm_version: "4.10.0", + validated_at: "2026-04-10T15:27:01.953Z", + }, + }, + stix: { + object_marking_refs: [ + "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", + ], + type: "relationship", + id: "relationship--06068b8a-0bfe-499c-8c7c-3cf3123a3541", + created: "2022-07-08T13:57:50.268Z", + x_mitre_version: "0.1", + external_references: [ + { + source_name: "Microsoft POLONIUM June 2022", + url: "https://www.microsoft.com/security/blog/2022/06/02/exposing-polonium-activity-and-infrastructure-targeting-israeli-organizations/", + description: "Microsoft. (2022, June 2). Exposing POLONIUM activity and infrastructure targeting Israeli organizations. Retrieved July 1, 2022.", + }, + ], + x_mitre_deprecated: true, + description: "(LinkById: S1023) has the ability to disable OneDrive protections that prevent the theft of token and client secrets.(Citation: Microsoft POLONIUM June 2022)", + spec_version: "2.1", + modified: "2022-08-10T13:01:17.510Z", + created_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + relationship_type: "uses", + source_ref: "malware--750eb92a-7fdf-451e-9592-1d42357018f1", + target_ref: "attack-pattern--cb715638-29a5-425c-bf77-c805ef3d7cb1", + x_mitre_modified_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + x_mitre_attack_spec_version: "3.3.0", + revoked: false, + }, +} +``` + +And here's a validation error that would occur on the above object: +``` +[ + { + message: " is Unrecognized key: \"x_mitre_version\"", + path: [ + ], + code: "unrecognized_keys", + input: undefined, + }, +] +``` + +We need to apply a database fix to bring preexisting documents into compliance with the new business logic. \ No newline at end of file diff --git a/.nocommit/prompts/revoke-workflow-bug.md b/.nocommit/prompts/revoke-workflow-bug.md new file mode 100644 index 00000000..8c591776 --- /dev/null +++ b/.nocommit/prompts/revoke-workflow-bug.md @@ -0,0 +1,48 @@ +I think there might be a logic bug in the `BaseService.revoke` method. + +Please read the method, as well as the documentation in `docs/user/revoke-workflow.md` before we get started. + +I think there is a bug in the way we migrate relationships from the revoked object to the revoking object when the `preserveRelationships` query parameter is set. + +The fundamental goal is to enable a pathway for carrying over relationships that exist on the revoked object onto the revoking object. You might imagine an object being the `source_ref` or `target_ref` or hundreds of relationships, and so this workflow should enable migrating such relationships onto some new/different object the _revoking_ object). + +But I think there's an issue with the logic that determines exactly which relationships to carry over. It says: + +> If the revoking object (Object B) already participates in a relationship with the same source, target, and relationship type as an existing relationship of the revoked object (Object A), the transfer is skipped and a warning is included in the response. + +But this is wrong. It's not possible for Object 'foo' to participate in an identical SRO as Object 'bar' unless the SRO were literally between the two of them. Just think about it: + +```json +// relationship1 +{ + "relationship_type": "mitigates", + "source_ref": "course-of-action--1234", + "target_ref": "attack-pattern-1234" // <-- this is 'foo' +}, +// relationship2 +{ + "relationship_type": "mitigates", + "source_ref": "course-of-action--1234", + "target_ref": "attack-pattern-5678" // <-- this is 'bar' +} +``` + +Let's assume here that we're revoking 'foo' with 'bar'. foo will become revoked, and all of its relationships will be preserved on 'bar'. + +Here, we show situation where we'd want to skip the "preserving" process for relationship1. In other words, we should not preserve the relationship above, dubbed 'relationship1', on 'bar', because 'bar' already has that relationship —— it's relationship2! + +I think we need to amend our business logic to say: "If the revoking object already participates in a relationship with the same relationship type AND the same source OR target, the transfer should be skipped. In other words, if there exists an essentially identical relationship already where the only difference is the revoking and revoked STIX IDs are hotswappable in either the `source_ref` or `target_ref` fields, then that relationship should be skipped. + +Moreover, we need to consider potential edge cases. For example: + +We can see that the server throws a `BadRequestError` if the revoking object is not of the same type as the revoked object, but this does not cover the case of subtechniques (subs) and techniques (parents). I think it should be permissible for subs to revoke parents and vice versa, but we need to reason through the implications and limitations to avoid from putting the database into an invalid state. + +If a subtechnique (Object B) revokes a parent (Object A), then we should require that Object B is NOT a subtechnique of Object A. Otherwise weirdness might ensue if we set `preserveRelationships=true`: + - If Object A has _other_ subtechniques, they would be migrated to Object B, leaving us with a state where subtechniques are subtechniques of other subtechniques. This is not permissible: subtechniques can only be subtechniques of parent techniques, i.e., nested subtechniques are not permitted. + - We would also end up with Object B being orphaned (a subtechnique without a parent). If this is the desired operation, then Object B should first be converted to a parent. There are separate workflow endpoints for converting techniques to subtechniques and vice versa. + +Are there other edge cases we need to consider? + +Is our logic for identifying out-of-scope relationships that should be skipped during the preservation process actually correct? + +Please reason about the current state of the revoke workflow and the concerns brought forth and determine if we need to make any modifications. diff --git a/.nocommit/prompts/revoke-workflow.md b/.nocommit/prompts/revoke-workflow.md new file mode 100644 index 00000000..715f8abf --- /dev/null +++ b/.nocommit/prompts/revoke-workflow.md @@ -0,0 +1,269 @@ +Currently, we have the following workflow in place: + +When a user revokes an object, all relationships referencing the revoked object are deleted. + +This workflow is facilitated by the frontend. In effect, a single POST request containing a revoke operation will cascade into potentially dozens or hundreds of subsequent DELETE requests. Not only is this workflow inefficient, it creates an inconsistent user experience — users interacting with the backend through means other than the frontend SPA (e.g., Python, cURL, etc.) will have to re-implement their own equivalent logic for handling relationships that become orphaned as a consequence of revoking an object. + +Moreover, the workflow doesn't provide an option for preserving preexisting relationships on the _revoking_ object. A user may desire to revoke Object A with Object B — perhaps they differ only in some trivial semantic way — but preserve all of Object A's relationships on Object B. This is not currently possible. The user must separately identify the relationships _before_ revoking the object, then recreate them after Object A is revoked and its relationships are destroyed. + +There is also the problem of the revoke operation itself: it's currently treated as "just another STIX operation" wherein the user makes a POST (for creating new objects as well as creating new revisions for existing objects) or PUT request (for editing existing objects in-place) to the requisite endpoint, such as `POST /api/attack-objects` for techniques and `POST /api/tactics` for tactics. Because the Revoke Workflow is orchestrated by the frontend, the backend has no conception/awareness of revocation. To the backend, toggling `revoked` is no different than modifying the object's `description`. Thus, I want to introduce a new backend workflow for handling revocations, and I want to make it accessible over a explicit endpoint (or set of endpoints), e.g., `POST /api/{type}/revoke`. + +The endpoint should support the following query parameters: + +- name: `preserveRelationships` + type: `boolean` + default: `false` + description: Tells the backend whether _revoked_ object's relationships (i.e., the relationships of Object A) should be carried over to the _revoking_ object (Object B). If true, Object A's relationships should be destroyed AND recreated on Object B. + +As for specifying the _revoked_ and _revoking_ objects themselves, we have two options: + +1. We can use a path parameter to specify the _revoked_ object and thus expose a Revoke Operation on each of the existing STIX type-specific routers, e.g., `/api/:type/:stixId/revoke`. In this solution, `stixId` would refer to the _revoked_ object, and we would specify the _revoking_ object in the request body, e.g.: +```json +{ + "revoking": { + "id": "attack-pattern--0002", + "modified": $Date + } +} +``` +1. Alternatively, we can setup a new, dedicated, type-agnostic router (e.g., `/api/revoke`) and specify _all_ required information in the request body: +```json +{ + "revoking": { + "id": "attack-pattern--0001", + "modified": $Date + }, + "revoking": { + "id": "attack-pattern--0002", + "modified": $Date + } +} +``` + +Read the supporting architectural documentation in the `docs/` folder. Pay particular attention to the following: +- `docs/CROSS_SERVICE_READS_PATTERN.md` +- `docs/EVENT_BUS_ARCHITECTURE.md` +- `docs/LIFECYCLE_HOOKS_GUIDE.md` +- `docs/SERVICE_REQUEST_MIDDLEWARE.md` + +The backend uses OOP makes ample use of inheritance throughout the service layer. All STIX-oriented services extend the `BaseService` class. Core worfklows such as `create` and `updateFull` are orchestrated by the `BaseService` and should NOT be overridden by child classes. Instead, child classes (such as `TechniquesService` and `TacticsService`) should implement lifecycle hook functions like `beforeCreate` and `afterCreate` to augment or modify core workflow behavior. + +In this particular case, we may want to implement a new "core" workflow in the `BaseService`. It might be a method called `revoke`. This would coincide with a new set of router endpoints and controller functions. + +Notably, the backend is an Express.js REST API, and the frontend is an Angular SPA. + +- Backend code: `/Users/ssica/Development/attack/workbench/attack-workbench-rest-api` +- Frontend code: `/Users/ssica/Development/attack/workbench/attack-workbench-frontend` + +Please weigh the pros and cons of each approach and propose a plan for implementing this feature, starting with the backend. + +--- + + Revoke Workflow — Implementation Plan + + Context + + Revoking an object (replacing Object A with Object B) is currently orchestrated by the frontend SPA. The backend has no + concept of revocation — setting stix.revoked = true is indistinguishable from modifying description. This means: + + 1. Non-SPA consumers (Python, cURL) must reimplement the cascade logic themselves + 2. There's no option to preserve Object A's relationships on Object B + 3. A single revoke cascades into dozens of DELETE requests from the frontend + + This plan introduces a backend-orchestrated Revoke Workflow accessible via dedicated endpoints. + + Endpoint Design: Type-Specific Routes (Approach 1) + + Chosen: POST /api/techniques/:stixId/revoke (and equivalent for each type) + + Why not type-agnostic (POST /api/revoke)? + - Every existing route is type-specific — a cross-type endpoint would be the only exception + - Auth middleware is applied per-type in each router file + - Controllers already import their singleton service — no dispatch logic needed + - Service resolution from STIX ID prefix would be fragile and unprecedented in this codebase + + Request format: + POST /api/techniques/attack-pattern--0001/revoke?preserveRelationships=true + + Body: + { + "revoking": { + "stixId": "attack-pattern--0002", + "modified": "2026-03-20T00:00:00.000Z" + } + } + + Response format: + { + "revokedObject": { }, + "revokedByRelationship": { }, + "relationshipsSummary": { + "deleted": 5, + "transferred": 5, + "warnings": [] + } + } + + Revoke Workflow (BaseService.revoke) + + 1. VALIDATE INPUTS + - stixId required (path param = Object A) + - body.revoking.stixId required (Object B) + - body.revoking.modified required + - A !== B (no self-revocation) + + 2. RETRIEVE OBJECTS + - objectA = this.repository.retrieveLatestByStixId(stixId) + - Throw NotFoundError if null + - Throw AlreadyRevokedError if objectA.stix.revoked === true + - objectB = this.repository.retrieveOneByVersion(revokingStixId, revokingModified) + - Throw NotFoundError if null + + 3. LIFECYCLE HOOK: beforeRevoke(objectA, objectB, options) + + 4. MARK OBJECT A AS REVOKED + - Clone objectA's data, set stix.revoked = true + - Call this.create() internally to create a new version + - This reuses the full create pipeline (ATT&CK ID reuse, field composition, ADM validation) + + 5. CREATE REVOKED-BY RELATIONSHIP + - Call relationshipsService.create() with: + { stix: { type: 'relationship', relationship_type: 'revoked-by', + source_ref: objectA.stix.id, target_ref: objectB.stix.id } } + + 6. HANDLE RELATIONSHIPS + - Query all relationships where source_ref OR target_ref = objectA.stix.id + - Exclude the revoked-by relationship just created + - If preserveRelationships === true: + - For each relationship, clone it with objectB substituted for objectA + - Save clones via relationshipsService.create() + - Delete all originals referencing objectA + + 7. LIFECYCLE HOOK: afterRevoke(revokedDocument, objectB, options) + + 8. EMIT EVENT: emitRevokedEvent(revokedDocument, objectB, options) + + 9. RETURN result with revokedObject, revokedByRelationship, relationshipsSummary + + Note on cross-service writes: Step 5-6 involve writing to the relationships collection from BaseService. Per the + architecture docs, cross-service writes should use events. However, the revoke workflow is inherently transactional — the + caller needs to know if relationship transfer failed. Since EventBus.emit() uses Promise.allSettled and swallows errors, + I'll use direct orchestration (calling relationshipsService.create() directly from BaseService.revoke()). This is the same + pragmatic approach used in TechniquesService._propagateShortnameViaNewVersions which directly calls this.create() for + related objects. The event is still emitted afterward for observability. + + Implementation Sequence + + Step 1: Add Exception Classes + + File: app/exceptions/index.js + - Add AlreadyRevokedError (→ 409 Conflict) + - Add SelfRevocationError (→ 400 Bad Request) + + File: app/lib/error-handler.js + - Register AlreadyRevokedError in 409 block + - Register SelfRevocationError in 400 block + + Step 2: Add Event Constants + + File: app/lib/event-constants.js + - Add *_REVOKED constants for each STIX type (following existing pattern) + + Step 3: Add Lifecycle Hooks + + File: app/services/meta-classes/hooks.service.js + - Add beforeRevoke(objectA, objectB, options) — no-op default + - Add afterRevoke(revokedDocument, objectB, options) — no-op default + - Add emitRevokedEvent(revokedDocument, revokingDocument, options) — emits ${this.type}::revoked + + Step 4: Add Repository Methods + + File: app/repository/relationships-repository.js + - Add retrieveAllBySourceOrTarget(stixId) — finds latest version of all relationships referencing a STIX ID as source or + target + - Add deleteManyBySourceOrTarget(stixId, excludeStixIds) — deletes all relationship documents (all versions) where + source_ref or target_ref matches, excluding specified stix.ids + + Step 5: Add revoke() to BaseService + + File: app/services/meta-classes/base.service.js + - Add the revoke(stixId, data, options) method implementing the workflow above + - Import relationshipsService and relationshipsRepository at the top + + Step 6: Add Controller Method + + Files: All type-specific controllers that support revocation: + - app/controllers/techniques-controller.js + - app/controllers/tactics-controller.js + - app/controllers/software-controller.js + - app/controllers/groups-controller.js + - app/controllers/mitigations-controller.js + - app/controllers/campaigns-controller.js + - app/controllers/data-sources-controller.js + - app/controllers/data-components-controller.js + - app/controllers/assets-controller.js + - app/controllers/matrices-controller.js + + Each gets a revoke export: + exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: req.query.preserveRelationships === 'true', + userAccountId: req.user?.userAccountId, + }; + const result = await service.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } + }; + + Step 7: Add Routes + + Files: All matching router files: + - app/routes/techniques-routes.js + - app/routes/tactics-routes.js + - app/routes/software-routes.js + - app/routes/groups-routes.js + - app/routes/mitigations-routes.js + - app/routes/campaigns-routes.js + - app/routes/data-sources-routes.js + - app/routes/data-components-routes.js + - app/routes/assets-routes.js + - app/routes/matrices-routes.js + + Each gets: + router + .route('//:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), controller.revoke); + + Edge Cases + + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ Case │ Handling │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Object A not found │ 404 NotFoundError │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Object A already revoked │ 409 AlreadyRevokedError │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Object B not found │ 404 NotFoundError │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Self-revocation (A === B) │ 400 SelfRevocationError │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Object A has no relationships │ Steps 5-6 are no-ops; revoked-by still created │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Relationship clone fails mid-way │ Log warning, continue; return warnings array │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Concurrent revocation │ First writer wins (create() throws DuplicateIdError) │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ Cross-type revocation (A and B differ) │ Allow — STIX spec permits it │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + + Verification + + 1. Unit tests: Test revoke() in isolation with mocked repositories + 2. Integration tests: + - Revoke without preserving relationships → verify A is revoked, relationships deleted, revoked-by created + - Revoke with preserving relationships → verify relationships cloned to B then deleted from A + - Error cases (not found, already revoked, self-revocation) + 3. Manual API test: POST /api/techniques/:stixId/revoke via cURL \ No newline at end of file diff --git a/.nocommit/prompts/synthetic-data-generator.md b/.nocommit/prompts/synthetic-data-generator.md new file mode 100644 index 00000000..627e66f2 --- /dev/null +++ b/.nocommit/prompts/synthetic-data-generator.md @@ -0,0 +1,625 @@ +I need your help solving a critical issue in the source code for the ATT&CK Workbench REST API application: All of our regression tests in `app/tests/` must be run with ATT&CK Data Model (ADM) validation turned off. + +```typescript +describe('Techniques Convert API', function () { + let app; + let passportCookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + + config.validateRequests.withAttackDataModel = false; // <-- this is the key line + config.validateRequests.withOpenApi = false; + + app = await require('../../../index').initializeApp(); + passportCookie = await login.loginAnonymous(app); + }); +``` + +The ADM refers to a JavaScript library, `@mitre-attack/attack-data-model`, maintained by us at MITRE, that provides a set of Zod schemas for validating STIX objects against the ATT&CK Data Model. It is the source of truth for what constitutes a valid ATT&CK STIX object, and it was recently integrated into the REST API's request validation logic to ensure that all incoming requests are compliant with the ADM validation rules. + +The reason that the regression tests must be run with ADM disabled is because the ADM makes the validation of requests significantly stricter than it was before, and many of our existing regression tests were not designed with this level of strictness in mind. Most, if not all, of the regression tests in `app/tests/` build request bodies from manually constructed STIX objects, most of which do not pass the ADM validation rules. This is not necessarily a bad thing, as it allows us to test the API's behavior with a wider variety of inputs, including those that may not be fully compliant with the ADM. However, it does mean that we cannot run these tests with ADM validation turned on without first updating them to ensure that their request bodies are compliant with the ADM. + +The way I see it, we have three solutions: + +1. Update all existing regression tests in `app/tests/` to ensure that their request bodies are compliant with the ADM. This would involve reviewing each test case, identifying any non-compliant STIX objects, and modifying them to adhere to the ADM validation rules. While this approach would allow us to run all tests with ADM validation turned on, it would require a significant amount of time and effort, especially if there are many tests that need to be updated. + +2. Create a separate set of regression tests that are specifically designed to be compliant with the ADM. This would allow us to maintain our existing regression tests as they are, while also having a new set of tests that can be run with ADM validation turned on. This approach would require less effort than updating all existing tests, but it would also mean that we have two sets of regression tests to maintain, which could lead to confusion and duplication of effort in the long run. + +3. Implement a synthetic data generator that can create compliant STIX objects for use in our regression tests. This approach is appealing because it presents an opportunity to expose an API for generating fake STIX objects directly from the `@mitre-attack/attack-data-model` library, which could be useful for other purposes beyond just our regression tests. Each regression test would still need to be updated to use the synthetic data generator, but this would likely be less time-consuming than manually updating each test case to use compliant, mock data. Additionally, this approach would likely save time in the long run, as it would allow us to easily generate compliant STIX objects for any future tests we may need to create, without having to manually construct them each time. + +Constraints & Issues: + +The ADM exposes a set of Zod schemas that define the structure and validation rules for STIX objects. However, these on their own are insufficient for validating the request bodies in our regression tests. Importantly, the REST API is more permissiveness than the ADM, allowing the user to omit certain fields that are required the ADM, specifically when the object is flagged as "work-in-progress": + +```typescript +const baseTechniqueData = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + name: 'convert-test-technique', + type: 'attack-pattern', + description: 'A technique for conversion tests.', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], + x_mitre_is_subtechnique: false, + x_mitre_platforms: ['Linux'], + }, +}; +``` + +In other words, while the ADM validates fully baked STIX objects that are ready for production use, the REST API also needs to validate draft STIX objects that are still being authored and may not yet be fully compliant with the ADM validation rules. + +Under the hood, the REST API tags the ADM/Zod schema with `.partial()` when the object is flagged as "work-in-progress". However, when we attempt to validate objects with the "awaiting-review" or "reviewed" workflow states, the ADM/Zod schemas are not tagged with `.partial()`, thereby deferring to the underlying ATT&CK & STIX validation rules. + +This presents an engineering challenge because it means that we cannot simply implement a synthetic data generator and expose it as an API in the `@mitre-attack/attack-data-model` library, as the validation rules for compliant STIX objects would differ based on the workflow state of the object. We would need to implement logic in the synthetic data generator to determine which fields are required based on the workflow state, and generate compliant STIX objects accordingly. This not only adds complexity to the implementation of the synthetic data generator, but it is arguably out of scope for the `@mitre-attack/attack-data-model` library, which is intended to be a general-purpose library for working with valid ATT&CK content represented in STIX. + +One option would be to implement the synthetic data generator in the REST API codebase instead of the `@mitre-attack/attack-data-model` library. This would allow us to tailor the generated STIX objects to be compliant with the specific validation rules of the REST API, including the handling of different workflow states. However, this approach would limit the reusability of the synthetic data generator, as it would be tightly coupled to the REST API's validation logic and may not be suitable for use in other contexts where different validation rules apply. + +Another option would be to implement the synthetic data generator in the `@mitre-attack/attack-data-model` library, but to wrap it with yet another API in the REST API codebase that applies the necessary logic to generate compliant STIX objects based on the workflow state. This would allow us to maintain the general-purpose nature of the synthetic data generator while still ensuring that it can be used to generate compliant STIX objects for our regression tests. However, this approach would add an additional layer of complexity to our codebase, as we would need to maintain both the synthetic data generator and the wrapper API in the REST API codebase, and ensure that they remain in sync with each other as we make updates and changes over time. + +The REST API does technically already have a utility function for dynamically yielding status-adjusted Zod schemas based on the workflow state of the object being validated: +``` +// in app/lib/validation-schemas.js +'use strict'; + +const { + tacticSchema, + + /** techniques */ + techniqueSchema, + techniquePartialSchema, + + /** groups */ + groupSchema, + groupPartialSchema, + + /** malware */ + malwareSchema, + malwarePartialSchema, + + /** tools */ + toolSchema, + toolPartialSchema, + + /** campaigns */ + campaignSchema, + campaignPartialSchema, + + /** relationships */ + relationshipSchema, + relationshipPartialSchema, + + /** simple schemas (no checks/refinements) */ + identitySchema, + mitigationSchema, + assetSchema, + dataSourceSchema, + dataComponentSchema, + detectionStrategySchema, + analyticSchema, + matrixSchema, + collectionSchema, + markingDefinitionSchema, +} = require('@mitre-attack/attack-data-model/dist/index.cjs'); + +// The ADM package exposes two validation shapes for several STIX types: +// - a full schema for normal validation +// - a prebuilt partial schema for draft/work-in-progress validation +// +// Workbench treats `work-in-progress` objects differently from objects in +// later workflow states. WIP objects are allowed to omit fields that are still +// being authored, while `awaiting-review` and `reviewed` objects should be +// held to the complete schema. +// +// We prefer the ADM-provided `*PartialSchema` exports when they exist rather +// than deriving them ourselves at call time. That keeps this layer aligned +// with however ADM composes partial validation for schemas that may include +// additional checks or refinements. +const STIX_SCHEMAS = { + 'x-mitre-tactic': tacticSchema, + 'attack-pattern': { + full: techniqueSchema, + partial: techniquePartialSchema, + }, + 'intrusion-set': { + full: groupSchema, + partial: groupPartialSchema, + }, + malware: { + full: malwareSchema, + partial: malwarePartialSchema, + }, + tool: { + full: toolSchema, + partial: toolPartialSchema, + }, + campaign: { + full: campaignSchema, + partial: campaignPartialSchema, + }, + relationship: { + full: relationshipSchema, + partial: relationshipPartialSchema, + }, + identity: identitySchema, + 'course-of-action': mitigationSchema, + 'marking-definition': markingDefinitionSchema, + 'x-mitre-asset': assetSchema, + 'x-mitre-data-source': dataSourceSchema, + 'x-mitre-data-component': dataComponentSchema, + 'x-mitre-detection-strategy': detectionStrategySchema, + 'x-mitre-analytic': analyticSchema, + 'x-mitre-matrix': matrixSchema, + 'x-mitre-collection': collectionSchema, +}; + +// Cache for locally-derived partial schemas. ADM does not export prebuilt +// partials for every STIX type; for those types we call `.partial()` ourselves. +// That call is expensive enough to show up in bulk-import profiles, so we +// memoize the result per STIX type. +const derivedPartialCache = new Map(); + +/** + * Get the schema to use for validating a STIX object. + * + * Some STIX types define both a full schema and a prebuilt partial schema, + * while others only define a single schema (no partial variant). This helper + * selects the correct schema based on the STIX type and workflow status. + * + * Determination rules: + * - `work-in-progress` uses partial validation so drafts can omit required fields + * - every other workflow state uses full validation + * - if ADM exports a dedicated partial schema, use it directly + * - otherwise, derive a partial schema locally with `.partial()` (memoized) + * + * @param {string} stixType - The STIX `type` being validated (e.g. "attack-pattern") + * @param {string} status - The workflow state (e.g. "work-in-progress", "awaiting-review", "reviewed") + * @returns {Object|null} Zod schema, or null if the STIX type is unknown + */ +function getSchema(stixType, status) { + const admSchemaRef = STIX_SCHEMAS[stixType]; + if (!admSchemaRef) return null; + + // Only draft objects get partial validation. Once an object leaves the + // work-in-progress state, we validate it against the full schema. + const isWip = status === 'work-in-progress'; + + if (admSchemaRef.full && admSchemaRef.partial) { + return isWip ? admSchemaRef.partial : admSchemaRef.full; + } + + if (!isWip) return admSchemaRef; + + let derived = derivedPartialCache.get(stixType); + if (!derived) { + derived = admSchemaRef.partial(); + derivedPartialCache.set(stixType, derived); + } + return derived; +} + +module.exports = { + STIX_SCHEMAS, + getSchema, +}; +``` + +One final option could be to use `getSchema()` to retrieve the appropriate Zod schema, and then use that schema to generate synthetic data using an established Zod data generation library. Zod officially advertises three libraries for mocking: + + +Name Stars Description +1. `@traversable/zod-test`: 157 GitHub Stars; Random zod schema generator built for fuzz testing; includes generators for both valid and invalid data +2. `zod-schema-faker`: 113 GitHub Stars; Generate mock data from zod schemas. Powered by @faker-js/faker and randexp.js. +3. `zocker`: 98 GitHub Stars; Generates valid, semantically meaningful data for your Zod schemas. + +I think this approach is worth exploring. Let's start by assessing the capabilities of these libraries to determine which one would be the best fit for our needs. + +The following markdown is the README for [raversable/zod-test](https://github.com/traversable/schema/tree/main/packages/zod-test): + + +
+

įÆ“š˜š—æš—®š˜ƒš—²š—æš˜€š—®š—Æš—¹š—²/š˜‡š—¼š—±-š˜š—²š˜€š˜

+
+ +

+ Testing utility that generates arbitrary, pseudorandom zod schemas, powered by fast-check +

+ +
+ NPM Version +   + TypeScript +   + License +   + npm +   +
+ +
+ + Static Badge +   + Static Badge +   + Static Badge +   +
+
+
+ +## Requirements + +`@traversable/zod-test` has 2 peer dependencies: + +1. [`zod`](https://zod.dev/) (v4) +2. [`fast-check`](https://fast-check.dev/) + +## Usage + +```bash +$ pnpm add -D @traversable/zod-test zod fast-check +``` + +Here's an example of importing the library: + +```typescript +import { z } from 'zod' +import { zxTest } from '@traversable/zod-test' + +// see below for specifc examples +``` + +## Track record + +`@traversabe/zod-test` has found several upstream bugs in `zod`: + +1. Security exploit: `z.object` pollutes the global `Object` prototype + - [Issue](https://github.com/colinhacks/zod/issues/4357) + - [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-ypelnmjv?file=test%2Frepro.test.ts&initialpath=__vitest__/) + +2. Bug: `z.literal` escaping bug + - [Issue](https://github.com/colinhacks/zod/issues/4894) + - [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-w1um2qny?file=test%2Frepro.test.ts&initialpath=__vitest__/) + +3. Bug: "Diagonal" objects passed to `z.enum` produce false negatives +- [Issue](https://github.com/colinhacks/zod/issues/4353) +- [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-srmahjsw?file=package.json,test%2Fenum.test.ts&initialpath=__vitest__/) + +4. Bug: `z.file` output type incompatible with `globalThis.File` + - [Issue](https://github.com/colinhacks/zod/issues/4973) + - [Sandbox](https://stackblitz.com/edit/zod-file-bug-repro?file=test%2Frepro.test.ts&initialpath=__vitest__/) + + +## Table of contents + +- [`zxTest.fuzz`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestfuzz) +- [`zxTest.seedToSchema`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoschema) +- [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata) +- [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata) +- [`zxTest.seedToValidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddatagenerator) +- [`zxTest.seedToInvalidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddatagenerator) +- [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator) +- [`zxTest.SeedValidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedvaliddatagenerator) +- [`zxTest.SeedInvalidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedinvaliddatagenerator) + + +### `zxTest.fuzz` + +Convert a Zod schema into a [fast-check](https://github.com/dubzzz/fast-check) arbitrary. + +Configure how fuzzed values will be generated via the 2nd argument (`options`). + +Override individual arbitraries via the 3rd argument (`overrides`). + +> [!NOTE] +> +> `zxTest.fuzz` is the __only__ schema-to-generator function that has itself +> been fuzz tested to ensure that no matter what schema you give it, the data-generator it +> returns will always produce valid data. +> +> This excludes schemas that make it impossible to generate valid data, for example: +> +> - `z.never` +> - `z.nonoptional(z.undefined())` +> - `z.enum([])` +> - `z.union([])` +> - `z.intersection(z.number(), z.string())` + +#### Example + +```typescript +import * as vi from 'vitest' +import * as fc from 'fast-check' +import { fuzz } from '@traversable/zod-test' + +const Schema = z.record( + z.string(), + z.union( + z.number(), + z.string(), + ) +) + +const generator = fuzz( + Schema, + { record: { minKeys: 1 }, number: { noDefaultInfinity: true } }, + { string: () => fc.stringMatching(/[\S\s]+[\S]+/) }, +) + +vi.test('fuzz test example', () => { + fc.assert( + fc.property(generator, (data) => { + vi.assert.doesNotThrow(() => Schema.parse(data)) + }), + { numRuns: 1_000 } + ) +}) +``` + +#### See also +- the [fast-check docs](https://fast-check.dev) + + +### `zxTest.seedToSchema` + +Use `zxTest.seedToSchema` to convert a seed generated by `zxTest.SeedGenerator` into a +zod schema that satisfies the configuration options you specified. + +#### Example + +```typescript +import { zxTest } from '@traversable/zod-test' +import * as fc from 'fast-check' + +const builder = zxTest.SeedGenerator()['*'] +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType +``` + + +### `zxTest.seedToValidData` + +Use `zxTest.seedToValidData` to convert a seed generated by `zxTest.SeedGenerator` into +data that satisfies the schema that the seed represents. + +#### Example + +```typescript +import { zxTest } from '@traversable/zod-test' +import * as fc from 'fast-check' + +const builder = zxTest.SeedGenerator()['*'] +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType + +const validData = zxTest.seedToValidData(mySeed) + +mySchema.parse(validData) // will never throw +``` + + +### `zxTest.seedToInvalidData` + +Use `zxTest.seedToInvalidData` to convert a seed generated by `zxTest.SeedGenerator` into +data that does **not** satisfy the schema that the seed represents. + +#### Example + +```typescript +import { zxTest } from '@traversable/zod-test' +import * as fc from 'fast-check' + +const builder = zxTest.SeedGenerator()['*'] +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType + +const invalidData = zxTest.seedToValidData(mySeed) + +mySchema.parse(invalidData) // should always throw +``` + + +### `zxTest.seedToValidDataGenerator` + +Like [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata), except `zxTest.seedToValidDataGenerator` accepts a seed and returns a valid data arbitrary (which can then be used to produce valid data). + +#### Example + +```typescript +import { zxTest } from '@traversable/zod-test' +import * as fc from 'fast-check' + +const builder = zxTest.SeedGenerator()['*'] +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType + +const validDataGenerator = zxTest.seedToValidDataGenerator(mySeed) +const [validData] = fc.sample(validDataGenerator, 1) + +mySchema.parse(validData) // will never throw +``` + + +### `zxTest.seedToInvalidDataGenerator` + +Like [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata), except `zxTest.seedToValidDataGenerator` accepts a seed and returns an invalid data arbitrary (which can then be used to produce invalid data). + +#### Example + +```typescript +import type * as z from 'zod' +import * as fc from 'fast-check' +import { zxTest } from '@traversable/zod-test' + +const builder = zxTest.SeedGenerator()['*'] +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType + +const invalidDataGenerator = zxTest.seedToInvalidDataGenerator(mySeed) +const [invalidData] = fc.sample(invalidDataGenerator, 1) + +mySchema.parse(invalidData) // will always throw +``` + + +### `zxTest.SeedGenerator` + +> [!NOTE] +> +> `zxTest.SeedGenerator` is fairly low-level. All of the other exports of this library have been implemented in terms of `zxTest.SeedGenerator`. + +Generates a configurable, pseudo-random "seed builder". + +- Use [`zxTest.seedToSchema`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoschema) to convert a seed into a zod schema +- Use [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata) to convert a seed into valid data +- Use [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata) to convert a seed into invalid data + +#### Example + +```typescript +import { zxTest } from '@traversable/zod-test' +import * as fc from 'fast-check' + +const builder = zxTest.SeedGenerator({ + include: ["boolean", "string", "object"], + // š™˜ use `include` to only include certain schema types + exclude: ["boolean", "any"], + // š™˜ use `exclude` to exclude certain schema types altogether (overrides `include`) + object: { maxKeys: 5 }, + // š™˜ specific arbitraries are configurable by name +}) + +// included schemas are present as properties on your generator... +builder.string +builder.object + +// ...excluded schemas are not present... +builder.boolean // 🚫 TypeError + +// ...a special wildcard `"*"` property (pronounced "surprise me") is always present: +builder["*"] + +/** + * `fast-check` will generate a seed, which is a data structure containing + * integers that represent a kind of AST. + * + * To use a seed, you need to pass it to an interpreter like `zxTest.seedToSchema`, + * `zxTest.seedToValidData` or `zxTest.seedToInvalidData`: + */ + +const [mySeed] = fc.sample(builder.object, 1) + +const mySchema = zxTest.seedToSchema(mySeed) +// ^? const mySchema: z.ZodType + +const validData = zxTest.seedToValidData(mySeed) +// ^? since the `mySeed` was also used to generate `mySchema`, +// parsing `validData` should always succeed + +const invalidData = zxTest.seedToInvalidData(mySeed) +// ^? since the `mySeed` was also used to generate `mySchema`, +// parsing `invalidData` should always fail +``` + + +### `zxTest.SeedValidDataGenerator` + +Like [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator), except `zxTest.SeedValidDataGenerator` comes pre-configured to exclude schemas that make it impossible to reliably generate valid data. + +> [!NOTE] +> +> `zxTest.SeedValidDataGenerator` does not accept any options. If you need more fine-grained control of the schemas being generated, use [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator). + + + +### `zxTest.SeedInvalidDataGenerator` + +Like [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator), except `zxTest.SeedValidDataGenerator` comes pre-configured to exclude schemas that make it impossible to reliably generate invalid data. + +> [!NOTE] +> +> `zxTest.SeedInvalidDataGenerator` does not accept any options. If you need more fine-grained control of the schemas being generated, use [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator). +
+ +The second option is [Zocker](https://zocker.sigrist.dev/?id=zocker). I have personally tested this one and verified basic functionality seems to work: + +```typescript +import { zocker } from "zocker"; +import { z } from 'zod/v4'; + +// Manually create a compatible schema based on the structure +const compatibleSchema = z.object({ + id: z.string(), + type: z.enum([ + "attack-pattern", + "bundle", + "campaign", + "course-of-action", + "extension-definition", + "identity", + "intrusion-set", + "malware", + "tool", + "marking-definition", + "x-mitre-analytic", + "x-mitre-data-component", + "x-mitre-detection-strategy", + "x-mitre-tactic", + "x-mitre-asset", + "x-mitre-data-source", + "x-mitre-log-source", + "x-mitre-matrix", + "x-mitre-collection", + "relationship", + "file", + "artifact" + ]), + spec_version: z.enum(["2.0", "2.1"]), + created: z.string(), // Should be a timestamp + modified: z.string(), // Should be a timestamp + created_by_ref: z.string().optional(), + labels: z.array(z.string()).optional(), + revoked: z.boolean().optional(), + confidence: z.int().min(1).max(99).optional(), + lang: z.string().optional(), + external_references: z.array(z.object({ + source_name: z.string(), + description: z.string().optional(), + url: z.url().optional(), + external_id: z.string().optional() + })).optional(), + object_marking_refs: z.array(z.string()).optional(), + granular_markings: z.array(z.object({ + marking_ref: z.string(), + selectors: z.array(z.string()) + })).optional(), + extensions: z.record(z.string(), z.unknown()).optional() +}).strict(); + +const out = zocker(compatibleSchema).generate(); +console.log(out); +``` + +The last option is [zod-schema-faker](https://github.com/soc221b/zod-schema-faker). I have not personally tested this one, but based on the documentation it appears to be a straightforward library for generating mock data from Zod schemas. It is powered by `@faker-js/faker` and `randexp.js`, which are both well-known libraries for generating fake data and regular expression-based data, respectively. + +Please check out the documentation for all three libraries and let me know which one you think would be the best fit for our needs. + +Afterwards, please familiarize yourself with the implementation of the `getSchema()` function in `app/lib/validation-schemas.js`, as well as the structure of the STIX objects being used in our regression tests, so that you can design a synthetic data generator that can produce compliant STIX objects based on the appropriate Zod schemas and workflow states. \ No newline at end of file diff --git a/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md b/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md new file mode 100644 index 00000000..fd494563 --- /dev/null +++ b/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md @@ -0,0 +1,23 @@ +Historically, the REST API treated all STIX fields as opaque, meaning that users could set, unset, or modify any field on a STIX object using a generic `create` or `update` endpoint, and the API would simply persist those changes without any special handling. This approach worked well for most fields, but it caused issues with certain server-controlled fields that have specific semantics or constraints, such as `created`, `modified`, and `revoked`. + +There exists a particular problem with the `x_mitre_is_subtechnique` field on Technique objects. This field is intended to be a boolean that indicates whether a Technique is a sub-technique (i.e., it has a parent technique) or not. However, since the REST API treated all fields as opaque, users could set this field to `true` or `false` arbitrarily, which could lead to inconsistencies in the data. For example, a user could create a Technique with `x_mitre_is_subtechnique` set to `true`, but without providing a valid parent technique reference, which would violate the intended semantics of the field. + +Moreover, there exists a conflict between two functionalities: + +1. The REST API blocks users from directly setting the ATT&CK ID (`workspace.attack_id`, `external_references[0].external_id`) on Technique objects, as these fields are meant to be generated by the server. +2. The REST API allows users to set the `x_mitre_is_subtechnique` field on Techniqe objects. + +What can happen is the folowing: + +1. A user creates a Technique object and sets `x_mitre_is_subtechnique` to `true`. The REST API accepts this and sets/generates the ATT&CK ID fields as usual. +2. The user then realizes that the Technique is actually a sub-technique and tries to update `x_mitre_is_subtechnique` to `false` using the `create` or `update` endpoint. The REST API accepts this change and updates the object, but it does not update the ATT&CK ID fields, which remain as if the object were a regular Technique. + +Essentially, once a Technique is created, its ATT&CK ID fields are sticky and do not change based on updates to the `x_mitre_is_subtechnique` field, which can lead to inconsistencies in the data and confusion for users. + +Viable solutions to this problem include: + +1. Adjust the `create` and `update` endpoints for Technique objects to include special handling for the `x_mitre_is_subtechnique` field. If the initial object state is being created or updated with `x_mitre_is_subtechnique` set to `true`, the API should generate the ATT&CK ID fields as if it were a regular Technique. If the object is being updated and the `x_mitre_is_subtechnique` field is changing from `true` to `false`, the API should either prevent this change or update the ATT&CK ID fields accordingly to reflect that it is no longer a sub-technique. + +2. Implement a new, dedicated endpoint for converting a Technique to a sub-technique or vice versa. This endpoint would handle all the necessary logic for updating the `x_mitre_is_subtechnique` field and ensuring that the ATT&CK ID fields are consistent with the new state of the object. This approach would provide a clear and explicit way for users to perform this conversion, while also allowing the API to maintain data integrity. + +Please reason about these options and determine the best course of action for addressing the issue with the `x_mitre_is_subtechnique` field and the ATT&CK ID fields on Technique objects. Consider factors such as user experience, data integrity, and maintainability when making your decision. \ No newline at end of file diff --git a/.nocommit/prompts/technique-sub-parent-conversion-workflow.md b/.nocommit/prompts/technique-sub-parent-conversion-workflow.md new file mode 100644 index 00000000..e7d0cd58 --- /dev/null +++ b/.nocommit/prompts/technique-sub-parent-conversion-workflow.md @@ -0,0 +1,19 @@ +Let's plan a workflow to handle converting techniques to and from sub-technique status. + +Before we get started, let's level-set on some terminology: +- I refer to "standard" techniques as "parents", irrespective of whether they actually contain a sub-technique or not. These are any technique whose `x_mitre_is_subtechnique` field is equal to `False` +- I refer to sub-techniques as "subs" or "children". These are any technique whose `x_mitre_is_subtechnique` field is equal to `True` + +Currently, the backend/REST API treats toggling this field as just like any other STIX modification: it will happily accept a POST or PUT request where the value of `x_mitre_is_subtechnique` flips in either direction (False<-->True). There are no built-in guards to protect against the user from putting the data into an invalid state (described below). We rely solely on the frontend SPA to handle orchestrating and blocking conversion requests which would put the data into an invalid state. + +So what constitutes an invalid state? Consider the following: + +If a parent (let's call it $parent_A$) contains one or more subs, then the subs must be rehomed or converted to parents themselves before $parent_A$ can be converted to a sub. Otherwise the end state will contain subs that don't have a parent. We informally refer to these as "orphans" and they are not permissible in final STIX bundle outputs. + +Here are some potential solutions that I am considering. Notably, none of them are mutually exclusive: + +1. We can introduce backend guardrails to block requests that would result in orphans being created. +2. We can introduce a new query parameter that allows users to override the aforementioned guardrail, e.g., `permitOrphans: bool`. +3. We can introduce a backend-driven "parent-to-sub conversion" workflow that allows users to bulk-edit all of $parent_A$'s subs as part of the conversion operation, thereby giving the REST API all of the information needed to reach a valid end state without creating orphans. In such a workflow, the user could specify, for each sub, whether to re-home the sub to a new parent or convert it to a parent itself. +4. Optionally, we can build in more tolerances to support "invalid states". Theoretically, it should be fine as long as the "orphans" are tagged/marked accordingly and easy to query. For example, we could introduce a new metadata field like `workspace.is_orphaned: bool` and add a new query parameter to allow users to retrieve orphans, e.g., `GET /api/techniques?include={parent,sub,orphan,all}`, `GET /api/techniques?includeOrphans=true`, etc. +5. In addition, we need to consider the "sub-to-parent conversion" workflow. I believe the only dimension to consider here is the sub's ATT&CK ID. It will need to be assigned a new ATT&CK ID without the sub-technique suffix. Thus, we will need to add support for regenerating a new ATT&CK ID specifically for subs that are being converted to parents. \ No newline at end of file diff --git a/.nocommit/prompts/validation-bypass-rules.md b/.nocommit/prompts/validation-bypass-rules.md new file mode 100644 index 00000000..e08b2aaf --- /dev/null +++ b/.nocommit/prompts/validation-bypass-rules.md @@ -0,0 +1,424 @@ +Feedback + +I don't mind amending the validation bypass rule model to accommdate distinguishing between different types of trigger events. i.e., I don't mind the spirit of adding `autoCreatedReason`. However, the approach seems under developed. If we stick with this approach, we should constrain the possible values of `autoCreatedReason` to a specific set of strings that are defined in a central location, and we should also add a `triggerEvent` field that specifies the event that triggered the bypass rule to be created. This way, we can easily query bypass rules based on the trigger event and reason, and we can also ensure consistency in the values of `autoCreatedReason`. + +Creating an entirely new service to handle the propagation of identity changes seems like overkill. Since the propogation service will rely on the `AttackObject` model/repository to make updates to objects, it seems like we could just add methods to the `AttackObjectService` to handle the propogation logic. We could then call these methods from the `IdentitiesService` when an identity is created, updated, or deleted. The `AttackObjectsService` would just listen for the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event and then execute the necessary updates to attack objects based on the identity changes. + +Rather than add `organization_identity_history` to the one system configuration entity/document, I think it would be better to create new system configuration document for each organization identity change. This way, we can maintain a clear history of all system configuration changes over time, and we can also easily query this history if needed. Each time the system configuration is changed, we would create a new system configuration document with the new updated values and a timestamp (which we can track via a new `created_at` field). This approach aligns better with the way we version control other types of entities in the system. + +Currently, system configuration documents look like the following: + +```json +{ + "_id": { + "$oid": "6969691cd1aed3fbdf8f8007" + }, + "organization_identity_ref": "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", + "default_marking_definitions": [], + "organization_namespace": { + "range_start": 0, + "prefix": "SEAN" + }, + "__v": 0, + "anonymous_user_account_id": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" +} +``` + +Thus, we'll need to edit the workflows for creating/updating organization identities (which maps to the `organization_identity_ref` field in the system configuration), as well as the workflows for editing the namespace prefix (which maps to the `organization_namespace.prefix` field in the system configuration). Each time either of these fields is updated, we would create a new system configuration document with the updated values and a timestamp. This way, we can maintain a clear history of all changes to the organization identity and namespace prefix over time. + +--- + +This draft looks good overall. I just want to clarify an important point about the propagation of identity changes: + +1. An object that has been imported into Workbench but not edited yet: In this case, we expect `created_by_ref` and `x_mitre_modified_by_ref` to both point to an external identity that is not part of the provenance chain. In this case, we would NOT update either field, because the object was created externally and has not been modified by any internal identities. +2. An object that has been created within Workbench and has been edited by internal identities: In this case, we expect `created_by_ref` to point to an external identity that is not part of the provenance chain, and we expect `x_mitre_modified_by_ref` to point to one or more internal identities that are part of the provenance chain. In this case, we would update `x_mitre_modified_by_ref` values to point to the organization identity, but we would NOT update `created_by_ref`, because the object was created externally. +3. An object that was created within Workbench: In this case, we expect both `created_by_ref` and `x_mitre_modified_by_ref` to point to one or more internal identities that are part of the provenance chain. In this case, we would update both `created_by_ref` and `x_mitre_modified_by_ref` values to point to the organization identity, because the object was created and modified by internal identities. + +--- + +Observed issues from looking through the code changes: + +1. For the change to the `systemConfigurationSchema`, we need to add a startup script that will backfill existing system configuration documents with the new `created_at` field. This script will need to query all existing system configuration documents and add a `created_at` field with the current timestamp to each document. This way, we can ensure that all system configuration documents have a `created_at` field, which will allow us to maintain a clear history of changes over time. Luckily, we already have a workflow for running startup scripts: see `www/bin` calling `migrateDatabase` for details. + +2. In the `system-configuration-controller.js` module, we should edit all endpoints to pass `next` so we can stop handling each service layer exception manually and instead return `next(err)` and let the global error handler take care of it. See any of the other controllers for examples of this pattern. + +3. In `attack-objects-service.js`, you imported the `AttackObject` Mongoose model. This violates the separation of concerns between the service layer and the repository layer. The service layer should not be directly importing Mongoose models, as this creates tight coupling between the layers and makes it harder to maintain and test the code. Instead, the service layer should only interact with the repository layer, which is responsible for managing the Mongoose models and database interactions. + +The integration test results from `npm test` are in `npm_test_output.txt`. The 777 core tests are passing, but there are 9 failing tests in `adm-validation-middleware.spec.js`: +``` + 8 passing (1s) + 9 failing + + 1) ADM Validation Middleware + POST operations - work-in-progress (partial validation) + should accept valid complete data in work-in-progress state: + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 201 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:184:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 2) ADM Validation Middleware + POST operations - work-in-progress (partial validation) + should accept partial data in work-in-progress state (missing optional fields): + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 201 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:217:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 3) ADM Validation Middleware + POST operations - reviewed (full validation) + should accept valid complete data in reviewed state: + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 201 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:273:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 4) ADM Validation Middleware + PUT operations - work-in-progress (partial validation) + "before each" hook for "should accept valid updates in work-in-progress state": + Error: expected 201 "Created", got 400 "Bad Request" + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:388:10) + at process.processImmediate (node:internal/timers:491:21) + ---- + at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) + at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 + at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) + at Test.assert (node_modules/supertest/lib/test.js:195:23) + at localAssert (node_modules/supertest/lib/test.js:138:14) + at Server. (node_modules/supertest/lib/test.js:152:11) + at Object.onceWrapper (node:events:632:28) + at Server.emit (node:events:518:28) + at emitCloseNT (node:net:2416:8) + at process.processTicksAndRejections (node:internal/process/task_queues:89:21) + + 5) ADM Validation Middleware + PUT operations - reviewed (full validation) + "before each" hook for "should accept valid complete updates in reviewed state": + Error: expected 201 "Created", got 400 "Bad Request" + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:516:10) + at process.processImmediate (node:internal/timers:491:21) + ---- + at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) + at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 + at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) + at Test.assert (node_modules/supertest/lib/test.js:195:23) + at localAssert (node_modules/supertest/lib/test.js:138:14) + at Server. (node_modules/supertest/lib/test.js:152:11) + at Object.onceWrapper (node:events:632:28) + at Server.emit (node:events:518:28) + at emitCloseNT (node:net:2416:8) + at process.processTicksAndRejections (node:internal/process/task_queues:89:21) + + 6) ADM Validation Middleware + Server-controlled field stripping + should silently strip x_mitre_attack_spec_version from client input: + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 201 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:673:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 7) ADM Validation Middleware + Server-controlled field stripping + should silently strip ATT&CK external references from client input: + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 201 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:705:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 8) ADM Validation Middleware + dryRun support + should return composed object without persisting on POST with dryRun=true: + Error: expect(received).toBe(expected) // Object.is equality + +Expected: 200 +Received: 400 + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:739:26) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + + 9) ADM Validation Middleware + dryRun support + should return composed object without persisting on PUT with dryRun=true: + Error: expected 201 "Created", got 400 "Bad Request" + at Context. (app/tests/middleware/adm-validation-middleware.spec.js:798:10) + at process.processImmediate (node:internal/timers:491:21) + ---- + at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) + at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 + at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) + at Test.assert (node_modules/supertest/lib/test.js:195:23) + at localAssert (node_modules/supertest/lib/test.js:138:14) + at Server. (node_modules/supertest/lib/test.js:152:11) + at Object.onceWrapper (node:events:632:28) + at Server.emit (node:events:518:28) + at emitCloseNT (node:net:2416:8) + at process.processTicksAndRejections (node:internal/process/task_queues:89:21) +``` + +--- + +The tests are failing because the validation bypass rules that are needed to allow the test objects to be created/updated without `x_mitre_modified_by_ref` values are not being created. Here's an example of one of the validation errors that occurs in one of the tests: +``` +{ + success: false, + error: { + name: "ZodError", + message: `[ + { + "code": "invalid_value", + "values": [ + "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5" + ], + "path": [ + "x_mitre_modified_by_ref" + ], + "message": "Invalid input: expected \\"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\\"" + } +]`, + }, +} +``` + +This is occurring because the necessary bypass rules are not being created at startup when the placeholder organization identity is created. + +The app starts via `bin/www`. In this startup script, we run `await databaseConfiguration.checkSystemConfiguration()`, which in turn calls `checkForOrganizationIdentity`; that then calls `createPlaceholderOrganizationIdentity()`. This is where we should add logic to create the necessary validation bypass rules for `x_mitre_modified_by_ref`. All we need to do is emit the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event from `createPlaceholderOrganizationIdentity()`; that will trigger the `ValidationBypassesService` to create the necessary bypass rules for `x_mitre_modified_by_ref` validation. + +This should ensure that the necessary bypass rules are always in place whenever the app is started, which will allow the tests to pass successfully. + +--- + +I don't think this fix resolves the issue and here's why: When the application starts up, it runs `await databaseConfiguration.checkSystemConfiguration()`. +This is turn calls `createPlaceholderOrganizationIdentity()`, which calls `systemConfigurationService.setOrganizationIdentity()`. +`SystemConfigurationService.setOrganizationIdentity()` only emits the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event if there is a change in the organization identity. However, when the app starts up for the first time and creates the placeholder organization identity, there is no "change" per se, because there was no existing organization identity before. Thus, the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event is not emitted during startup, and the validation bypass rules are not created. I verified this in the debugger by putting a breakpoint in the `setOrganizationIdentity()` method and observed that `currentConfig` is `null` when the placeholder identity is created, so the condition to emit the event is not met. + +--- + +The issue is persisting because there are no listeners for the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event at the time when the placeholder organization identity is created during startup. The listeners for this event are registered in the `ValidationBypassesService`, which is initialized in the `services/index.js` module. However, the `checkSystemConfiguration()` function (which calls `createPlaceholderOrganizationIdentity()`) is executed before the services are initialized in the startup sequence. + +```javascript +// in bin/www + +// Check for valid database configuration +const databaseConfiguration = require('../app/lib/database-configuration'); +await databaseConfiguration.checkSystemConfiguration(); + +// Create the app +const app = await require('../app').initializeApp(); // <-- services, including event listeners, are initialized in this step +``` + +Thus, when the placeholder organization identity is created and the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event is emitted, there are no listeners registered to handle that event, so the validation bypass rules are not created. + + +--- + +All tests passing. +Summary of changes: + +feat: version system config, validate org identity, and propagate identity changes to objects + +- Refactor system-configuration-controller to use \`next\` for error handling; defer to global error handler instead of handling service layer exceptions manually in the controller. +- Add new \`app/lib/bypass-rule-constants.js\` module to define constants for validation bypass rule reasons and trigger events. +- Update \`app/lib/database-configuration.js\` to load the \`ValidationBypassesService\` before calling \`checkSystemConfiguration()\`. This is critical to ensure that the event listeners for \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` are registered before we attempt to emit that event during the creation of the placeholder organization identity. This way, when the placeholder identity is created and the event is emitted, the listeners will be in place to create the necessary validation bypass rules for \`x_mitre_modified_by_ref\`, which will allow the tests to pass successfully. +- Add new event type, \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\`, to \`app/lib/event-constants.js\`. +- Add new field, \`created_at\`, to \`systemConfigurationSchema\` in \`app/models/system-configuration.js\` to track when each system configuration document is created. This will allow us to maintain a clear history of changes to the system configuration over time. +- Add new field, \`autoCreatedReason\`, to the validation bypass rule model in \`app/models/validation-bypass-rule.js\` to track the reason why a validation bypass rule was automatically created. This will allow us to easily identify and manage validation bypass rules that were created due to identity changes. +- Add new repository method, \`AttackObjectsRepository.retrieveAllLatestByOrgIdentityRefs()\`, to retrieve all latest attack objects that have \`created_by_ref\` or \`x_mitre_modified_by_ref\` values matching any of the provided organization identity refs. This will allow us to easily find all attack objects that are associated with any of the organization identities in the history of organization identity changes. +- Add new repository method, \`SystemConfigurationRepository.retrieveAllDistinctIdentityRefs()\`, to retrieve a list of all distinct organization identity refs that are currently referenced in any system configuration documents. This will allow us to easily track all organization identities that have been used in the system configuration over time. +- Modify \`SystemConfigurationRepository.retrieveOne\` to retrieve the latest system configuration document based on the \`created_at\` timestamp, rather than just retrieving a single document without any sorting. This will ensure that we always get the most recent system configuration, which is important now that we are creating a new system configuration document each time there is a change to the organization identity or namespace prefix. +- Add new repository method, \`ValidationBypassesRepository.deleteByReason\`, to delete validation bypass rules based on the reason they were created. This will allow us to easily clean up old validation bypass rules that were created due to previous identity changes when a new identity change occurs. +- Add new event handler to \`AttackObjectsService\` to handle the \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` event. This handler will retrieve all attack objects that are associated with any of the organization identities in the history of identity changes, and it will update their \`created_by_ref\` and \`x_mitre_modified_by_ref\` values to point to the new organization identity as appropriate based on the rules outlined in the implementation plan. This will ensure that all attack objects are properly updated to reflect the new organization identity whenever an identity change occurs. +- Refactor \`system-configuration-service.js\` to create a new system configuration document each time there is a change to the organization identity or namespace prefix, rather than updating an existing document. This will allow us to maintain a clear history of all changes to the system configuration over time, and it will also allow us to easily query this history if needed. Each time there is a change, we will create a new system configuration document with the updated values and a \`created_at\` timestamp. This way, we can track the evolution of the system configuration over time and have a clear record of when changes occurred. +- Add event hanlder to \`validation-bypasses-service.js\` to handle the \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` event. This handler will create new validation bypass rules for \`x_mitre_modified_by_ref\` based on the new organization identity, and it will also clean up old validation bypass rules that were created due to previous identity changes. This will ensure that the necessary validation bypass rules are always in place whenever an identity change occurs, which will allow the tests to pass successfully and will also ensure that the system continues to function properly after identity changes. +- Add startup migration script to backfill existing system configuration documents with the new \`created_at\` field. This script will query all existing system configuration documents and add a \`created_at\` field with the current timestamp to each document. This way, we can ensure that all system configuration documents have a \`created_at\` field, which will allow us to maintain a clear history of changes over time. This script can be run as part of the existing database migration workflow that is executed at startup. + +Please provide a conventional commit message for this PR that summarizes the list above. I plan to include the list above in the PR description, so the commit message can be a concise summary of the changes. + + +--- + +Findings: + +Tactics: +Frontend: +```txt +x_mitre_shortname: x_mitre_shortname is Invalid option: expected one of "credential-access"|"execution"|"impact"|"persistence"|"privilege-escalation"|"lateral-movement"|"defense-evasion"|"exfiltration"|"discovery"|"collection"|"resource-development"|"reconnaissance"|"command-and-control"|"initial-access"|"inhibit-response-function"|"evasion"|"impair-process-control"|"network-effects"|"remote-service-effects" +``` +dryRun res.body: +```json +{ + "message": "ADM validation failed", + "details": [ + { + "message": "x_mitre_shortname is Invalid option: expected one of \"credential-access\"|\"execution\"|\"impact\"|\"persistence\"|\"privilege-escalation\"|\"lateral-movement\"|\"defense-evasion\"|\"exfiltration\"|\"discovery\"|\"collection\"|\"resource-development\"|\"reconnaissance\"|\"command-and-control\"|\"initial-access\"|\"inhibit-response-function\"|\"evasion\"|\"impair-process-control\"|\"network-effects\"|\"remote-service-effects\"", + "path": [ + "x_mitre_shortname" + ], + "code": "invalid_value" + } + ], + "warnings": [] +} +``` + +Possible solutions: +1. By default, run a migration script that preloads the database with the necessary validation bypass rules for `x_mitre_tactic_shortname` validation. +2. Add an option to load validation bypass rules from a JSON file at startup, and provide a default JSON file that includes the necessary bypass rules for `x_mitre_tactic_shortname` validation. +3. Nothing -- explicitly require teams add (POST) the necessary validation bypass rules for `x_mitre_tactic_shortname` validation as part of their setup process. We can provide documentation and examples to guide them through this process. + +When namespace prefix changes, we need to propagate to all objects with that prefix that originated from the current Workbench deployment. + + +--- + +Notably, tactic creation will break if you set a non-compliant name because I reversed the rule that converts the ZodError to a warning — so you'll see something like this in the dryRun response: + +{ + "message": "ADM validation failed", + "details": [ + { + "message": "x_mitre_shortname is Invalid option: expected one of \"credential-access\"|\"execution\"|\"impact\"|\"persistence\"|\"privilege-escalation\"|\"lateral-movement\"|\"defense-evasion\"|\"exfiltration\"|\"discovery\"|\"collection\"|\"resource-development\"|\"reconnaissance\"|\"command-and-control\"|\"initial-access\"|\"inhibit-response-function\"|\"evasion\"|\"impair-process-control\"|\"network-effects\"|\"remote-service-effects\"", + "path": [ + "x_mitre_shortname" + ], + "code": "invalid_value" + } + ], + "warnings": [] +} + +Possible solutions: +1. By default, run a migration script that preloads the database with the necessary validation bypass rules for x_mitre_tactic_shortname validation. +2. Add an option to load validation bypass rules from a JSON file at startup, and provide a default JSON file that includes the necessary bypass rules for x_mitre_tactic_shortname validation. +3. Do nothing — explicitly require teams add (POST) the necessary validation bypass rules for x_mitre_tactic_shortname validation as part of their setup process. We can provide documentation and examples to guide them through this process. +4. Tangential to this — we can/should build out a frontend CRUD interface for managing bypass rules + +There are additional rules that were inadvertently left out of the original list of bypass rules that we will need to address: +```javascript +const ERROR_TRANSFORMATION_RULES = [ + // Server always sets x_mitre_attack_spec_version + { + fieldPath: ['x_mitre_attack_spec_version'], + errorCode: 'invalid_type', + stixType: 'all', + suppressError: true, + }, + // Server sets x_mitre_modified_by_ref based on authenticated user - user does not need to supply it + { + fieldPath: ['x_mitre_modified_by_ref'], + errorCode: 'invalid_value', + stixType: 'all', + suppressError: true, + }, + // Warn about non-standard tactic shortnames + { + fieldPath: ['x_mitre_shortname'], + errorCode: 'invalid_value', + stixType: 'x-mitre-tactic', + warningMessage: + 'Tactic shortname does not match predefined ATT&CK tactics. This may prevent compatibility with official ATT&CK data but can be used for custom taxonomies.', + }, + // Server sets x_mitre_domains for certain types (assigned during bundle export) + { + fieldPath: ['x_mitre_domains'], + errorCode: 'invalid_type', + stixType: ['intrusion-set', 'campaign', 'x-mitre-matrix', 'x-mitre-detection-strategy'], + suppressError: true, + }, + // Server sets object_marking_refs for certain types + { + fieldPath: ['object_marking_refs'], + errorCode: 'invalid_type', + stixType: ['campaign', 'identity'], + suppressError: true, + }, + // Server sets created_by_ref for certain types + { + // catch Zod error pertaining to field: + fieldPath: ['created_by_ref'], + // catch Zod error type: + errorCode: 'invalid_type', + // applicable to: + stixType: ['campaign', 'x-mitre-matrix', 'x-mitre-asset', 'course-of-action'], + suppressError: true, + }, +]; +``` + +Here's an example of how to interpret the above rules: +```json +{ + // catch Zod error pertaining to field: + "fieldPath": ['created_by_ref'], + // catch Zod error type: + "errorCode": 'invalid_type', + // applicable to: + "stixType": ['campaign', 'x-mitre-matrix', 'x-mitre-asset', 'course-of-action'], + "suppressError": true, +}, +``` + +Please build out the necessary functionality to load bypass rules via JSON file at startup. Create a default JSON file that addresses the above rules. Add an environment variable that allows teams to specify a custom JSON file if they want to override the default path. This way, we can ensure that the necessary bypass rules are always in place for the above scenarios, which will allow the tests to pass successfully and will also ensure that the system continues to function properly in these scenarios. + +--- + +When I create (via `POST /api/campaigns`) a new campaign in the `awaiting-review` status, it unexpectedly fails validation with the following errors: +```json +{"workspace":{"workflow":{"state":"awaiting-review"}},"stix":{"type":"campaign","id":"campaign--920c66ad-eff2-40f2-904e-6700b53a23eb","created":"2026-04-07T17:40:11.145Z","modified":"2026-04-07T17:40:11.145Z","x_mitre_version":"0.1","x_mitre_deprecated":false,"revoked":false,"spec_version":"2.1","name":"campane","first_seen":"2003-04-01T05:00:00.000Z","last_seen":"2026-04-01T04:00:00.000Z","x_mitre_first_seen_citation":"(Citation: Sean)","x_mitre_last_seen_citation":"(Citation: Sica)","aliases":["placeholder"]}} +``` +Response: +```json +{ + "message": "ADM validation failed", + "details": [ + { + "message": "revoked is Invalid input: expected nonoptional, received undefined", + "path": [ + "revoked" + ], + "code": "invalid_type" + }, + { + "message": "description is Invalid input: expected string, received undefined", + "path": [ + "description" + ], + "code": "invalid_type" + } + ], + "warnings": [] +} +``` +The error related to `description` is expected. No issues there. +But the error related to `related` is NOT expected. The request is clearly setting `revoked` to `false`, so why does the ADM validation think it's `undefined`? + +--- + +I changed a bunch of Mongoose model properties from `[String]` to `{ type: [String], default: undefined }` to protect from writing empty arrays to entities (Mongo documents) because that would cause response bodies to contain empty list properties, which would be a violation of the STIX 2.1 specification, which states that lists cannot be empty. + +Example: +``` +// before +x_mitre_aliases: [String], +// after +x_mitre_aliases: { type: [String], default: undefined } +``` + +Please provide a database migration script in the `migrations/` folder that target the models which are staged in git to bring the database into harmony with the updated models. + +app/models/campaign-model.js +app/models/data-source-model.js +app/models/detection-strategy-model.js +app/models/group-model.js +app/models/identity-model.js +app/models/matrix-model.js +app/models/software-model.js +app/models/tactic-model.js \ No newline at end of file diff --git a/.nocommit/prompts/validation-bypasses-impl-plan.md b/.nocommit/prompts/validation-bypasses-impl-plan.md new file mode 100644 index 00000000..15f8bc0c --- /dev/null +++ b/.nocommit/prompts/validation-bypasses-impl-plan.md @@ -0,0 +1,165 @@ +# Plan: Database-Backed Validation Bypass Rules + +## Context + +When an organization namespace is configured (e.g., prefix "FOOBAR", range 1000), the backend generates ATT&CK IDs like "FOOBAR-T1000". The ADM Zod schemas strictly enforce the standard format (e.g., "T####") and cannot be modified. This causes validation failures: + +``` +external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.### +``` + +**Goal**: Create a system that (1) provides CRUD for stateful validation bypass rules, (2) auto-manages namespace-related bypass rules, and (3) exposes a function `isErrorBypassed(error, stixType) → boolean` for callers to use. + +As part of this effort, we also remove the legacy `validate-service.js`, `validate-controller.js`, `validate-routes.js`, `validate-paths.yml`, and `ERROR_TRANSFORMATION_RULES` from `validation-schemas.js`. + +--- + +## New Files to Create + +### 1. Mongoose Model — `app/models/validation-bypass-rule-model.js` +Pattern: `app/models/reference-model.js` (standalone `mongoose.model()`) + +Fields: +- `fieldPath`: `[String]`, required — e.g., `["external_references", "0", "external_id"]` +- `errorCode`: `String`, required — e.g., `"custom"` +- `stixType`: `String`, required — e.g., `"attack-pattern"` or `"all"` +- `suppressError`: `Boolean`, default `true` +- `autoCreated`: `Boolean`, default `false` — marks entries auto-generated by namespace setup + +Compound unique index on `{ fieldPath, errorCode, stixType }`. + +### 2. Repository — `app/repository/validation-bypasses-repository.js` +Pattern: `app/repository/references-repository.js` + +Methods: +- `retrieveAll(options)` — aggregation with pagination, returns `[{ totalCount, documents }]` +- `save(data)` — create, handle duplicate key (11000) +- `retrieveById(id)` — `findById` +- `deleteById(id)` — `findByIdAndDelete` +- `deleteAutoCreated()` — `deleteMany({ autoCreated: true })` +- `findAll()` — lightweight `find({}).lean()` for the bypass-check path + +### 3. Service — `app/services/system/validation-bypasses-service.js` +Pattern: `app/services/system/references-service.js` + +CRUD methods: +- `retrieveAll(options)` — paginate via `BaseService.paginate` +- `create(data)` — `repository.save(data)` +- `retrieveById(id)` — `repository.retrieveById(id)` +- `deleteById(id)` — `repository.deleteById(id)` + +Bypass logic: +- `isErrorBypassed(error, stixType)` — loads all rules from DB, checks if the error matches any active rule by comparing `fieldPath` (with `.map(String)` coercion for numeric indices), `errorCode`, and `stixType` (supports `"all"` or specific type). Returns `true`/`false`. + +Namespace auto-management: +- `createNamespaceRules(stixTypes)` — bulk-create entries with `autoCreated: true` for each ATT&CK-ID-bearing STIX type, targeting `fieldPath: ["external_references", "0", "external_id"]`, `errorCode: "custom"` +- `removeNamespaceRules()` — `repository.deleteAutoCreated()` + +### 4. Controller — `app/controllers/validation-bypasses-controller.js` +Pattern: `app/controllers/references-controller.js` + +- `retrieveAll(req, res)` → 200 +- `create(req, res)` → 201, 409 on duplicate +- `retrieveById(req, res)` → 200 or 404 +- `deleteById(req, res)` → 204 or 404 + +### 5. Routes — `app/routes/validation-bypasses-routes.js` +Auto-discovered by `app/routes/index.js`: + +``` +GET /api/config/validation-bypasses (visitor+) +POST /api/config/validation-bypasses (admin) +GET /api/config/validation-bypasses/:id (visitor+) +DELETE /api/config/validation-bypasses/:id (admin) +``` + +--- + +## Existing Files to Modify + +### 6. `app/services/meta-classes/base.service.js` + +**6a.** Remove import of `processValidationIssues` from `validate-service.js` (line 25). Keep `getSchema` import but change it to import from `../../lib/validation-schemas` instead (since `validate-service.js` is being deleted). + +**6b.** Rewrite `validateComposedObject(data)` (line 409) to be `async`. Instead of calling `processValidationIssues`, directly process Zod issues into the error format, then filter using `isErrorBypassed`: + +```javascript +async validateComposedObject(data) { + const empty = { errors: [], warnings: [] }; + if (!config.validateRequests.withAttackDataModel) return empty; + + const stixType = data.stix?.type; + const status = data.workspace?.workflow?.state || 'reviewed'; + + const schema = getSchema(stixType, status); + if (!schema) return empty; + + const result = schema.safeParse(data.stix); + if (result.success) return empty; + + // Convert Zod issues to error objects + const allErrors = result.error.issues.map(issue => ({ + message: `${issue.path.join('.')} is ${issue.message}`, + path: issue.path, + code: issue.code, + input: issue.input, + })); + + // Filter out bypassed errors + const validationBypassesService = require('../system/validation-bypasses-service'); + const errors = []; + for (const error of allErrors) { + const bypassed = await validationBypassesService.isErrorBypassed(error, stixType); + if (!bypassed) { + errors.push(error); + } + } + + return { errors, warnings: [] }; +} +``` + +**6c.** Update 3 call sites (lines 587, 628, 727) to `await this.validateComposedObject(data)`. + +### 7. `app/lib/validation-schemas.js` + +**7a.** Remove `ERROR_TRANSFORMATION_RULES` (lines 124-168) and its export (line 172). + +**7b.** Move `getSchema` function here from `validate-service.js` and export it. (It already imports `STIX_SCHEMAS` which is defined here, so this is a natural home.) + +### 8. `app/services/system/system-configuration-service.js` + +In `setOrganizationNamespace(namespace)` (line 240): +1. Call `validationBypassesService.removeNamespaceRules()` to clear previous auto-created entries +2. After saving, if `namespace?.prefix` is truthy, call `validationBypassesService.createNamespaceRules(stixTypes)` where `stixTypes = Object.keys(stixTypeToAttackIdMapping)` + +### 9. Delete Legacy Files +- `app/services/system/validate-service.js` +- `app/controllers/validate-controller.js` +- `app/routes/validate-routes.js` +- `app/api/definitions/paths/validate-paths.yml` +- Remove the validate path reference from `app/api/definitions/openapi.yml` + +--- + +## Implementation Order + +1. Create model (`validation-bypass-rule-model.js`) +2. Create repository (`validation-bypasses-repository.js`) +3. Create service with `isErrorBypassed()` + namespace rule helpers (`validation-bypasses-service.js`) +4. Create controller + routes +5. Move `getSchema` to `validation-schemas.js`, remove `ERROR_TRANSFORMATION_RULES` +6. Rewrite `validateComposedObject` in `base.service.js` — make async, use `isErrorBypassed` +7. Modify `system-configuration-service.js` — auto-create/remove rules on namespace set +8. Delete legacy files (`validate-service.js`, `validate-controller.js`, `validate-routes.js`, `validate-paths.yml`, openapi ref) + +--- + +## Verification + +1. **CRUD test**: Create/read/delete validation bypass rules via `/api/config/validation-bypasses` +2. **Namespace auto-creation**: Set namespace via `POST /api/config/organization-namespace`, verify bypass rules auto-created via `GET /api/config/validation-bypasses` +3. **Namespace removal**: Clear namespace, verify auto-created bypass rules removed +4. **Integration**: With namespace configured, create a technique — confirm no validation error for the namespaced ATT&CK ID +5. **Negative**: Create a technique with a genuinely invalid field — confirm real validation errors still throw +6. **Regression**: `npm test` — ensure existing tests pass diff --git a/.nocommit/prompts/virtual tracks.md b/.nocommit/prompts/virtual tracks.md new file mode 100644 index 00000000..27c9af40 --- /dev/null +++ b/.nocommit/prompts/virtual tracks.md @@ -0,0 +1,102 @@ +Virtual tracks sync from candidate tracks. Candidate tracks are just standard (non-virtual) release tracks. + +Virtual tracks must target a specific tier for each candidate track. Virtual tracks can sync from a candidate tracks `members`, `staged` or `candidates` list, but not a combination. + +Virtual tracks must each define a resolution policy to handle deduplication, which is to say: users must specify how virtual tracks should handle objects that exist in multiple candidate tracks. Here are some possible ideas: + +1. Prioritize newer object: Keep the newest version of the object, as defined by the object's `modified` timestamp. +2. Prioritize newer snapshot: Keep whichever version of the object came from the newest snapshot, as defined by the snapshot's `modified` timestamp. Note that this strategy makes it possible to sync older copies of an object! +3. Prioritize candidate priority: Keep whichever version of the object came from the higher priority candidate track. This would entail making it possible for users to specify which candidate tracks should be prioritized; for example: +``` + composition: { + component_tracks: [ + { + track_id: "release-track--uuid-1", + resolution_strategy: "latest_tagged", + priority: 1, + filters: { + object_types: ["intrusion-set"], + // Additional filters... + } + }, + { + track_id: "release-track--uuid-2", + resolution_strategy: "latest_tagged", + priority: 2, + filters: { + object_types: ["attack-pattern"] + } + } + ], +``` +4. Quarantine: Don't include either permutation. Instead, store both versions of the object as `candidates` in the virtual track. This will give the users the opportunity to review and decide which one to promote. + +The last point brings up another important topic about the promotion process. There are three tiers of membership: + +- `members`: cannot include duplicate objects. Cannot include multiple versions of the same object, as defined by the objects' `(id, modified)` pair. +- `staged`: same thing as members +- `candidates`: Cannot include duplicate objects. CAN include multiple versions of the same object. + +We need to define what happens if the release track tries to promote an conflicting object from `candidates` to `staged`: + - **Always overwrite**: This mode preserves the incoming object. + - **Prefer reject**: This mode rejects the incoming object/preserves the incumbent object. + +An additional setting/mode is supported when objects are being promoted from `staged` to `members`; recall that this particular promotion path can only be done when a snapshot is being tagged/released (i.e., `POST /api/release-tracks/:id/bump`). Importantly, once a snapshot is tagged/released, it becomes immutable. Therefore, we need to provide additional guardrails to ensure that the release process goes smoothly. Thus, in addition to the two aforementioned settings (`always-overwrite` and `always-reject`), an additional "abort" setting is supported for the release/tagging operation: + - **Abort**: If there is a conflict between an incumbent object in `members` and an incoming object, reject/abort the entire release/tagging operation. An immutable snapshot will not be created. Notify the user what caused the failure. + +--- + +On second thought, I think we need to make some modifications: + +DEDUPLICATION POLICY/STRATEGY FOR VIRTUAL TRACK SYNCING: + +Virtual tracks may only sync/pull from each candidate track's `members` list. Given that virtual tracks may only sync from tagged/release snapshots, this makes perfect sense. + +Virtual tracks support two object tiers: `members` and `quarantine`. + - `quarantine` is for objects that couldn't be reconciled during the sync/pull operation (i.e., duplicate objects). + - `members` contains everything that was synced from the release track's candidate tracks. + +During a sync operation, conflicts may occur -- it's possible that a virtual track contains an object with conflicts with an object in one of its candidate tracks. In such situations, virutal track sync dictate how to proceed. They are defined as follows: + + 1. **Prioritize latest object**: Keep the newest version of the object, as defined by the object's `modified` timestamp. + 2. **Prioritize latest snapshot**: Keep whichever version of the object came from the newest snapshot, as defined by the snapshot's `modified` timestamp. Note that this strategy makes it possible to sync older copies of an object! + 3. **Prioritize higher priority**: Keep whichever version of the object came from the candidate track with the higher priority. When a candidate track is added to a virtual track, the user must specify a numerical value specifying its priority. Candidate tracks may not have the same priority level. For example: + ```javascript + composition: { + component_tracks: [ + { + track_id: "release-track--uuid-1", + resolution_strategy: "latest_tagged", + priority: 1, + filters: { + object_types: ["intrusion-set"], + // Additional filters... + } + }, + { + track_id: "release-track--uuid-2", + resolution_strategy: "latest_tagged", + priority: 2, + filters: { + object_types: ["attack-pattern"] + } + } + ], + ``` + 1. Quarantine: Don't include either permutation. Instead, store both versions of the object in the virtual track's `quarantine`. This will give the users the opportunity to review and decide which one to promote. The objects will remain in quarantine until manual user intervention occurs. + +DEDUPLICATION POLICY/STRATEGY FOR PROMOTIONS IN STANDARD TRACKS: + +A promotion constitutes moving an object from `candidates` to `staged` or from `staged` to `members`. Promotions can occur in three ways: + + - **Manually** via REST API endpoint + - Via **auto-promotion** policy (e.g., an object transitions from `work-in-progress` to `awaiting-review` and the release track's `candidacy_threshold` is set to `awaiting-review`, therefore auto-promotion will promote the object to `staged`). + - Via **tagging/release/bump operations** + +The following policies can be used to reconcile promotion conflicts in standard tracks for any of the 3 aforementioned situations: + + - **Always overwrite**: This mode preserves the incoming object. + - **Always reject**: This mode rejects the incoming object/preserves the incumbent object. The rejected object is kept in `candidates`. + - **Prefer latest**: The object with the newer `modified` timestamp is kept. + - **Abort**: If there is a conflict between an incumbent object in `members` and an incoming object, reject/abort the entire release/tagging operation. An immutable snapshot will not be created. Notify the user what caused the failure. + diff --git a/.nocommit/run-field-probe.js b/.nocommit/run-field-probe.js new file mode 100644 index 00000000..e7378cfd --- /dev/null +++ b/.nocommit/run-field-probe.js @@ -0,0 +1,383 @@ +#!/usr/bin/env node + +'use strict'; + +/** + * Field Requirements Probe Runner + * + * This script safely runs the field requirements tests without requiring + * a running server, by directly testing the validation middleware logic. + */ + +const { validateWorkspaceStixData } = require('./app/lib/validation-middleware'); +const { + techniqueSchema, + tacticSchema, + campaignSchema, +} = require('@mitre-attack/attack-data-model'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Mock Express request/response for testing middleware + */ +function createMockReqRes(body) { + const req = { + body, + path: '/test', + method: 'POST' + }; + + const res = { + statusCode: 200, + responseBody: null, + status: function(code) { + this.statusCode = code; + return this; + }, + json: function(body) { + this.responseBody = body; + return this; + } + }; + + const next = function() { + // Middleware passed validation + }; + + return { req, res, next }; +} + +/** + * Test middleware validation for a given object + */ +function testValidation(schema, testObject) { + const middleware = validateWorkspaceStixData(schema); + const { req, res, next } = createMockReqRes(testObject); + + return new Promise((resolve) => { + // Override next to capture success + const nextOverride = () => { + resolve({ success: true, status: 200 }); + }; + + // Override response methods to capture errors + res.status = function(code) { + this.statusCode = code; + return this; + }; + res.json = function(body) { + this.responseBody = body; + resolve({ + success: false, + status: this.statusCode, + error: body + }); + return this; + }; + + try { + middleware(req, res, nextOverride); + } catch (error) { + resolve({ + success: false, + status: 500, + error: { message: error.message } + }); + } + }); +} + +/** + * Create base test objects + */ +function createTestObjects() { + const now = new Date().toISOString(); + + const baseStixFields = { + spec_version: '2.1', + created: now, + modified: now, + name: 'Test Object', + description: 'Test description', + x_mitre_attack_spec_version: '3.3.0', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + external_references: [{ + source_name: 'mitre-attack', + external_id: 'T9999', + url: 'https://attack.mitre.org/techniques/T9999' + }] + }; + + return { + technique: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'T9999' + }, + stix: { + ...baseStixFields, + type: 'attack-pattern', + id: 'attack-pattern--' + uuidv4(), + x_mitre_is_subtechnique: false + } + }, + + tactic: { + workspace: { + workflow: { state: 'awaiting-review' }, + attackId: 'TA9999' + }, + stix: { + ...baseStixFields, + type: 'x-mitre-tactic', + id: 'x-mitre-tactic--' + uuidv4(), + x_mitre_shortname: 'test-tactic', + external_references: [{ + source_name: 'mitre-attack', + external_id: 'TA9999', + url: 'https://attack.mitre.org/tactics/TA9999' + }] + } + }, + + campaign: { + workspace: { + workflow: { state: 'awaiting-review' } + }, + stix: { + ...baseStixFields, + type: 'campaign', + id: 'campaign--' + uuidv4(), + aliases: ['Test Campaign'], + first_seen: now, + last_seen: now, + x_mitre_first_seen_citation: '(Citation: Test)', + x_mitre_last_seen_citation: '(Citation: Test)', + revoked: false, + external_references: [{ + source_name: 'Test', + description: 'Test reference' + }] + } + } + }; +} + +/** + * Test specific field requirements + */ +async function probeFieldRequirements() { + console.log('šŸ” Testing Field Requirements for "awaiting-review" Objects'); + console.log('='*70); + + const schemas = { + technique: techniqueSchema, + tactic: tacticSchema, + campaign: campaignSchema + }; + + const testObjects = createTestObjects(); + + // Fields that are typically set by the backend + const BACKEND_FIELDS = [ + 'x_mitre_attack_spec_version', + 'x_mitre_domains', + 'x_mitre_modified_by_ref', + 'created_by_ref', + 'object_marking_refs' + ]; + + // Core STIX fields that should always be required + const CORE_FIELDS = [ + 'type', + 'spec_version', + 'created', + 'modified' + ]; + + const results = {}; + + for (const [objectType, schema] of Object.entries(schemas)) { + console.log(`\nšŸ” Testing ${objectType}...`); + results[objectType] = { + coreFields: {}, + backendFields: {}, + baselineValid: false + }; + + const baseObject = testObjects[objectType]; + + // Test 1: Baseline - full object should be valid + const baselineResult = await testValidation(schema, baseObject); + results[objectType].baselineValid = baselineResult.success; + console.log(` Baseline (full object): ${baselineResult.success ? 'āœ…' : 'āŒ'}`); + if (!baselineResult.success) { + console.log(` Error: ${JSON.stringify(baselineResult.error, null, 2)}`); + } + + // Test 2: Core STIX fields + console.log('\n Core STIX fields:'); + for (const field of CORE_FIELDS) { + const testObj = JSON.parse(JSON.stringify(baseObject)); + delete testObj.stix[field]; + + const result = await testValidation(schema, testObj); + results[objectType].coreFields[field] = { + required: !result.success, + error: result.error + }; + + console.log(` ${field}: ${result.success ? 'āœ… Optional' : 'āŒ Required'}`); + } + + // Test 3: Backend-managed fields + console.log('\n Backend-managed fields:'); + for (const field of BACKEND_FIELDS) { + const testObj = JSON.parse(JSON.stringify(baseObject)); + delete testObj.stix[field]; + + const result = await testValidation(schema, testObj); + results[objectType].backendFields[field] = { + required: !result.success, + error: result.error + }; + + const status = result.success ? 'āœ… Optional' : 'āŒ Required (PROBLEM!)'; + console.log(` ${field}: ${status}`); + } + } + + return results; +} + +/** + * Generate analysis report + */ +function generateReport(results) { + console.log('\n' + '='*70); + console.log('šŸ“Š FIELD REQUIREMENTS ANALYSIS REPORT'); + console.log('='*70); + + // Identify problematic backend fields + const problematicFields = new Set(); + const coreFieldIssues = new Set(); + + Object.entries(results).forEach(([objectType, result]) => { + // Check core fields + Object.entries(result.coreFields).forEach(([field, fieldResult]) => { + if (!fieldResult.required) { + coreFieldIssues.add(field); + } + }); + + // Check backend fields + Object.entries(result.backendFields).forEach(([field, fieldResult]) => { + if (fieldResult.required) { + problematicFields.add(field); + } + }); + }); + + console.log('\nšŸ”’ CORE STIX FIELD ANALYSIS:'); + if (coreFieldIssues.size === 0) { + console.log(' āœ… All core STIX fields are properly required'); + } else { + console.log(' āš ļø These core fields are unexpectedly optional:'); + coreFieldIssues.forEach(field => console.log(` - ${field}`)); + } + + console.log('\nāš ļø BACKEND FIELD ANALYSIS:'); + if (problematicFields.size === 0) { + console.log(' āœ… No backend-managed fields are required for "awaiting-review" objects'); + console.log(' āœ… Your validation middleware is working correctly!'); + } else { + console.log(' āŒ These backend-managed fields are required for "awaiting-review" objects:'); + problematicFields.forEach(field => { + console.log(` - ${field}`); + + // Provide recommendations + if (field === 'x_mitre_attack_spec_version') { + console.log(` → Frontend should include: "3.3.0"`); + } else if (field === 'x_mitre_domains') { + console.log(` → Frontend should include: ["enterprise-attack"]`); + } else if (field === 'x_mitre_modified_by_ref') { + console.log(` → Frontend should include: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"`); + } else if (field === 'created_by_ref') { + console.log(` → Frontend should include: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"`); + } else if (field === 'object_marking_refs') { + console.log(` → Frontend should include: ["marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"]`); + } + }); + } + + console.log('\nšŸŽÆ RECOMMENDATIONS:'); + if (problematicFields.size === 0) { + console.log(' āœ… No action required! Your middleware correctly handles "work-in-progress" vs "awaiting-review" validation.'); + } else { + console.log(' šŸ“‹ Update your frontend to include these fields when posting "awaiting-review" objects:'); + console.log(' šŸ“‹ OR modify your validation middleware to handle these fields differently.'); + } + + // Detailed breakdown + console.log('\nšŸ“‹ DETAILED BREAKDOWN:'); + Object.entries(results).forEach(([objectType, result]) => { + console.log(`\n ${objectType.toUpperCase()}:`); + console.log(` Baseline valid: ${result.baselineValid ? 'āœ…' : 'āŒ'}`); + + const requiredCore = Object.entries(result.coreFields) + .filter(([, fr]) => fr.required).length; + const requiredBackend = Object.entries(result.backendFields) + .filter(([, fr]) => fr.required).length; + + console.log(` Required core fields: ${requiredCore}/4`); + console.log(` Required backend fields: ${requiredBackend}/5 ${requiredBackend > 0 ? 'āš ļø' : 'āœ…'}`); + }); + + return { + problematicFields: [...problematicFields], + coreFieldIssues: [...coreFieldIssues], + summary: { + hasProblems: problematicFields.size > 0, + coreFieldsOk: coreFieldIssues.size === 0 + } + }; +} + +/** + * Main execution + */ +async function main() { + try { + const results = await probeFieldRequirements(); + const analysis = generateReport(results); + + console.log('\nāœ… Field requirements probe complete!'); + + if (analysis.summary.hasProblems) { + console.log('\n🚨 Action required: Frontend needs to include additional fields for "awaiting-review" objects.'); + process.exit(1); + } else { + console.log('\nšŸŽ‰ No issues found! Your validation is working correctly.'); + process.exit(0); + } + } catch (error) { + console.error('\nšŸ’„ Probe failed:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + createTestObjects, + testValidation, + probeFieldRequirements, + generateReport +}; \ No newline at end of file diff --git a/.nocommit/test-mongoose-serialization.js b/.nocommit/test-mongoose-serialization.js new file mode 100644 index 00000000..c56b53a1 --- /dev/null +++ b/.nocommit/test-mongoose-serialization.js @@ -0,0 +1,136 @@ +const request = require('supertest'); +const database = require('./app/lib/database-in-memory'); +const databaseConfiguration = require('./app/lib/database-configuration'); +const config = require('./app/config/config'); +const login = require('./app/tests/shared/login'); +const uuid = require('uuid'); + +async function testMongooseSerialization() { + console.log('=== Testing Mongoose Serialization Behavior ===\n'); + + // Configure validation + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = false; + + // Initialize database and app + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + const app = await require('./app/index').initializeApp(); + const passportCookie = await login.loginAnonymous(app); + + // Create a minimal technique WITHOUT x_mitre_platforms or x_mitre_contributors + const minimalTechnique = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + type: 'attack-pattern', + spec_version: '2.1', + id: `attack-pattern--${uuid.v4()}`, + created: new Date().toISOString(), + modified: new Date().toISOString(), + name: 'Test Technique - No Arrays', + x_mitre_is_subtechnique: false, + x_mitre_domains: ['enterprise-attack'], + external_references: [{ external_id: 'T001' }], + // NOTE: We are deliberately NOT including x_mitre_platforms or x_mitre_contributors + }, + }; + + console.log('1. Sending POST request WITHOUT x_mitre_platforms and x_mitre_contributors:'); + console.log(' Request body includes these fields:', Object.keys(minimalTechnique.stix)); + console.log(' x_mitre_platforms in request:', 'x_mitre_platforms' in minimalTechnique.stix); + console.log( + ' x_mitre_contributors in request:', + 'x_mitre_contributors' in minimalTechnique.stix, + ); + console.log(''); + + const postResponse = await request(app) + .post('/api/techniques') + .send(minimalTechnique) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); + + console.log('2. POST Response status:', postResponse.status); + console.log(''); + + if (postResponse.status === 201) { + const returnedStix = postResponse.body.stix; + + console.log('3. Server Response Analysis:'); + console.log(' Fields in response:', Object.keys(returnedStix)); + console.log(''); + console.log('4. Critical Fields Check:'); + console.log(' x_mitre_platforms in response:', 'x_mitre_platforms' in returnedStix); + console.log(' x_mitre_platforms value:', JSON.stringify(returnedStix.x_mitre_platforms)); + console.log(' x_mitre_platforms type:', typeof returnedStix.x_mitre_platforms); + console.log( + ' x_mitre_platforms Array.isArray:', + Array.isArray(returnedStix.x_mitre_platforms), + ); + console.log(''); + console.log(' x_mitre_contributors in response:', 'x_mitre_contributors' in returnedStix); + console.log( + ' x_mitre_contributors value:', + JSON.stringify(returnedStix.x_mitre_contributors), + ); + console.log(' x_mitre_contributors type:', typeof returnedStix.x_mitre_contributors); + console.log( + ' x_mitre_contributors Array.isArray:', + Array.isArray(returnedStix.x_mitre_contributors), + ); + console.log(''); + + // Now check what happens when we try to PUT with this data + console.log('5. Testing PUT with the returned object:'); + const putBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + ...returnedStix, + name: 'Updated Name', + }, + }; + + delete putBody.stix.x_mitre_attack_spec_version; + + console.log( + ' x_mitre_platforms in PUT body:', + JSON.stringify(putBody.stix.x_mitre_platforms), + ); + console.log( + ' x_mitre_contributors in PUT body:', + JSON.stringify(putBody.stix.x_mitre_contributors), + ); + console.log(''); + + const putResponse = await request(app) + .put(`/api/techniques/${returnedStix.id}/modified/${returnedStix.modified}`) + .send(putBody) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); + + console.log(' PUT Response status:', putResponse.status); + if (putResponse.status === 400) { + console.log(' PUT FAILED with validation errors:'); + console.log(' Errors:', JSON.stringify(putResponse.body.details, null, 2)); + } else { + console.log(' PUT succeeded (unexpected!)'); + } + } else { + console.log('POST failed:', postResponse.body); + } + + process.exit(0); +} + +testMongooseSerialization().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/ADM-test-migration-handoff.md b/ADM-test-migration-handoff.md new file mode 100644 index 00000000..f14b9bdb --- /dev/null +++ b/ADM-test-migration-handoff.md @@ -0,0 +1,173 @@ +# Handoff: Enable ADM validation across the regression test suites + +## Goal + +The REST API can validate incoming objects against the ATT&CK Data Model (ADM) +Zod schemas when `config.validateRequests.withAttackDataModel` is `true`. Most +regression specs were written against looser, pre-ADM payloads and still run with +that flag `false`, so the strict validation path is untested. + +Your job: go directory-by-directory through `app/tests/api/`, flip each spec to +run **with ADM validation enabled**, make the seeded request payloads +ADM-compliant, and confirm the suite still passes. One commit per directory. + +This is mechanical, repetitive work. A playbook of the common fixes is below — +most directories need only a handful of them. + +## Branch & continuation + +- Work on the existing branch **`fix/adm-validation-logging`** (forked from `next`). +- A draft PR is already open against `next`. +- Already migrated (do not redo): `techniques`, `analytics`, `assets`, + `campaigns`, `collections`, `data-components`, `data-sources`, + `detection-strategies`, plus a logging fix and a fixture-inlining commit. +- Continue **alphabetically**. Next up is `groups`. + +## The per-directory workflow (repeat for each) + +1. List the specs: `ls app/tests/api//`. +2. For each `*.spec.js` (and any `*-spec.js` — note the inconsistent naming), + find the ADM toggle: `grep -n withAttackDataModel app/tests/api//*`. +3. Flip `config.validateRequests.withAttackDataModel = false` → `true`. Leave + `withOpenApi` as it is. Update the stale `// Disable ADM validation` comment to + `// Enable ADM validation; the request payloads in this spec are ADM-compliant`. + - **Pagination specs are special** — see below. +4. Run the spec: `npm run test:file -- app/tests/api//.spec.js`. +5. Read the failures. The logger prints full ADM detail at `[WARN] Bad request: %s` + with a `details` array naming the exact field + rule. Fix the fixture(s) per the + playbook. Re-run. Iterate until green. +6. When all specs in the directory pass, run the whole directory: + `npm run test:file -- --recursive app/tests/api/`. +7. Lint + format the changed files: + `npx eslint app/tests/api//` and `npx prettier app/tests/api// --check` + (use `--write` if needed). +8. Commit (one per directory): + `git add app/tests/api//...` then + `git commit -m "test(): run suites with ADM validation enabled"` + with a short body describing the fixture fixes. **Match the existing commit + style — plain messages, no AI-attribution footer.** A pre-commit hook runs + prettier/eslint/lockfile-check automatically. + +## Pagination specs (special case) + +Pagination specs delegate to the shared `PaginationTests` class +(`app/tests/shared/pagination.js`) and have no `before()` of their own. Do **not** +add a config toggle. Instead pass the flag through the options object: + +```js +const options = { + prefix: '...', + baseUrl: '/api/...', + label: '...', + // The seeded fixture is ADM-compliant; pin validation on so this suite does + // not inherit the flag from whichever spec ran before it. + validateWithAdm: true, +}; +``` + +`PaginationTests` already honors `options.validateWithAdm` (added during the +techniques migration) and sets the config in its own `before()`. + +## ADM fix playbook (the common failures) + +Validation runs on the **server-composed** object, after the server fills in +`id`, the ATT&CK `external_references[0]`, `created_by_ref`, +`x_mitre_modified_by_ref`, and `x_mitre_attack_spec_version`. So a payload only +needs to be valid *after* composition. Workflow state drives strictness: +`work-in-progress` → **partial** schema (lenient; may omit required fields); +`awaiting-review`/`reviewed`/absent → **full** schema (strict). `status` defaults +to `reviewed` when `workspace.workflow.state` is absent. + +| Symptom (from the `details` array) | Fix | +| --- | --- | +| `x_mitre_platforms.N ... Platform must be one of: ...` | Enum is **case-sensitive**: `'windows'` → `'Windows'`; `'platform-1'` → a real value (`Linux`, `macOS`, `Windows`, `Android`, `Network`, `Office Suite`, …). Domain does **not** constrain platform. | +| `kill_chain_phases.N.kill_chain_name ... expected one of "mitre-attack"\|"mitre-mobile-attack"\|"mitre-ics-attack"` | Use `mitre-attack` (or the mobile/ics variant). | +| `phase_name ...` | Lowercase, hyphenated tactic shortname, e.g. `execution`, `impact`. | +| `x_mitre_impact_type.N ... must be one of: Availability, Integrity` | Use a valid value **and** include the `impact` tactic in `kill_chain_phases` (enterprise-only refinement). If the field isn't asserted by any test, just delete it. | +| `x_mitre_sectors / related_asset_sectors ... Sector must be one of: ...` | Enum: `Electric`, `Water and Wastewater`, `Manufacturing`, `Rail`, `Maritime`, `General`. | +| `x_mitre_collection_layers ...` | Enum: `Cloud Control Plane`, `Host`, `Report`, `Container`, `Device`, `OSINT`, `Network`. | +| `x_mitre_data_sources.N ... pattern ': '` | Deprecated `z.custom` field. Use `'Name: Component'` format, or (if not asserted) delete it. | +| `id ... invalid UUIDv4 format` / `must comply with format 'type--UUIDv4'` | Hardcoded STIX ids must be `type--`. **Valid v4**: 13th hex digit (version) = `4`, 17th (variant) = `8/9/a/b`. Keep ids consistent across every place they're referenced (definition + embedded refs + literal assertions). For "non-existent ref → 404" tests, use a **valid-format-but-absent** UUID so ADM (400) doesn't preempt the intended 404. | +| `x_mitre_version ... expected string, received undefined` | Full schema requires `x_mitre_version` (e.g. `'1.0'`). | +| `x_mitre_domains ... expected array, received undefined` | Full schema requires `x_mitre_domains` (e.g. `['enterprise-attack']`). WIP omits it fine. | +| `... Unrecognized key: "description"` | `x-mitre-detection-strategy` has **no** `description` field — remove it. (Schemas are `.strict()`; analytics/most SDOs *do* allow `description`.) | +| `x_mitre_analytic_refs ... Too small / At least one` | Detection-strategy requires ≄1 analytic ref. Either reference a real created analytic, or for "no refs" omit the field (don't send `[]`). | +| `x_mitre_log_source_references ... Too small` | Omit the field entirely to mean "none"; it must be non-empty when present. | +| `x_mitre_contents ... At least one` + missing `x_mitre_version` (collections) | Collection full schema needs `x_mitre_version` and ≄1 `x_mitre_contents` entry. | + +**Do not** assert that server-controlled fields echo what you sent. The server +overwrites/strips: `workspace.attack_id`, the ATT&CK `external_references[0]`, +`x_mitre_attack_spec_version`, `revoked`, and (on create) `created_by_ref` / +`x_mitre_modified_by_ref`. If an existing assertion expects a specific +server-controlled value, assert the server's *rule* instead (e.g. "`attack_id` +matches `T####`"), not the literal you sent. + +## Where to find schema truth + +- Selector used by the validator: `getSchema()` in + `app/lib/validation-schemas.js`. +- Compiled schemas: `node_modules/@mitre-attack/attack-data-model/dist/index.cjs`. +- Readable source (checked out locally): + `/Users/ssica/Development/attack/attack-data-model/src/schemas/` — the + per-type files under `sdo/` and the cross-field rules in + `refinements/index.ts`. Enum lists and required fields live in the type's + `*.schema.ts`. +- Quick UUID sanity check: + `node -e "const {validate,version}=require('uuid'); const u='...'; console.log(validate(u)&&version(u)===4)"` + +## Gotchas + +- **Flakiness:** the suite has intermittent cross-spec state leakage (shared + in-memory DB). An occasional failure at delete-all / "expect empty array" + assertions usually clears on re-run. Don't chase it; re-run once. +- **Collections route returns 500, not 400, on ADM failure** — its create path + doesn't map `ValidationError` to 400 and the details bypass the `Bad request: %s` + log. If `details` aren't logged, validate the composed object directly against + `getSchema(type, status)` in a scratch script. +- **Non-STIX specs:** some directories operate on non-STIX/system objects + (`teams`, `user-accounts`, `session`, parts of `system-configuration`, + `identities`). For those, `getSchema()` returns `null` and ADM never runs, so + flipping the flag is a harmless no-op — flip it for consistency, confirm green, + move on. If a spec clearly has nothing to do with STIX object creation, it's + fine to leave it unchanged; note it in the commit body. +- **Naming inconsistency:** some files are `-spec.js` instead of + `.spec.js`, and some pagination files use `-` vs `.`. Mocha's + `--recursive` loads all `.js` files so they still run; leave renames out of this + effort. + +## Remaining work (alphabetical; one commit each) + +Vanilla CRUD suites — do these in order: + +- [ ] `groups` — `groups.spec.js`, `groups-input-validation.spec.js`, `groups.query.spec.js`, `groups-pagination.spec.js` (pagination → `validateWithAdm` option) +- [ ] `identities` — `identities.spec.js` (likely non-STIX no-op; verify) +- [ ] `marking-definitions` — `marking-definitions.spec.js` +- [ ] `matrices` — `matrices.spec.js` (matrix ATT&CK external_id is the domain name, not an auto ID) +- [ ] `mitigations` — `mitigations.spec.js`, `mitigations-pagination.spec.js` (pagination → `validateWithAdm` option) +- [ ] `notes` — `notes.spec.js` (workspace object; may be a no-op) +- [ ] `recent-activity` — `recent-activity.spec.js` (bundle-seeded; may be involved) +- [ ] `references` — `references.spec.js` (workspace object; may be a no-op) +- [ ] `relationships` — `relationships.spec.js`, `relationships-pagination.spec.js` (pagination → `validateWithAdm` option) +- [ ] `reports` — `reports.spec.js` +- [ ] `session` — `session.spec.js` (non-STIX; likely no-op) +- [ ] `software` — `software.spec.js`, `software-pagination.spec.js` (`malware`/`tool`; pagination → `validateWithAdm` option) +- [ ] `stix-bundles` — `stix-bundles.spec.js` (bundle-seeded; may be involved) +- [ ] `system-configuration` — `system-configuration.spec.js`, `create-object-identity.spec.js` +- [ ] `tactics` — `tactics.spec.js`, `tactics.techniques.spec.js` (the latter is bundle-import seeded) +- [ ] `teams` — `teams.spec.js`, `teams-invalid.spec.js` (non-STIX; likely no-op) +- [ ] `user-accounts` — `user-accounts.spec.js`, `user-accounts-invalid.spec.js` (non-STIX; likely no-op) + +**Defer to the very end** (non-vanilla; import-path / bundle-validation heavy — +ADM is recorded-not-rejected there, so they behave differently): + +- [ ] `attack-objects` +- [ ] `collection-bundles` +- [ ] `collection-indexes` + +## Definition of done (per directory) + +- Every spec in the directory sets/pins `withAttackDataModel = true` (or + `validateWithAdm: true` for pagination). +- `npm run test:file -- --recursive app/tests/api/` is green. +- eslint + prettier clean. +- One commit, `test(): ...`, with a body listing the fixture fixes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b104267..68345060 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ The project follows these general steps for development: 5. **Run Tests Locally**: Make sure all tests pass before submitting changes. 6. **Submit a Pull Request**: Create a pull request against the `develop` branch. -If your changes are related to or dependent on changes in [attack-workbench-frontend](https://github.com/center-for-threat-informed-defense/attack-workbench-frontend), please link to the corresponding frontend pull request in your PR description. +If your changes are related to or dependent on changes in [attack-workbench-frontend](https://github.com/mitre-attack/attack-workbench-frontend), please link to the corresponding frontend pull request in your PR description. ## Branching Strategy diff --git a/Dockerfile b/Dockerfile index 492e8a6a..7db0fa21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,8 @@ ARG REVISION=unknown # Set Docker labels LABEL org.opencontainers.image.title="ATT&CK Workbench REST API Service" \ org.opencontainers.image.description="This Docker image contains the REST API service of the ATT&CK Workbench, an application for exploring, creating, annotating, and sharing extensions of the MITRE ATT&CKĀ® knowledge base. The service handles the storage, querying, and editing of ATT&CK objects. The application is built on Node.js and Express.js, and is served by the built-in web server provided by Express.js." \ - org.opencontainers.image.source="https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api" \ - org.opencontainers.image.documentation="https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api/README.md" \ + org.opencontainers.image.source="https://github.com/mitre-attack/attack-workbench-rest-api" \ + org.opencontainers.image.documentation="https://github.com/mitre-attack/attack-workbench-rest-api/README.md" \ org.opencontainers.image.url="https://ghcr.io/center-for-threat-informed-defense/attack-workbench-rest-api" \ org.opencontainers.image.vendor="The MITRE Corporation" \ org.opencontainers.image.licenses="Apache-2.0" \ diff --git a/README.md b/README.md index 086b16e9..de275737 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The REST API provides: ## Related Repositories -- [ATT&CK Workbench Frontend](https://github.com/center-for-threat-informed-defense/attack-workbench-frontend): The user interface for the ATT&CK Workbench +- [ATT&CK Workbench Frontend](https://github.com/mitre-attack/attack-workbench-frontend): The user interface for the ATT&CK Workbench - [ATT&CK Workbench TAXII 2.1 Server](https://github.com/mitre-attack/attack-workbench-taxii-server): An *optional* Workbench service for sharing STIX content through a TAXII 2.1-compliant interface - [ATT&CK Workbench Deployment Guide](https://github.com/mitre-attack/attack-workbench-deployment): The official instructions and configuration templates for deploying Workbench in Docker diff --git a/USAGE.md b/USAGE.md index 9f04e4f5..6870131d 100644 --- a/USAGE.md +++ b/USAGE.md @@ -37,8 +37,8 @@ This guide provides comprehensive instructions for installing, configuring, and The ATT&CK Workbench REST API provides services for storing, querying, and editing ATT&CK objects. It is built on Node.js and Express.js, and uses MongoDB for data persistence. This component is part of the larger ATT&CK Workbench application, which includes: -- [ATT&CK Workbench Frontend](https://github.com/center-for-threat-informed-defense/attack-workbench-frontend) -- [ATT&CK Workbench REST API](https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api) (this component) +- [ATT&CK Workbench Frontend](https://github.com/mitre-attack/attack-workbench-frontend) +- [ATT&CK Workbench REST API](https://github.com/mitre-attack/attack-workbench-rest-api) (this component) ## Installation Methods @@ -97,7 +97,7 @@ docker run -p 3000:3000 -d \ 1. **Clone the repository**: ```shell - git clone https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api.git + git clone https://github.com/mitre-attack/attack-workbench-rest-api.git cd attack-workbench-rest-api ``` diff --git a/app/lib/default-bypass-rules.json.original b/app/lib/default-bypass-rules.json.original new file mode 100644 index 00000000..f4b999ce --- /dev/null +++ b/app/lib/default-bypass-rules.json.original @@ -0,0 +1,94 @@ +[ + // { + // "fieldPath": ["x_mitre_attack_spec_version"], + // "errorCode": "invalid_type", + // "stixType": "all", + // "suppressError": true, + // "_comment": "Server always sets x_mitre_attack_spec_version" + // }, + // { + // "fieldPath": ["x_mitre_modified_by_ref"], + // "errorCode": "invalid_value", + // "stixType": "all", + // "suppressError": true, + // "_comment": "Server sets x_mitre_modified_by_ref based on authenticated user" + // }, + { + "fieldPath": ["x_mitre_shortname"], + "errorCode": "invalid_value", + "stixType": "x-mitre-tactic", + "suppressError": false, + "warningMessage": "Tactic shortname does not match predefined ATT&CK tactics. This may prevent compatibility with official ATT&CK data but can be used for custom taxonomies.", + "_comment": "Warn about non-standard tactic shortnames instead of blocking" + }, + { + "fieldPath": ["x_mitre_domains"], + "errorCode": "invalid_type", + "stixType": "intrusion-set", + "suppressError": true, + "_comment": "Server sets x_mitre_domains for intrusion-set (assigned during bundle export)" + }, + { + "fieldPath": ["x_mitre_domains"], + "errorCode": "invalid_type", + "stixType": "campaign", + "suppressError": true, + "_comment": "Server sets x_mitre_domains for campaign (assigned during bundle export)" + }, + { + "fieldPath": ["x_mitre_domains"], + "errorCode": "invalid_type", + "stixType": "x-mitre-matrix", + "suppressError": true, + "_comment": "Server sets x_mitre_domains for x-mitre-matrix (assigned during bundle export)" + }, + { + "fieldPath": ["x_mitre_domains"], + "errorCode": "invalid_type", + "stixType": "x-mitre-detection-strategy", + "suppressError": true, + "_comment": "Server sets x_mitre_domains for x-mitre-detection-strategy (assigned during bundle export)" + }, + // { + // "fieldPath": ["object_marking_refs"], + // "errorCode": "invalid_type", + // "stixType": "campaign", + // "suppressError": true, + // "_comment": "Server sets object_marking_refs for campaign" + // }, + // { + // "fieldPath": ["object_marking_refs"], + // "errorCode": "invalid_type", + // "stixType": "identity", + // "suppressError": true, + // "_comment": "Server sets object_marking_refs for identity" + // }, + // { + // "fieldPath": ["created_by_ref"], + // "errorCode": "invalid_type", + // "stixType": "campaign", + // "suppressError": true, + // "_comment": "Server sets created_by_ref for campaign" + // }, + // { + // "fieldPath": ["created_by_ref"], + // "errorCode": "invalid_type", + // "stixType": "x-mitre-matrix", + // "suppressError": true, + // "_comment": "Server sets created_by_ref for x-mitre-matrix" + // }, + // { + // "fieldPath": ["created_by_ref"], + // "errorCode": "invalid_type", + // "stixType": "x-mitre-asset", + // "suppressError": true, + // "_comment": "Server sets created_by_ref for x-mitre-asset" + // }, + // { + // "fieldPath": ["created_by_ref"], + // "errorCode": "invalid_type", + // "stixType": "course-of-action", + // "suppressError": true, + // "_comment": "Server sets created_by_ref for course-of-action" + // } +] diff --git a/codex-prompt.md b/codex-prompt.md new file mode 100644 index 00000000..559e12ea --- /dev/null +++ b/codex-prompt.md @@ -0,0 +1,9 @@ +You're picking up a mechanical test-migration effort in the `attack-workbench-rest-api` repo. Read `ADM-test-migration-handoff.md` at the repo root first — it has the full context, the per-directory workflow, a playbook of the common ADM validation fixes, and a checklist of remaining directories. + +**Task:** Enable ATT&CK Data Model (ADM) request validation across the remaining `app/tests/api/` regression suites, one directory at a time, alphabetically, starting with `groups`. For each directory: flip each spec's `config.validateRequests.withAttackDataModel` from `false` to `true` (for pagination specs, pass `validateWithAdm: true` in the `PaginationTests` options instead), make the seeded request payloads ADM-compliant per the playbook, get the suite green, lint+format, and make one commit per directory (`test(): run suites with ADM validation enabled`, plain message body listing the fixture fixes, no AI-attribution footer). + +Work on the existing `fix/adm-validation-logging` branch. Run a single spec with `npm run test:file -- ` and a directory with `npm run test:file -- --recursive app/tests/api/`. The server logs full ADM failure detail at `[WARN] Bad request: %s` — read the `details` array to see exactly which field/rule failed. + +**Skip for now** (do last, they behave differently): `attack-objects`, `collection-bundles`, `collection-indexes`. + +Do the directories in order, committing each before moving to the next. Stop and ask if a spec needs a behavior change beyond fixture compliance, or if a directory turns out not to involve STIX-object creation at all. diff --git a/docs/README.md b/docs/README.md index 9b150ce7..4cb37dc5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,6 @@ Interactive API documentation is available when running the application in devel ## Additional Resources -- [GitHub Repository](https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api) -- [Frontend Repository](https://github.com/center-for-threat-informed-defense/attack-workbench-frontend) -- [Issue Tracker](https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api/issues) \ No newline at end of file +- [GitHub Repository](https://github.com/mitre-attack/attack-workbench-rest-api) +- [Frontend Repository](https://github.com/mitre-attack/attack-workbench-frontend) +- [Issue Tracker](https://github.com/mitre-attack/attack-workbench-rest-api/issues) \ No newline at end of file diff --git a/docs/developer/crud-regression-test-taxonomy.md b/docs/developer/crud-regression-test-taxonomy.md new file mode 100644 index 00000000..bd6224bc --- /dev/null +++ b/docs/developer/crud-regression-test-taxonomy.md @@ -0,0 +1,276 @@ +# CRUD Regression Test Taxonomy + +## Overview + +Almost every ATT&CK object type (techniques, tactics, groups, software, mitigations, +assets, data sources, data components, campaigns, …) is served by a thin +`*-service.js` that extends [`BaseService`](../../app/services/meta-classes/base.service.js). +The HTTP controllers are equally thin. This means the **business logic exercised by +the regression suites is overwhelmingly shared**: the create/update/delete behavior +lives in `BaseService`, and each per-type suite is, in effect, re-testing the same +pipeline with a different `stix.type`. + +This document does two things: + +1. Maps the POST execution stack so we know exactly what behavior each suite is + (or should be) exercising. +2. Defines a **taxonomy of evaluations** — a checklist of shared behaviors — so that + when we refactor a suite to run with ADM validation on, we converge on a common, + consistent set of test cases instead of re-deriving them per type. + +The taxonomy is the starting contract. Type-specific behavior (e.g. subtechnique +conversion, matrix external IDs) layers on top of it. + +## Execution stack: `POST /api/techniques` + +Techniques are representative — `TechniquesService` does **not** override `create`, +so the whole path is `BaseService` logic shared by nearly every type. + +``` +techniques-controller.js exports.create + └─ techniquesService.create(req.body, options) // options: { import, userAccountId, parentTechniqueId, dryRun } + └─ BaseService.create(data, options) // app/services/meta-classes/base.service.js + 1. ANALYZE REQUEST + 2. COMPOSE OBJECT + 3. SET SERVER-CONTROLLED FIELDS + 4. LIFECYCLE HOOKS (beforeCreate) + 5. VALIDATE WITH ADM (validateComposedObject → getSchema) + 6. PERSIST (repository.save → afterCreate → emit event) + └─ 201 + created object | 200 + composed object (dryRun) | 409 DuplicateId | 400 ADM/validation +``` + +### What each stage does (and which fields it touches) + +| Stage | Behavior | Key fields | +| --- | --- | --- | +| 1. Analyze | Reject if `stix.type !== service.type`. Look up existing versions by `stix.id` to decide **new object vs. new version** and whether to reuse the ATT&CK ID. | `stix.type`, `stix.id` | +| 2. Compose | `stripServerControlledFields` → strip empty strings → normalize dates → **generate or reuse the ATT&CK ID** → build and prepend the ATT&CK external reference. | `workspace.attack_id`, `external_references[0]`, `revoked`, `x_mitre_attack_spec_version` | +| 3. Set server fields | Set spec version; set `stix.id`/`created` (new object) or carry `revoked` forward (new version); default `modified`/`spec_version`; set identity refs; apply default markings; record `created_by_user_account`. | `stix.id`, `created`, `modified`, `created_by_ref`, `x_mitre_modified_by_ref`, `object_marking_refs`, `workspace.workflow` | +| 4. Hooks | `beforeCreate` — per-type transforms (most types: no-op). | (varies) | +| 5. Validate | `validateComposedObject` runs ADM `getSchema(type, status)` on the **composed** `stix`. WIP → partial schema; otherwise full schema. 400 on failure. | all | +| 6. Persist | `repository.save` (409 on duplicate `id`+`modified`), `afterCreate`, emit `created` event. `dryRun` returns the composed object with `200` and skips persistence. | all | + +> Validation runs on the **server-composed** object, not the raw request body. A test +> only needs to send a body that is valid *after* the server fills in `id`, +> `external_references`, `created_by_ref`, etc. This is why work-in-progress payloads +> can omit `stix.id` and the ATT&CK reference and still pass. + +## Field provenance model + +The single most important concept for refactoring tests is knowing **who owns each +field**. Three categories: + +### Server-controlled (client values are stripped / overwritten — do not assert that what you sent comes back) + +| Field | Behavior | Source | +| --- | --- | --- | +| `workspace.attack_id` | Stripped, then generated (new) or reused (new version). | `stripServerControlledFields`, `attackIdGenerator` | +| `external_references[0]` (the `mitre-attack` ref) | Any client-supplied ATT&CK ref is filtered out; server prepends the canonical one. `external_references[0].external_id` always mirrors `workspace.attack_id`. | `stripServerControlledFields`, `createAttackExternalReference` | +| `x_mitre_attack_spec_version` | Always stripped, then set to `config.app.attackSpecVersion`. | `ALWAYS_STRIPPED_STIX_FIELDS` | +| `revoked` | Always stripped, then `false` (new) or carried forward (new version). | `ALWAYS_STRIPPED_STIX_FIELDS` | +| `created_by_ref` | Overwritten with the org identity **on new objects** (preserved on new versions). | step 3 | +| `x_mitre_modified_by_ref` | Overwritten with the org identity (new and new version). | step 3 | +| `workspace.validation` | Stripped on every create/update. | `stripServerControlledFields` | +| `workspace.workflow.created_by_user_account` | Set from the authenticated user. | step 3 | + +### Hybrid (client may supply; server defaults when omitted) + +| Field | Provided | Omitted | +| --- | --- | --- | +| `stix.id` | Honored verbatim (and, if it already exists, makes this a **new version**). | Generated as `${type}--${uuidv4}`. | +| `stix.created` | Honored. | Defaulted to "now" **for new objects only**; on a new version it is taken from the request as-is. | +| `stix.modified` | Honored. | Defaulted to "now" (new objects and new versions). | +| `stix.spec_version` | Honored. | Defaulted to `'2.1'`. | +| `object_marking_refs` | Honored. | Default marking definitions applied. | + +### Client-controlled (round-trip faithfully; safe to assert equality) + +Everything else: `name`, `description`, `kill_chain_phases`, `x_mitre_platforms`, +`x_mitre_domains`, `x_mitre_detection`, user-provided non-ATT&CK `external_references`, +`workspace.workflow.state`, etc. These must be ADM-valid but are stored as sent. + +## The taxonomy + +Each category below is a shared behavior that should be evaluated by (nearly) every +SDO suite. IDs are stable handles for cross-referencing during the refactor. + +### A. Request shape & type + +- **A1 — Empty body rejected.** `POST` with `{}` → 400. +- **A2 — Type mismatch rejected.** `stix.type` not matching the endpoint → 400 (`InvalidTypeError`). +- **A3 — ADM validation enforced.** A body that is ADM-invalid after composition → 400, and the + response/server log carries the per-field `details` (see the validation logging fix). + +### B. STIX identity (`stix.id`) + +- **B1 — Generated when omitted.** Response `stix.id` is defined and matches `${type}--`. +- **B2 — Honored when provided.** A client-supplied `stix.id` is returned unchanged. +- **B3 — Duplicate `(id, modified)` rejected.** Re-POST of the same id+modified → 409. + +### C. Timestamps (`stix.created`, `stix.modified`) + +- **C1 — `modified` generated when omitted.** +- **C2 — `created`/`modified` honored when provided.** +- **C3 — New version bumps `modified`.** Same `stix.id`, different `modified` → second version + created (`versions=all` returns N, default GET returns latest by `modified`). +- **C4 — `created` is stable across versions** (client carries it forward; server does not rewrite it on a new version). + +### D. ATT&CK ID idempotency (`workspace.attack_id` ↔ `external_references[0].external_id`) + +- **D1 — Generated for a new object** in the correct format for the type (`T####`, `TA####`, `G####`, …). +- **D2 — Mirrored into `external_references[0].external_id`** with `source_name: 'mitre-attack'`. +- **D3 — User cannot set it.** A client-supplied `workspace.attack_id` or `mitre-attack` + external reference is stripped; the server value wins. +- **D4 — Reused across revisions.** A new version of an existing `stix.id` keeps the original + `attack_id` regardless of what the client sends (omit / keep / change → same result). +- **D5 — Searchable by ATT&CK ID.** `GET ?search=` returns the object. + +### E. Server-controlled STIX fields + +- **E1 — `x_mitre_attack_spec_version`** equals `config.app.attackSpecVersion` regardless of input. +- **E2 — `revoked`** is `false` on a new object; **carried forward** on a new version; never settable by the client. +- **E3 — `created_by_ref` / `x_mitre_modified_by_ref`** set to the org identity (not the client's value) on create. +- **E4 — Default marking definitions** applied when `object_marking_refs` is omitted. + +### F. Workspace / workflow + +- **F1 — `created_by_user_account`** recorded from the authenticated session. +- **F2 — `workspace.attack_id`/`workspace.validation`** never accepted from the client. +- **F3 — Workflow state drives validation strictness.** `work-in-progress` → partial schema + (drafts may omit fields); `awaiting-review`/`reviewed` → full schema. + +### G. Lifecycle & retrieval (the existing CRUD coverage, restated) + +- **G1 — Create → 201**, returns the composed object. +- **G2 — Retrieve** by collection, by id, by id+modified; latest-version semantics. +- **G3 — Update (PUT)** returns 200; preserves server-controlled fields from the stored doc + (`attack_id`, `revoked`, `x_mitre_is_subtechnique`, spec version). +- **G4 — Delete** by id+modified (one version) and by id (all versions). +- **G5 — `dryRun=true`** returns 200 + composed object and **persists nothing** (subsequent GET is empty). + +### H. Negative / not-found + +- **H1 — GET/DELETE unknown id** → 404. +- **H2 — Malformed parameters** → 400. + +## How `PUT` (update) differs from `POST` + +`updateFull` reuses the same strip/normalize helpers but **composes server-controlled +fields from the stored document** rather than generating them: + +- `attack_id`, `revoked`, `x_mitre_attack_spec_version`, and the ATT&CK + `external_references[0]` are taken from the existing document. +- `x_mitre_is_subtechnique` is preserved from the stored doc (changing it requires the + dedicated conversion endpoints, not the generic update path). +- `workspace.validation` is cleared once validation passes. + +So categories **D, E** apply to PUT as "carried forward from the stored doc" rather than +"generated". + +## Applicability matrix + +| Category | SDOs (technique, tactic, group, software, mitigation, asset, …) | Matrices | Marking definitions / Identities | Relationships | +| --- | --- | --- | --- | --- | +| A, B, C, G, H | āœ… | āœ… | āœ… | āœ… | +| D (ATT&CK ID) | āœ… | āš ļø external_id is the domain name, not an auto ID | āŒ no ATT&CK ID | āŒ | +| E1, E2 | āœ… | āœ… | partial | partial | +| E3 (identity refs) | āœ… | āœ… | āš ļø identities created during bootstrap | āœ… | +| F3 (WIP vs full) | āœ… | āœ… | āœ… (simple schema) | āœ… | + +Types in the āš ļø/āŒ columns get the shared categories that apply plus a small set of +type-specific cases; they should **not** re-implement the categories that don't apply. + +## Test suite organization + +A per-type capability (techniques, tactics, …) is covered by **several spec files split +by behavior**, not one monolith. Using `app/tests/api/techniques/` as the model: + +| Spec | Scope | Taxonomy categories | +| --- | --- | --- | +| `.spec.js` | Core CRUD lifecycle | A, B, C, E, F, G, H | +| `-pagination.spec.js` | Pagination (offset/limit/`includePagination`) | G2 (read) | +| `.query.spec.js` | GET filter parameters (`search`, `state`, `includeRevoked`, domains, …) | G2 (read) | +| `.convert.spec.js` | Type-specific endpoints (e.g. sub/technique conversion) | type-specific | +| `.tactics.spec.js` | Type-specific relationship endpoints | type-specific | +| `.revoke.spec.js` | Revoke workflow | type-specific | + +**This decomposition is correct and should be preserved.** Each file is cohesive, names +its failures clearly, and can be run alone. The decomposition is not the problem; the +inconsistencies within it are. When refactoring, fix these and **do not** collapse files: + +- **One fixture per type.** Today each spec defines its own `initialObjectData` and they + have drifted. Converge on a single shared ADM-compliant baseline per type (see the + refactor steps below) so an ADM rule change is fixed in one place, not six. +- **Consistent naming.** Use `.` separators (`.pagination.spec.js`), not a mix of + `-` and `.`. +- **Put search/filter tests in `.query`**, not in the core CRUD spec. + +### Bootstrapping styles (choose deliberately) + +Three ways a suite gets the database into a known state are in use. All are legitimate; +the choice should be intentional: + +1. **HTTP-driven** (`request(app).post(...)`) — exercises the *full* stack: OpenAPI request + validation → controller → service → ADM validation → persistence. Use this when the + behavior under test **is** the HTTP contract (the core CRUD spec, convert, revoke). +2. **Service-driven** (`service.create(...)`) — seeds state by calling the service directly. + Faster and less verbose, but **bypasses the controller and OpenAPI request-validation + middleware**. ADM validation still runs (it lives in `BaseService.create`), so seeded + fixtures must still be ADM-compliant. Use this to set up *preconditions* for tests whose + real subject is reads/queries (`query`, `pagination`). +3. **Bundle-import** (collection bundle JSON via the import path) — seeds many related + objects at once. Use for relationship/graph-shaped fixtures (`tactics`). + +> Refactor consequence: because service-driven and bundle-import seeding still run ADM +> validation, the `query`, `pagination`, and `tactics` fixtures need the **same** +> compliance fixes as the core spec — they are not exempt just because they skip HTTP. + +## Test independence + +The industry baseline is **F.I.R.S.T.**: Fast, **Independent**, Repeatable, +Self-validating, Timely. "Independent" means a test establishes its own preconditions and +passes regardless of execution order or what ran before it. + +The distinction that matters in practice is **what** a test depends on: + +- Depending on a **`before`/`beforeEach` hook** for shared state — āœ… good. The dependency + is explicit and re-established on every run; tests within the block stay order-independent. +- Depending on a **previous `it` block having executed** — āš ļø fragile. Mocha runs in + definition order and never randomizes, so it works, but you cannot run a single `it` in + isolation (`it.only`), and a failure mid-chain cascades into misleading downstream + failures. + +**The one sanctioned exception** is a deliberately **sequential CRUD/E2E narrative** +(create → read → update → new version → delete) where per-test bootstrapping would be +wasteful and the *flow itself* is the thing under test. This is idiomatic for integration +suites — provided it is **deliberate and contained**: + +- keep it in a single `describe` with its own database lifecycle (`before`/`after`), +- comment it as an intentional sequential flow, +- keep the chain short; pull shared preconditions into `before` rather than relying on a + sibling `it` wherever practical. + +### Policy + +- **Core CRUD spec** (`.spec.js`): a sequential narrative is acceptable — label it as + such. Everything else should be hook-seeded and order-independent. +- **Feature specs** (`query`, `pagination`, `convert`, `tactics`, `revoke`): seed shared + state in `before`/`beforeEach`; individual `it` blocks must not depend on a sibling `it`. +- **Never** rely on cross-*file* state. Each spec owns its database lifecycle. + +## Using this during the refactor + +For each suite we convert to ADM-on: + +1. Build a single ADM-compliant `initialObjectData` for the type (state at rest, fully + composed — see [`techniques.spec.js`](../../app/tests/api/techniques/techniques.spec.js)). +2. Walk the taxonomy and confirm the suite exercises each applicable category, adding the + missing ones (the `stix.id`/`created`/`modified` provided-vs-omitted cases and the + ATT&CK-ID idempotency cases **D3/D4** are the ones most commonly absent today). +3. Do **not** assert that server-controlled fields echo the client's input — assert the + server's rule instead (e.g. "`attack_id` is defined and matches `T####`", not + "`attack_id === 'T9999'`"). + +Once the taxonomy is encoded once (ideally as shared assertion helpers in +`app/tests/shared/`), each per-type suite shrinks to: a valid baseline object + the +type-specific deltas. From 47ca5d2bf7040c476bdd94f59bb32106bb160abc Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:26:45 -0400 Subject: [PATCH 2/4] docs: remove developer artifacts --- .nocommit/backend-field-test.js | 365 -------- .nocommit/docs/99_IMPLEMENTATION_PLAN.md | 401 --------- .../99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md | 842 ------------------ .../docs/99_POST_IMPL_TESTING_OBSERVATIONS.md | 349 -------- .../99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md | 638 ------------- .../docs/99_SERVICE_LAYER_IMPL_PROMPTS.md | 50 -- .nocommit/field-requirements-probe.js | 457 ---------- .nocommit/plans/workflow-response-ontology.md | 106 --- .nocommit/prompts/adm-validation-explainer.md | 29 - .nocommit/prompts/fix-import-pipeline.md | 117 --- ...-prefix-and-adm-validation-bypass-rules.md | 105 --- ...s-for-revoke-and-subtechnique-workflows.md | 115 --- .../prompts/organization-identity-syncing.md | 69 -- .nocommit/prompts/refactor-export-service.md | 66 -- ...nclude_candidates_in_snapshots property.md | 36 - ...tre-version-from-existing-relationships.md | 72 -- .nocommit/prompts/revoke-workflow-bug.md | 48 - .nocommit/prompts/revoke-workflow.md | 269 ------ .nocommit/prompts/synthetic-data-generator.md | 625 ------------- ...nique-sub-parent-conversion-partial-fix.md | 23 - ...echnique-sub-parent-conversion-workflow.md | 19 - .nocommit/prompts/validation-bypass-rules.md | 424 --------- .../prompts/validation-bypasses-impl-plan.md | 165 ---- .nocommit/prompts/virtual tracks.md | 102 --- .nocommit/run-field-probe.js | 383 -------- .nocommit/test-mongoose-serialization.js | 136 --- 26 files changed, 6011 deletions(-) delete mode 100644 .nocommit/backend-field-test.js delete mode 100644 .nocommit/docs/99_IMPLEMENTATION_PLAN.md delete mode 100644 .nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md delete mode 100644 .nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md delete mode 100644 .nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md delete mode 100644 .nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md delete mode 100644 .nocommit/field-requirements-probe.js delete mode 100644 .nocommit/plans/workflow-response-ontology.md delete mode 100644 .nocommit/prompts/adm-validation-explainer.md delete mode 100644 .nocommit/prompts/fix-import-pipeline.md delete mode 100644 .nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md delete mode 100644 .nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md delete mode 100644 .nocommit/prompts/organization-identity-syncing.md delete mode 100644 .nocommit/prompts/refactor-export-service.md delete mode 100644 .nocommit/prompts/remove the include_candidates_in_snapshots property.md delete mode 100644 .nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md delete mode 100644 .nocommit/prompts/revoke-workflow-bug.md delete mode 100644 .nocommit/prompts/revoke-workflow.md delete mode 100644 .nocommit/prompts/synthetic-data-generator.md delete mode 100644 .nocommit/prompts/technique-sub-parent-conversion-partial-fix.md delete mode 100644 .nocommit/prompts/technique-sub-parent-conversion-workflow.md delete mode 100644 .nocommit/prompts/validation-bypass-rules.md delete mode 100644 .nocommit/prompts/validation-bypasses-impl-plan.md delete mode 100644 .nocommit/prompts/virtual tracks.md delete mode 100644 .nocommit/run-field-probe.js delete mode 100644 .nocommit/test-mongoose-serialization.js diff --git a/.nocommit/backend-field-test.js b/.nocommit/backend-field-test.js deleted file mode 100644 index e24ccab1..00000000 --- a/.nocommit/backend-field-test.js +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -/** - * Backend Field Requirements Test - * - * This script specifically tests the fields that are typically set by the backend - * to identify which ones the frontend needs to start including when posting - * "awaiting-review" objects. - * - * Focus areas: - * 1. Core STIX fields: type, spec_version, created, modified - * 2. Backend-managed fields: x_mitre_attack_spec_version, x_mitre_domains, x_mitre_modified_by_ref - * 3. STIX identity fields: created_by_ref, object_marking_refs - */ - -const supertest = require('supertest'); -const { v4: uuidv4 } = require('uuid'); - -// Import your Express app -const app = require('./app/app'); - -// Test configuration -const TEST_CONFIG = { - // Standard test identity and marking refs used by the backend - IDENTITY_REF: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - MARKING_REF: 'marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168', - ATTACK_SPEC_VERSION: '3.3.0', - DOMAINS: ['enterprise-attack'] -}; - -/** - * Backend-managed fields that we want to test - */ -const BACKEND_FIELDS_TO_TEST = [ - 'x_mitre_attack_spec_version', - 'x_mitre_domains', - 'x_mitre_modified_by_ref', - 'created_by_ref', - 'object_marking_refs' -]; - -/** - * Core STIX fields that should always be required - */ -const CORE_STIX_FIELDS = [ - 'type', - 'spec_version', - 'created', - 'modified' -]; - -/** - * Create base valid objects for testing - */ -function createBaseObjects() { - const now = new Date().toISOString(); - - const baseStixObject = { - type: 'attack-pattern', // Will be overridden per object type - spec_version: '2.1', - id: '', // Will be set per test - created: now, - modified: now, - name: 'Test Object', - description: 'Test description', - x_mitre_attack_spec_version: TEST_CONFIG.ATTACK_SPEC_VERSION, - x_mitre_version: '1.0', - x_mitre_domains: TEST_CONFIG.DOMAINS, - x_mitre_modified_by_ref: TEST_CONFIG.IDENTITY_REF, - created_by_ref: TEST_CONFIG.IDENTITY_REF, - object_marking_refs: [TEST_CONFIG.MARKING_REF], - external_references: [{ - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/techniques/T9999' - }] - }; - - return { - technique: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'T9999' - }, - stix: { - ...baseStixObject, - type: 'attack-pattern', - id: 'attack-pattern--' + uuidv4(), - x_mitre_is_subtechnique: false - } - }, - - tactic: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'TA9999' - }, - stix: { - ...baseStixObject, - type: 'x-mitre-tactic', - id: 'x-mitre-tactic--' + uuidv4(), - x_mitre_shortname: 'test-tactic', - external_references: [{ - source_name: 'mitre-attack', - external_id: 'TA9999', - url: 'https://attack.mitre.org/tactics/TA9999' - }] - } - }, - - campaign: { - workspace: { - workflow: { state: 'awaiting-review' } - }, - stix: { - ...baseStixObject, - type: 'campaign', - id: 'campaign--' + uuidv4(), - aliases: ['Test Campaign'], - first_seen: now, - last_seen: now, - x_mitre_first_seen_citation: '(Citation: Test)', - x_mitre_last_seen_citation: '(Citation: Test)', - revoked: false, - external_references: [{ - source_name: 'Test', - description: 'Test reference' - }] - } - } - }; -} - -/** - * Test field requirements for a specific object type - */ -async function testObjectFieldRequirements(objectType, endpoint, baseObject) { - console.log(`\nšŸ” Testing ${objectType} (${endpoint})`); - - const results = { - objectType, - endpoint, - coreFieldResults: {}, - backendFieldResults: {}, - validationErrors: [] - }; - - // Test 1: Core STIX fields - these should ALWAYS be required - console.log(`\n Testing core STIX fields...`); - for (const field of CORE_STIX_FIELDS) { - try { - const testObject = JSON.parse(JSON.stringify(baseObject)); - delete testObject.stix[field]; - - const response = await makeRequest(endpoint, testObject); - results.coreFieldResults[field] = { - required: response.status === 400, - status: response.status, - error: response.status === 400 ? response.body : null - }; - - console.log(` ${field}: ${response.status === 400 ? 'āŒ Required' : 'āœ… Optional'}`); - } catch (error) { - results.coreFieldResults[field] = { error: error.message }; - console.log(` ${field}: šŸ’„ Error - ${error.message}`); - } - } - - // Test 2: Backend-managed fields - these are the problematic ones - console.log(`\n Testing backend-managed fields...`); - for (const field of BACKEND_FIELDS_TO_TEST) { - try { - const testObject = JSON.parse(JSON.stringify(baseObject)); - delete testObject.stix[field]; - - const response = await makeRequest(endpoint, testObject); - results.backendFieldResults[field] = { - required: response.status === 400, - status: response.status, - error: response.status === 400 ? response.body : null - }; - - const status = response.status === 400 ? 'āŒ Required (PROBLEM!)' : 'āœ… Optional'; - console.log(` ${field}: ${status}`); - - if (response.status === 400) { - results.validationErrors.push({ - field, - error: response.body - }); - } - } catch (error) { - results.backendFieldResults[field] = { error: error.message }; - console.log(` ${field}: šŸ’„ Error - ${error.message}`); - } - } - - // Test 3: Full object with "awaiting-review" status - console.log(`\n Testing complete awaiting-review object...`); - try { - const response = await makeRequest(endpoint, baseObject); - console.log(` Complete object: ${response.status < 400 ? 'āœ… Valid' : 'āŒ Invalid'}`); - if (response.status >= 400) { - console.log(` Error: ${JSON.stringify(response.body, null, 2)}`); - } - } catch (error) { - console.log(` Complete object: šŸ’„ Error - ${error.message}`); - } - - return results; -} - -/** - * Make HTTP request with proper headers - */ -async function makeRequest(endpoint, data) { - return supertest(app) - .post(endpoint) - .send(data) - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer test-token') // Adjust auth as needed - .timeout(5000); -} - -/** - * Generate analysis report - */ -function generateAnalysisReport(allResults) { - console.log('\n' + '='*80); - console.log('šŸ“Š BACKEND FIELD REQUIREMENTS ANALYSIS'); - console.log('='*80); - - // Analyze core STIX field requirements - console.log(`\nšŸ”’ CORE STIX FIELD ANALYSIS:`); - const coreFieldAnalysis = {}; - CORE_STIX_FIELDS.forEach(field => { - const results = allResults.map(r => r.coreFieldResults[field]?.required).filter(Boolean); - const requiredCount = results.filter(Boolean).length; - const totalCount = results.length; - coreFieldAnalysis[field] = { requiredCount, totalCount }; - console.log(` ${field}: Required in ${requiredCount}/${totalCount} schemas`); - }); - - // Analyze backend field requirements - THIS IS THE KEY SECTION - console.log(`\nāš ļø BACKEND FIELD ANALYSIS (PROBLEMATIC FIELDS):`); - const problematicFields = []; - - BACKEND_FIELDS_TO_TEST.forEach(field => { - const results = allResults.map(r => r.backendFieldResults[field]); - const requiredResults = results.filter(r => r?.required); - const totalTested = results.filter(r => r && !r.error).length; - - if (requiredResults.length > 0) { - problematicFields.push({ - field, - requiredIn: requiredResults.length, - totalTested, - schemas: allResults - .filter(r => r.backendFieldResults[field]?.required) - .map(r => r.objectType) - }); - - console.log(` āŒ ${field}: Required in ${requiredResults.length}/${totalTested} schemas`); - console.log(` Affects: ${problematicFields[problematicFields.length - 1].schemas.join(', ')}`); - } else { - console.log(` āœ… ${field}: Optional in all schemas`); - } - }); - - // Frontend recommendations - console.log(`\nšŸŽÆ FRONTEND RECOMMENDATIONS:`); - - if (problematicFields.length === 0) { - console.log(` āœ… Good news! No backend-managed fields are required for "awaiting-review" objects.`); - } else { - console.log(` āš ļø The frontend needs to include these fields when posting "awaiting-review" objects:`); - problematicFields.forEach(pf => { - console.log(`\n šŸ“‹ ${pf.field}:`); - console.log(` - Required in: ${pf.schemas.join(', ')}`); - console.log(` - Recommended value: Check backend configuration`); - - // Provide specific recommendations - if (pf.field === 'x_mitre_attack_spec_version') { - console.log(` - Suggested value: "${TEST_CONFIG.ATTACK_SPEC_VERSION}"`); - } else if (pf.field === 'x_mitre_domains') { - console.log(` - Suggested value: ${JSON.stringify(TEST_CONFIG.DOMAINS)}`); - } else if (pf.field === 'x_mitre_modified_by_ref') { - console.log(` - Suggested value: "${TEST_CONFIG.IDENTITY_REF}"`); - } else if (pf.field === 'created_by_ref') { - console.log(` - Suggested value: "${TEST_CONFIG.IDENTITY_REF}"`); - } else if (pf.field === 'object_marking_refs') { - console.log(` - Suggested value: ["${TEST_CONFIG.MARKING_REF}"]`); - } - }); - } - - // Validation errors summary - const allValidationErrors = allResults.flatMap(r => r.validationErrors); - if (allValidationErrors.length > 0) { - console.log(`\nšŸ’„ VALIDATION ERRORS ENCOUNTERED:`); - allValidationErrors.forEach(err => { - console.log(` - ${err.field}: ${JSON.stringify(err.error)}`); - }); - } - - return { coreFieldAnalysis, problematicFields, allValidationErrors }; -} - -/** - * Main execution - */ -async function main() { - console.log('šŸš€ Starting Backend Field Requirements Test'); - console.log('='*80); - - const baseObjects = createBaseObjects(); - const testCases = [ - { type: 'technique', endpoint: '/api/techniques', object: baseObjects.technique }, - { type: 'tactic', endpoint: '/api/tactics', object: baseObjects.tactic }, - { type: 'campaign', endpoint: '/api/campaigns', object: baseObjects.campaign } - ]; - - const allResults = []; - - for (const testCase of testCases) { - try { - const results = await testObjectFieldRequirements( - testCase.type, - testCase.endpoint, - testCase.object - ); - allResults.push(results); - - // Small delay between tests - await new Promise(resolve => setTimeout(resolve, 100)); - } catch (error) { - console.error(`šŸ’„ Failed to test ${testCase.type}:`, error.message); - } - } - - // Generate comprehensive analysis - const analysis = generateAnalysisReport(allResults); - - console.log('\nāœ… Backend field requirements test complete!'); - return analysis; -} - -// Export for potential use as module -module.exports = { - createBaseObjects, - testObjectFieldRequirements, - generateAnalysisReport, - BACKEND_FIELDS_TO_TEST, - CORE_STIX_FIELDS -}; - -// Run if called directly -if (require.main === module) { - main().catch(error => { - console.error('šŸ’„ Test failed:', error); - process.exit(1); - }); -} \ No newline at end of file diff --git a/.nocommit/docs/99_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 69cb96b0..00000000 --- a/.nocommit/docs/99_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,401 +0,0 @@ -# Release Tracks V2 - Implementation Design - -## Architecture Overview - -``` - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ release-tracks-routes.js │ (auto-discovered) - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - │ - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ release-tracks-controller.js │ - │ (Zod validation, response formatting) │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - │ - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ release-tracks-service.js │ - │ (Facade / Orchestrator) │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ snapshot │ standard │ version │ virtual │ export │ - │ -service │ -track │ -ing │ -track │ -service │ - │ │ -service │ -service │ -service │ │ - │ │ │ │ │ │ - │ │ workflow │ │ │ │ - │ │ -service │ │ │ │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - │ │ - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ registry.repo │ │ dynamic.repo │ - │ (releaseTrack │ │ (per-track collections │ - │ Registry coll) │ │ via ModelFactory) │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - │ - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ ModelFactory │ - │ (dynamic Mongoose │ - │ model cache) │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - -### Key Architectural Decision: Collection-per-Track - -Each release track gets its own MongoDB collection (named `release-track--`). A central `releaseTrackRegistry` collection indexes all tracks for discovery/listing. This requires a **ModelFactory** that dynamically creates and caches Mongoose models at runtime -- the single most novel infrastructure component. - -### Relationship to Existing V2 Stubs - -The existing `collections-controller-v2.js` and `collections-routes-v2.js` serve endpoints under `/api/collections/`. These will be **deleted** and replaced by the new `/api/release-tracks/` routes. The existing stubs can serve as reference for patterns (Zod validation, error handling) but the new implementation starts fresh under the `release-tracks` namespace. Clean break -- no dual-routing. - ---- - -## File Layout - -``` -app/ - models/release-tracks/ - release-track-registry-model.js ← Mongoose schema for registry collection - release-track-snapshot-schema.js ← Reusable schema for per-track snapshot docs - model-factory.js ← Dynamic Mongoose model creation/caching - - repository/release-tracks/ - release-track-registry.repository.js ← CRUD on releaseTrackRegistry collection - release-track-dynamic.repository.js ← CRUD on per-track collections via ModelFactory - - routes/ - release-tracks-routes.js ← All ~30 endpoints (auto-discovered) - - controllers/ - release-tracks-controller.js ← Request parsing, validation, delegation - - services/release-tracks/ - release-tracks-service.js ← Facade: delegates to sub-services - snapshot-service.js ← Snapshot creation, clone, delete - standard-track-service.js ← Candidate/staged/member management - workflow-service.js ← Auto-promotion, candidacy threshold logic - versioning-service.js ← Bump/tag, version history, preview - virtual-track-service.js ← Composition resolution, deduplication - export-service.js ← bundle/workbench/filesystemstore serialization - ephemeral-service.js ← Stateless domain bundle generation - - lib/release-tracks/ - release-track-validators.js ← Zod schemas for all request bodies - version-utils.js ← MAJOR.MINOR parse/compare/increment - deduplication-strategies.js ← 4 strategies for virtual track conflicts - resolution-strategies.js ← 3 strategies for component track resolution - conflict-resolution-policies.js ← 4 policies for tier promotion conflicts - snapshot-differ.js ← Diff/preview between snapshots - - scheduler/ - virtual-track-snapshot-task.js ← Auto-discovered by scheduler/index.js - - exceptions/index.js ← Add: ReleaseConflictError, NoTaggedSnapshotsError, - InvalidComponentTypeError, TrackNotFoundError - - lib/event-constants.js ← Add: release-track::created, ::released, - ::candidate-added, ::object-staged, etc. - - lib/error-handler.js ← Add: HTTP status mappings for new exceptions -``` - ---- - -## Data Model - -### Registry Collection (`releaseTrackRegistry`) - -```javascript -{ - track_id: "release-track--", // unique, indexed -- also the Mongo collection name - type: "standard" | "virtual", - name: "Enterprise ATT&CK", - description: "...", - - // Denormalized for fast listing (updated on each snapshot/tag) - latest_snapshot_modified: Date, - latest_tagged_version: String | null, - snapshot_count: Number, - tagged_release_count: Number, - - // Virtual tracks only - snapshot_schedule: { mode, cron?, dates? }, - - created_at: Date, - updated_at: Date -} -``` - -### Snapshot Document (one per doc in per-track collection) - -```javascript -{ - id: "release-track--", // same across all snapshots in this collection - type: "standard" | "virtual", - modified: Date, // unique per snapshot, compound index with id - version: String | null, // null = draft, "X.Y" = tagged - - name: String, - description: String, - created: Date, // when the track was first created - created_by_ref: String, - object_marking_refs: [String], - - // --- Standard track tiers --- - members: [{ object_ref, object_modified }], - staged: [{ object_ref, object_modified, object_status, object_staged_at, object_staged_by }], - candidates: [{ object_ref, object_modified, object_status, object_added_at, object_added_by }], - - // --- Virtual track tiers --- - quarantine: [{ object_ref, object_modified, source_track_id, source_track_name, - source_snapshot_version, conflict_reason }], - - // --- Virtual track composition --- - composition: { - component_tracks: [{ track_id, resolution_strategy, priority, version?, snapshot?, filters? }], - deduplication: { strategy } - }, - composition_resolution: { resolved_at, component_snapshots[], deduplication{}, summary{} }, - - // --- Shared --- - config: { - candidacy_threshold: "reviewed", - auto_promote: true, - promotion_conflicts: { candidates_to_staged, staged_to_members } - }, - version_history: [{ version, tagged_at, tagged_by, snapshot_id, summary, component_versions? }] -} -``` - -### Object Documents (existing attackObjects) - -**Deferred**: The `workspace.referenced_by` reverse index (tracking which release tracks reference each object version) is **not** included in the initial scaffolding. Queries like "which tracks contain this object?" will require scanning track collections rather than a reverse lookup. This will be added as a follow-up phase once the core release track system is stable. - ---- - -## Data Flow: Key Operations - -### 1. Create Standard Track - -``` -POST /new → controller → releaseTracksService.createTrack() - → Generate UUID → "release-track--" - → registryRepo.create({ track_id, name, type: "standard", ... }) - → modelFactory.getModel(track_id) → creates Mongoose model + Mongo collection - → dynamicRepo.saveSnapshot(track_id, { id, modified: now, version: null, members: [], ... }) - → modelFactory.ensureIndexes(track_id) - → eventBus.emit('release-track::created') - → Return initial snapshot -``` - -### 2. Add Candidates + Auto-Promotion - -``` -POST /:id/candidates → controller → standardTrackService.addCandidates() - → dynamicRepo.getLatestSnapshot(trackId) - → For each object_ref: - → Validate object exists in attackObjects collection - → Resolve modified (use latest if omitted) - → Check duplicates in candidates[] - → Create candidate entry { object_ref, object_modified, status: "work-in-progress" } - → workflowService.checkAutoPromotion(): - If status meets config.candidacy_threshold → move to staged[] - If conflict with existing staged entry → apply conflict resolution policy - → Clone snapshot with new modified timestamp, save - → Update registry (latest_snapshot_modified, snapshot_count) - → eventBus.emit('release-track::candidate-added') - → Return { added, autoPromoted, errors } -``` - -### 3. Bump/Tag - -``` -POST /:id/bump → controller → versioningService.bumpLatest() - → dynamicRepo.getLatestSnapshot(trackId) - → Validate version === null (else throw AlreadyReleasedError) - → versionUtils.calculateNextVersion(version_history, bumpType) - → workflowService.promoteStagedToMembers(): - Resolve dynamic pins ("latest" → actual modified timestamp) - For each staged entry: check conflict with members → apply policy - If policy=abort and conflicts exist → throw ReleaseConflictError - Move staged → members, clear staged - → If dry_run: return computed result without persisting - → dynamicRepo.tagSnapshotInPlace(trackId, modified, { version, historyEntry }) - → Update registry (latest_tagged_version, tagged_release_count) - → eventBus.emit('release-track::released') - → Return tagged snapshot with release_summary -``` - -### 4. Virtual Track Snapshot Creation - -``` -POST /:id/snapshots/create → controller → virtualTrackService.createSnapshot() - → dynamicRepo.getLatestSnapshot(trackId) - → Validate type === "virtual" - → For each component_tracks entry: - → resolutionStrategies.resolve(component, dynamicRepo) → component snapshot - → Validate snapshot is tagged (version !== null) - → Validate component is standard (not virtual) - → Collect members from resolved snapshot - → Apply filters (object_types, domains) - → deduplicationStrategies.deduplicate(allObjects, config) → { members, quarantine, report } - → Build snapshot: { modified: now, version: null, members, quarantine, composition_resolution } - → dynamicRepo.saveSnapshot(trackId, snapshot) - → Update registry - → Return snapshot with composition_resolution -``` - -### 5. Export as STIX Bundle - -``` -GET /:id?format=bundle → controller → exportService.exportAsBundle() - → Resolve snapshot (latest, or by version/modified) - → Build x-mitre-collection SDO from snapshot metadata - → Batch-fetch member objects from attackObjects collection - → Strip workspace data, keep only stix.* properties - → Return { type: "bundle", id: "bundle--", objects: [collectionSDO, ...memberObjects] } -``` - ---- - -## Parallel Work Streams - -Seven independent streams with a clean dependency graph: - -``` - WS1 (Infrastructure) ←── Foundation, no dependencies - │ - ā”œā”€ā”€ WS2 (Routes + Controller) ←── Independent (uses service stubs) - │ - ā”œā”€ā”€ WS3 (Standard Track Core) ←── depends on WS1 - │ │ - │ ā”œā”€ā”€ WS4 (Workflow Engine) ←── depends on WS1 - │ │ │ - │ │ └── WS5 (Versioning) ←── depends on WS1, optionally WS4 - │ │ - │ └── WS7 (Export Engine) ←── depends on WS1 - │ - └── WS6 (Virtual Track Engine) ←── depends on WS1 only -``` - -### WS1: Infrastructure Layer (Foundation) -**Scope**: MongoDB schemas, ModelFactory, both repositories -**Files**: `models/release-tracks/*`, `repository/release-tracks/*` -**Tests**: Unit tests for ModelFactory caching, registry CRUD, dynamic repo CRUD - -### WS2: Route + Controller Shell -**Scope**: All ~30 endpoints wired with auth/validation, returning 501 until services ready -**Files**: `routes/release-tracks-routes.js`, `controllers/release-tracks-controller.js`, `lib/release-tracks/release-track-validators.js` -**Tests**: Route registration, auth gating, Zod validation (400 on bad input) - -### WS3: Standard Track Core (Create, Read, Delete, Clone) -**Scope**: Track lifecycle -- create, list, retrieve, clone, delete, metadata/contents update, ephemeral bundles -**Files**: `services/release-tracks/release-tracks-service.js` (facade), `snapshot-service.js`, `ephemeral-service.js` -**Tests**: Full lifecycle tests against in-memory MongoDB - -### WS4: Workflow Engine (Candidates, Staging, Auto-Promotion) -**Scope**: Three-tier lifecycle, status transitions, auto-promotion, conflict resolution -**Files**: `services/release-tracks/standard-track-service.js`, `workflow-service.js`, `lib/release-tracks/conflict-resolution-policies.js` -**Tests**: Promotion flows, threshold configs, conflict scenarios - -### WS5: Versioning Engine (Bump, Tag, Preview, Dry-Run) -**Scope**: Version calculation, in-place tagging, version history, preview/diff, dry-run -**Files**: `services/release-tracks/versioning-service.js`, `lib/release-tracks/version-utils.js`, `lib/release-tracks/snapshot-differ.js` -**Tests**: Version arithmetic, tag validation, preview accuracy - -### WS6: Virtual Track Engine (Composition, Resolution, Deduplication) -**Scope**: Virtual track CRUD, composition resolution, deduplication, quarantine, scheduling -**Files**: `services/release-tracks/virtual-track-service.js`, `lib/release-tracks/resolution-strategies.js`, `lib/release-tracks/deduplication-strategies.js`, `scheduler/virtual-track-snapshot-task.js` -**Tests**: Resolution strategy correctness, dedup strategy correctness, quarantine flows - -### WS7: Export Engine (Bundle, FileSystemStore, Workbench Formats) -**Scope**: Serialize snapshots to output formats, fetch STIX objects, strip workspace data -**Files**: `services/release-tracks/export-service.js` -**Tests**: Format correctness, large bundle handling - ---- - -## Interface Contracts - -### ModelFactory -```javascript -getModel(trackId: string): mongoose.Model // Get or create cached model -removeModel(trackId: string): void // Cleanup on track delete -ensureIndexes(trackId: string): Promise // Create indexes on new collection -``` - -### RegistryRepository -```javascript -create(data): Promise // Register new track -findByTrackId(trackId): Promise // Lookup single track -findAll(options): Promise<{data, pagination}> // List with filtering/pagination -updateByTrackId(trackId, updates): Promise // Update denormalized fields -deleteByTrackId(trackId): Promise // Remove registry entry -``` - -### DynamicRepository -```javascript -getLatestSnapshot(trackId): Promise -getSnapshotByModified(trackId, modified): Promise -getLatestTaggedSnapshot(trackId): Promise -getSnapshotByVersion(trackId, version): Promise -getAllSnapshots(trackId, options): Promise -saveSnapshot(trackId, snapshotData): Promise -tagSnapshotInPlace(trackId, modified, versionData): Promise -updateSnapshot(trackId, modified, updateOps): Promise -deleteSnapshot(trackId, modified): Promise -deleteAllSnapshots(trackId): Promise -dropCollection(trackId): Promise // Drops entire Mongo collection -``` - -### Facade Service (consumed by controller) -```javascript -// Track management -listTracks(options), createTrack(data), getLatestSnapshot(trackId, options), -getSnapshotByModified(trackId, modified, options), updateMetadata(trackId, updates), -updateContents(trackId, contents), cloneTrack(trackId, options), -deleteTrack(trackId), deleteSnapshot(trackId, modified), -createTrackFromBundle(bundleData) - -// Candidates -addCandidates(trackId, objectRefs, userId), listCandidates(trackId, options), -removeCandidate(trackId, objectRef), reviewCandidates(trackId, reviewData), -promoteCandidates(trackId, objectRefs), updateCandidateVersion(trackId, objectRef, data) - -// Staged -listStaged(trackId), demoteStaged(trackId, objectRefs) - -// Versioning -bumpLatest(trackId, options), bumpSpecific(trackId, modified, options), previewBump(trackId) - -// Config -getConfig(trackId), updateConfig(trackId, config) - -// Virtual -updateComposition(trackId, composition), createVirtualSnapshot(trackId, options), -previewVirtualSnapshot(trackId) - -// Export -exportSnapshot(trackId, modified, format), getEphemeralBundle(domain, format) -``` - ---- - -## Integration Points with Existing Codebase - -| Concern | How to Integrate | -|---------|-----------------| -| **Route auto-discovery** | File named `release-tracks-routes.js` in `app/routes/` is auto-registered | -| **Auth middleware** | Use existing `authn.authenticate` + `authz.requireRole()` | -| **Error handling** | Add 4 new exception classes to `app/exceptions/index.js`; add HTTP mappings to `app/lib/error-handler.js` | -| **Event system** | Add ~10 event constants to `app/lib/event-constants.js`; emit from services | -| **Scheduler** | File named `virtual-track-snapshot-task.js` in `app/scheduler/` is auto-discovered | -| **STIX object lookup** | Use existing services (techniques, groups, etc.) for object validation and bulk retrieval | -| **Validation** | Zod schemas in `release-track-validators.js`; ADM schema for bundle import | - ---- - -## Verification Plan - -1. **Unit tests**: Each utility module (`version-utils`, `deduplication-strategies`, `resolution-strategies`, `conflict-resolution-policies`) tested in isolation -2. **Repository tests**: ModelFactory + DynamicRepository against `mongodb-memory-server` -3. **Service integration tests**: Each sub-service tested with real MongoDB, mocked dependencies -4. **API integration tests**: Full HTTP round-trips via supertest for all ~30 endpoints -5. **End-to-end scenario tests**: Multi-step workflows (create track → add candidates → review → bump → export) diff --git a/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md deleted file mode 100644 index cdc0ee73..00000000 --- a/.nocommit/docs/99_MEMBER_SYNC_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,842 +0,0 @@ -# Member Sync Strategies — Implementation Plan - -> **Purpose**: Implementation guide for the Member Sync Strategies feature documented in [08_MEMBER_SYNC_STRATEGIES.md](./08_MEMBER_SYNC_STRATEGIES.md). This plan follows the architecture established in [99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md](./99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md). - ---- - -## Status Tracker - -| Phase | Description | Status | -|-------|-------------|--------| -| A | Schema Updates (Mongoose + Zod + Defaults) | TODO | -| B | Member Sync Service (Core Logic) | TODO | -| C | Event Integration (STIX Object Hooks) | TODO | -| D | Testing & Verification | TODO | - ---- - -## Architecture Integration - -### Where Member Sync Fits - -``` -Controller - └─▶ release-tracks-service.js (Facade) - ā”œā”€ā–¶ snapshot-service.js ← Phase 1 (existing) - ā”œā”€ā–¶ standard-track-service.js ← Phase 2 (existing) - ā”œā”€ā–¶ workflow-service.js ← Phase 3 (existing) - ā”œā”€ā–¶ versioning-service.js ← Phase 4 (existing) - ā”œā”€ā–¶ virtual-track-service.js ← Phase 5 (existing) - └─▶ member-sync-service.js ← NEW (this plan) - -STIX Object Services (techniques, groups, malware, etc.) - └─▶ EventBus.emit('stix-object::modified') - └─▶ member-sync-service.handleObjectModified() -``` - -### Design Decision: Dedicated Service vs. Workflow Extension - -**Choice**: Create a new `member-sync-service.js` rather than extending `workflow-service.js`. - -**Rationale**: -1. **Single Responsibility**: Workflow service handles candidacy thresholds and auto-promotion. Member sync is a distinct concern (event-driven enrollment from external modifications). -2. **Event-Driven Nature**: Member sync listens to STIX object events, while workflow service operates on release track events. -3. **Testability**: A dedicated service can be tested in isolation with mocked events. -4. **Future Flexibility**: Member sync may evolve independently (e.g., per-object exclusions, batch processing). - -### Dependencies - -``` -member-sync-service.js - ā”œā”€ā–¶ registryRepo (find tracks where object is a member) - ā”œā”€ā–¶ dynamicRepo (read/write snapshots) - ā”œā”€ā–¶ snapshotService (cloneSnapshot helper) - ā”œā”€ā–¶ workflowService (auto-promotion after enrollment) - └─▶ EventBus (subscribe to stix-object events) -``` - ---- - -## Phase A: Schema Updates - -### Scope - -Update all schema layers to support the `member_sync` configuration: -1. Mongoose schema (database storage) -2. Zod schemas (request/response validation) -3. Default configuration in `createTrack()` - -### A.1: Mongoose Schema Update - -**File**: `app/models/release-tracks/release-track-snapshot-schema.js` - -Add `member_sync` to the `config` subdocument: - -```javascript -config: { - // Existing fields... - candidacy_threshold: { - type: String, - enum: ['work-in-progress', 'awaiting-review', 'reviewed'], - default: 'reviewed' - }, - auto_promote: { - type: Boolean, - default: true - }, - promotion_conflicts: { - candidates_to_staged: { - type: String, - enum: ['always_overwrite', 'always_reject', 'prefer_latest'], - default: 'prefer_latest' - }, - staged_to_members: { - type: String, - enum: ['always_overwrite', 'always_reject', 'prefer_latest', 'abort'], - default: 'abort' - } - }, - - // NEW: Member Sync Configuration - member_sync: { - strategy: { - type: String, - enum: ['track_latest', 'manual'], - default: 'track_latest' - }, - supplant: { - behavior: { - type: String, - enum: ['replace', 'queue', 'ignore'], - default: 'replace' - }, - status_policy: { - type: String, - enum: ['reset', 'preserve'], - default: 'reset' - } - } - } -} -``` - -### A.2: Zod Schema Updates - -**File**: `app/lib/release-tracks/release-track-schemas.js` - -Add validation schemas for member sync configuration: - -```javascript -// Member sync supplant behavior -const memberSyncSupplantBehaviorSchema = z.enum(['replace', 'queue', 'ignore']); - -// Member sync status policy -const memberSyncStatusPolicySchema = z.enum(['reset', 'preserve']); - -// Member sync strategy -const memberSyncStrategySchema = z.enum(['track_latest', 'manual']); - -// Complete member sync config -const memberSyncConfigSchema = z.object({ - strategy: memberSyncStrategySchema.optional(), - supplant: z.object({ - behavior: memberSyncSupplantBehaviorSchema.optional(), - status_policy: memberSyncStatusPolicySchema.optional() - }).optional() -}).optional(); - -// Update the track config schema to include member_sync -const trackConfigSchema = z.object({ - candidacy_threshold: z.enum(['work-in-progress', 'awaiting-review', 'reviewed']).optional(), - auto_promote: z.boolean().optional(), - promotion_conflicts: z.object({ - candidates_to_staged: z.enum(['always_overwrite', 'always_reject', 'prefer_latest']).optional(), - staged_to_members: z.enum(['always_overwrite', 'always_reject', 'prefer_latest', 'abort']).optional() - }).optional(), - member_sync: memberSyncConfigSchema // NEW -}); - -// Export for use in validators -module.exports = { - // ... existing exports - memberSyncConfigSchema, - memberSyncStrategySchema, - memberSyncSupplantBehaviorSchema, - memberSyncStatusPolicySchema -}; -``` - -### A.3: Default Configuration - -**File**: `app/services/release-tracks/snapshot-service.js` - -In `createTrack()`, set explicit defaults for member sync: - -```javascript -exports.createTrack = async function(data) { - // ... existing code ... - - const initialSnapshot = { - // ... existing fields ... - config: { - candidacy_threshold: 'reviewed', - auto_promote: true, - promotion_conflicts: { - candidates_to_staged: 'prefer_latest', - staged_to_members: 'abort' - }, - // NEW: Default member sync config - member_sync: { - strategy: 'track_latest', - supplant: { - behavior: 'replace', - status_policy: 'reset' - } - } - } - }; - - // ... rest of function ... -}; -``` - -The Mongoose schema defaults (defined in A.1) will also apply if `config.member_sync` is not explicitly provided. - -### A.4: Verification (Phase A) - -```bash -# Unit tests for Zod schemas -npm test -- --grep "member_sync schema" - -# Verify Mongoose schema accepts new config -# Create a track and check config is populated -curl -X POST http://localhost:3000/api/release-tracks/new \ - -H 'Content-Type: application/json' \ - -d '{"name":"Test Track","type":"standard"}' - -# Verify config includes member_sync with defaults -curl http://localhost:3000/api/release-tracks//config -``` - ---- - -## Phase B: Member Sync Service - -### Scope - -Implement the core member sync logic in a dedicated service that: -1. Determines if/how to enroll a new object revision -2. Handles supplant behavior (replace/queue/ignore) -3. Applies status policy (reset/preserve) -4. Creates new draft snapshots - -### B.1: New File: `app/services/release-tracks/member-sync-service.js` - -**Dependencies**: -```javascript -'use strict'; - -const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); -const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); -const snapshotService = require('./snapshot-service'); -const workflowService = require('./workflow-service'); -const logger = require('../../lib/logger'); -``` - -### B.2: Core Method: `handleObjectModified()` - -This is the main entry point, called when a STIX object is created or modified. - -```javascript -/** - * Handle a STIX object modification event. - * Identifies release tracks where the object is a member and applies - * the configured member sync strategy. - * - * @param {Object} event - The modification event - * @param {string} event.objectRef - The STIX ID of the modified object - * @param {Date} event.newModified - The new modified timestamp - * @param {Date} event.oldModified - The previous modified timestamp (if update) - * @param {string} event.modifiedBy - User who made the modification - * @returns {Promise} Array of affected release track snapshots - */ -exports.handleObjectModified = async function(event) { - const { objectRef, newModified, modifiedBy } = event; - - // 1. Find all release tracks where this object is in members - const affectedTracks = await findTracksWithObjectInMembers(objectRef); - - if (affectedTracks.length === 0) { - logger.debug(`[member-sync] No release tracks contain ${objectRef} in members`); - return []; - } - - logger.debug(`[member-sync] Found ${affectedTracks.length} tracks with ${objectRef} in members`); - - // 2. Process each track according to its member_sync config - const results = []; - for (const trackInfo of affectedTracks) { - try { - const result = await processMemberSync(trackInfo.trackId, { - objectRef, - newModified, - modifiedBy - }); - if (result) results.push(result); - } catch (err) { - logger.error(`[member-sync] Error processing track ${trackInfo.trackId}: ${err.message}`); - // Continue processing other tracks; don't let one failure stop all - } - } - - return results; -}; -``` - -### B.3: Helper: `findTracksWithObjectInMembers()` - -Query all release tracks to find where the object is a member. - -```javascript -/** - * Find all release tracks where the given object is in the members array. - * - * @param {string} objectRef - The STIX ID to search for - * @returns {Promise>} - */ -async function findTracksWithObjectInMembers(objectRef) { - // Get all track IDs from registry - const allTracks = await registryRepo.findAll({ limit: 10000 }); - const results = []; - - for (const trackInfo of allTracks.data) { - // Skip virtual tracks (they don't have the same member sync semantics) - if (trackInfo.type === 'virtual') continue; - - const snapshot = await dynamicRepo.getLatestSnapshot(trackInfo.track_id); - if (!snapshot) continue; - - // Check if object is in members - const memberEntry = snapshot.members?.find(m => m.object_ref === objectRef); - if (memberEntry) { - results.push({ - trackId: trackInfo.track_id, - memberEntry, - snapshot - }); - } - } - - return results; -} -``` - -**Performance Note**: This naive approach queries all tracks. For large deployments, consider: -1. Adding a reverse index (`workspace.referenced_by` on objects as noted in the design doc) -2. Caching track membership -3. Using MongoDB aggregation with `$lookup` - -For MVP, the naive approach is acceptable given expected track counts. - -### B.4: Core Logic: `processMemberSync()` - -Apply the configured strategy to a single release track. - -```javascript -/** - * Process member sync for a single release track. - * - * @param {string} trackId - * @param {Object} event - * @returns {Promise} New snapshot if changes made, null otherwise - */ -async function processMemberSync(trackId, event) { - const { objectRef, newModified, modifiedBy } = event; - - // Get latest snapshot with defaults applied - const snapshot = await snapshotService.getLatestSnapshot(trackId); - const config = snapshot.config.member_sync; - - // Check strategy - if (config.strategy === 'manual') { - logger.debug(`[member-sync] Track ${trackId} uses manual strategy, skipping auto-enrollment`); - return null; - } - - // strategy === 'track_latest' - // Check if object already exists in candidates or staged - const existingInCandidates = snapshot.candidates?.find(c => c.object_ref === objectRef); - const existingInStaged = snapshot.staged?.find(s => s.object_ref === objectRef); - const existingEntry = existingInStaged || existingInCandidates; - const existingTier = existingInStaged ? 'staged' : (existingInCandidates ? 'candidates' : null); - - // Determine action based on supplant.behavior - let action = null; - if (!existingEntry) { - // No existing entry → simple enrollment - action = { type: 'enroll', tier: 'candidates' }; - } else { - // Existing entry → apply supplant behavior - switch (config.supplant.behavior) { - case 'replace': - action = { - type: 'replace', - removeTier: existingTier, - removeEntry: existingEntry, - targetTier: config.supplant.status_policy === 'preserve' ? existingTier : 'candidates' - }; - break; - case 'queue': - action = { type: 'enroll', tier: 'candidates' }; - break; - case 'ignore': - logger.debug(`[member-sync] Track ${trackId}: ignoring ${objectRef} (existing entry in ${existingTier})`); - return null; - } - } - - if (!action) return null; - - // Build the new candidate/staged entry - const now = new Date(); - const newEntry = { - object_ref: objectRef, - object_modified: new Date(newModified), - object_added_at: now, - object_added_by: modifiedBy || 'system' - }; - - // Determine status - if (action.type === 'replace' && config.supplant.status_policy === 'preserve') { - newEntry.object_status = action.removeEntry.object_status; - if (action.removeEntry.object_staged_at) { - newEntry.object_staged_at = now; - newEntry.object_staged_by = modifiedBy || 'system'; - } - } else { - newEntry.object_status = 'work-in-progress'; - } - - // Build updated tier arrays - let newCandidates = [...(snapshot.candidates || [])]; - let newStaged = [...(snapshot.staged || [])]; - - // Remove old entry if replacing - if (action.type === 'replace') { - if (action.removeTier === 'candidates') { - newCandidates = newCandidates.filter(c => - !(c.object_ref === objectRef && - c.object_modified.getTime() === action.removeEntry.object_modified.getTime()) - ); - } else if (action.removeTier === 'staged') { - newStaged = newStaged.filter(s => - !(s.object_ref === objectRef && - s.object_modified.getTime() === action.removeEntry.object_modified.getTime()) - ); - } - } - - // Add new entry to target tier - const targetTier = action.targetTier || action.tier; - if (targetTier === 'staged') { - newStaged.push(newEntry); - } else { - newCandidates.push(newEntry); - } - - // Clone snapshot with updated tiers - const newSnapshot = await snapshotService.cloneSnapshot(trackId, snapshot, { - candidates: newCandidates, - staged: newStaged - }); - - logger.info(`[member-sync] Track ${trackId}: ${action.type} ${objectRef} → ${targetTier}`); - - // Check if auto-promotion should occur (new entry in candidates that meets threshold) - if (targetTier === 'candidates' && snapshot.config.auto_promote) { - const promoted = await workflowService.evaluateAutoPromotion(trackId, newSnapshot); - if (promoted) { - logger.info(`[member-sync] Track ${trackId}: auto-promoted ${objectRef} to staged`); - return promoted; - } - } - - return newSnapshot; -} -``` - -### B.5: Facade Wiring - -**File**: `app/services/release-tracks/release-tracks-service.js` - -Add export for the member sync handler (used by event integration): - -```javascript -const memberSyncService = require('./member-sync-service'); - -// Expose for event handlers -exports.handleObjectModified = memberSyncService.handleObjectModified; -``` - -### B.6: Verification (Phase B) - -Unit tests for member-sync-service: - -```javascript -// app/tests/unit/services/release-tracks/member-sync-service.spec.js - -describe('member-sync-service', () => { - describe('handleObjectModified', () => { - it('should auto-enroll when strategy is track_latest', async () => { - // Setup: create track with track_latest config - // Modify object - // Assert: new snapshot has object in candidates - }); - - it('should not enroll when strategy is manual', async () => { - // Setup: create track with manual config - // Modify object - // Assert: no new snapshot created - }); - - it('should replace existing candidate when behavior is replace', async () => { - // Setup: track with existing candidate entry - // Modify object (new version) - // Assert: old version removed, new version added - }); - - it('should queue alongside existing when behavior is queue', async () => { - // Setup: track with existing candidate entry - // Modify object - // Assert: both versions present - }); - - it('should ignore when existing entry and behavior is ignore', async () => { - // Setup: track with existing candidate entry - // Modify object - // Assert: no change - }); - - it('should reset status when status_policy is reset', async () => { - // Setup: track with reviewed entry in staged - // Modify object with replace + reset - // Assert: new entry is work-in-progress in candidates - }); - - it('should preserve status when status_policy is preserve', async () => { - // Setup: track with reviewed entry in staged - // Modify object with replace + preserve - // Assert: new entry is reviewed in staged - }); - }); -}); -``` - ---- - -## Phase C: Event Integration - -### Scope - -Connect the member sync service to STIX object modification events so that sync happens automatically when objects are created or updated. - -### C.1: Identify Event Sources - -STIX objects can be modified via: -1. Direct CRUD endpoints (`POST /api/techniques`, `PUT /api/techniques/:id`, etc.) -2. Bundle imports (`POST /api/collection-bundles`) -3. Other internal operations - -The existing codebase uses an EventBus pattern (see `app/lib/event-constants.js`). We need to: -1. Identify where STIX modifications occur -2. Emit events with the required data -3. Subscribe to these events in member-sync-service - -### C.2: Add Event Constants - -**File**: `app/lib/event-constants.js` - -Add new event constant: - -```javascript -module.exports = { - // ... existing constants ... - - // STIX Object Events (for member sync) - STIX_OBJECT_CREATED: 'stix-object::created', - STIX_OBJECT_MODIFIED: 'stix-object::modified', - STIX_OBJECT_DELETED: 'stix-object::deleted' -}; -``` - -### C.3: Emit Events from STIX Services - -The STIX object services (techniques, groups, malware, etc.) need to emit events when objects are created or modified. This requires modifications to the base service layer or individual services. - -**Option A**: Modify each STIX service individually (tedious but explicit) -**Option B**: Add a post-save hook at the repository layer (cleaner) - -**Recommended**: Option B with a dedicated event emitter utility. - -**File**: Create `app/lib/stix-object-events.js` - -```javascript -'use strict'; - -const EventEmitter = require('events'); -const eventConstants = require('./event-constants'); -const logger = require('./logger'); - -const stixObjectEvents = new EventEmitter(); - -/** - * Emit a STIX object modification event. - * Called after STIX objects are created or updated. - * - * @param {Object} params - * @param {string} params.objectRef - STIX ID of the object - * @param {Date} params.newModified - New modified timestamp - * @param {Date} [params.oldModified] - Previous modified timestamp (for updates) - * @param {string} [params.modifiedBy] - User who made the change - * @param {string} params.eventType - 'created' or 'modified' - */ -exports.emitObjectModified = function(params) { - const event = { - objectRef: params.objectRef, - newModified: params.newModified, - oldModified: params.oldModified, - modifiedBy: params.modifiedBy, - timestamp: new Date() - }; - - const eventName = params.eventType === 'created' - ? eventConstants.STIX_OBJECT_CREATED - : eventConstants.STIX_OBJECT_MODIFIED; - - logger.debug(`[stix-events] Emitting ${eventName} for ${params.objectRef}`); - stixObjectEvents.emit(eventName, event); - - // Also emit generic 'modified' for member sync (handles both create and update) - stixObjectEvents.emit(eventConstants.STIX_OBJECT_MODIFIED, event); -}; - -/** - * Subscribe to STIX object modification events. - */ -exports.onObjectModified = function(handler) { - stixObjectEvents.on(eventConstants.STIX_OBJECT_MODIFIED, handler); -}; - -exports.emitter = stixObjectEvents; -``` - -### C.4: Integrate Event Emission into STIX Services - -This is the most invasive change. Each STIX service that creates/updates objects needs to emit events. - -**Example**: `app/services/techniques-service.js` - -```javascript -const stixObjectEvents = require('../lib/stix-object-events'); - -// In the create/update methods, after saving: -exports.create = async function(data, options) { - // ... existing creation logic ... - const savedObject = await techniquesRepository.save(technique); - - // Emit event for member sync - stixObjectEvents.emitObjectModified({ - objectRef: savedObject.stix.id, - newModified: new Date(savedObject.stix.modified), - modifiedBy: options.userAccountId, - eventType: 'created' - }); - - return savedObject; -}; - -exports.updateFull = async function(id, modified, data, options) { - // ... existing update logic ... - const savedObject = await techniquesRepository.save(technique); - - stixObjectEvents.emitObjectModified({ - objectRef: savedObject.stix.id, - newModified: new Date(savedObject.stix.modified), - oldModified: modified, - modifiedBy: options.userAccountId, - eventType: 'modified' - }); - - return savedObject; -}; -``` - -**Alternative**: Use Mongoose middleware (post-save hooks) on the base schema to emit events automatically. This is cleaner but requires schema-level changes. - -### C.5: Subscribe in Member Sync Service - -**File**: `app/services/release-tracks/member-sync-service.js` - -Add initialization function to subscribe to events: - -```javascript -const stixObjectEvents = require('../../lib/stix-object-events'); - -/** - * Initialize the member sync service. - * Subscribes to STIX object modification events. - * Should be called once during application startup. - */ -exports.initialize = function() { - stixObjectEvents.onObjectModified(async (event) => { - try { - await exports.handleObjectModified(event); - } catch (err) { - logger.error(`[member-sync] Error handling object modification: ${err.message}`, err); - } - }); - - logger.info('[member-sync] Member sync service initialized'); -}; -``` - -### C.6: Application Startup - -**File**: `app/index.js` or `app/server.js` (wherever initialization happens) - -```javascript -const memberSyncService = require('./services/release-tracks/member-sync-service'); - -// During application startup -memberSyncService.initialize(); -``` - -### C.7: Verification (Phase C) - -Integration tests: - -```javascript -// app/tests/integration/member-sync.spec.js - -describe('Member Sync Integration', () => { - it('should auto-enroll when object in members is modified', async () => { - // 1. Create a release track with track_latest config - // 2. Add an object to members (via bump/release) - // 3. Modify the object via the STIX endpoint - // 4. Verify new snapshot has object in candidates - }); - - it('should handle bulk import with multiple member objects', async () => { - // 1. Create release track with multiple members - // 2. Import bundle that updates several members - // 3. Verify all updated members appear in candidates - }); -}); -``` - ---- - -## Phase D: Testing & Verification - -### D.1: Unit Tests - -| File | Tests | -|------|-------| -| `app/tests/unit/services/release-tracks/member-sync-service.spec.js` | All strategy/behavior combinations | -| `app/tests/unit/lib/stix-object-events.spec.js` | Event emission and subscription | - -### D.2: Integration Tests - -| File | Tests | -|------|-------| -| `app/tests/integration/release-tracks/member-sync.spec.js` | End-to-end flows | -| `app/tests/api/release-tracks/config.spec.js` | API for updating member_sync config | - -### D.3: Manual Verification - -```bash -# 1. Create a release track -curl -X POST http://localhost:3000/api/release-tracks/new \ - -H 'Content-Type: application/json' \ - -d '{"name":"Test Track","type":"standard"}' - -# 2. Add a technique as a candidate and release it -# (This requires the full workflow: add candidates, review, stage, bump) - -# 3. Verify config shows track_latest -curl http://localhost:3000/api/release-tracks//config - -# 4. Modify the technique -curl -X PUT http://localhost:3000/api/techniques/ \ - -H 'Content-Type: application/json' \ - -d '{"name":"Updated Technique", ...}' - -# 5. Check that the new version appears in candidates -curl http://localhost:3000/api/release-tracks//candidates - -# 6. Test ignore behavior: Update config to use 'ignore' -curl -X PUT http://localhost:3000/api/release-tracks//config \ - -H 'Content-Type: application/json' \ - -d '{"member_sync":{"supplant":{"behavior":"ignore"}}}' - -# 7. Modify technique again -# 8. Verify no new entry added (ignore in effect) -``` - ---- - -## Files Summary - -| File | Phase | Action | -|------|-------|--------| -| `app/models/release-tracks/release-track-snapshot-schema.js` | A | **Modify** (add member_sync to config) | -| `app/lib/release-tracks/release-track-schemas.js` | A | **Modify** (add Zod schemas) | -| `app/services/release-tracks/snapshot-service.js` | A | **Modify** (set defaults on create) | -| `app/services/release-tracks/member-sync-service.js` | B | **Create** | -| `app/services/release-tracks/release-tracks-service.js` | B | **Modify** (wire member sync export) | -| `app/lib/event-constants.js` | C | **Modify** (add STIX object events) | -| `app/lib/stix-object-events.js` | C | **Create** | -| `app/services/*.js` (STIX services) | C | **Modify** (emit events on create/update) | -| `app/index.js` or `app/server.js` | C | **Modify** (initialize member sync) | - ---- - -## Implementation Notes - -### Performance Considerations - -1. **Track Scanning**: The naive `findTracksWithObjectInMembers()` scans all tracks. For deployments with many tracks: - - Add a reverse index (deferred in design doc) - - Cache membership maps - - Use MongoDB aggregation - -2. **Event Processing**: STIX object modifications should not block on member sync completion. Consider: - - Asynchronous processing (fire-and-forget) - - Queue-based processing for high-volume scenarios - -3. **Snapshot Creation**: Each auto-enrollment creates a new snapshot. For bulk imports: - - Batch multiple enrollments into a single snapshot - - Process at end of import transaction - -### Edge Cases - -1. **Object in Multiple Tracks**: When an object is a member of multiple tracks, each track is processed independently according to its own config. - -2. **Virtual Tracks**: Member sync does not apply to virtual tracks (they pull from component track members at snapshot creation time). - -3. **Concurrent Modifications**: If the same object is modified multiple times in rapid succession, each modification triggers member sync. The snapshot system handles concurrency (each creates a new snapshot). - -4. **Circular Events**: Member sync creates new snapshots, but these should not trigger additional STIX object events (no risk of infinite loops since we're modifying release tracks, not STIX objects). - ---- - -## Dependencies on Other Phases - -| This Phase | Depends On | -|------------|------------| -| A (Schema) | None | -| B (Service) | A (Schema), Phase 1 (snapshot-service), Phase 3 (workflow-service for auto-promotion) | -| C (Events) | B (Service), existing STIX services | -| D (Testing) | A, B, C complete | - -**Note**: Phase B depends on `workflow-service.evaluateAutoPromotion()` from Phase 3 of the main implementation plan. If Phase 3 is not yet complete, the auto-promotion call in member-sync-service can be stubbed or the feature can be implemented without auto-promotion initially. diff --git a/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md b/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md deleted file mode 100644 index 2f42fefc..00000000 --- a/.nocommit/docs/99_POST_IMPL_TESTING_OBSERVATIONS.md +++ /dev/null @@ -1,349 +0,0 @@ -# Observations - -```httpie -echo '{ - "from": "work-in-progress", - "to": "awaiting-review", - "object_refs": [ - "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", - "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8" - ] -}' | \ - http POST http://localhost:3000/api/release-tracks/release-track--e4e3b098-5755-4525-814b-26fc16beab87/candidates/review \ - authorization:'Basic dGF4aWktc2VydmVyOnNlY3JldC1zcXVpcnJlbA==' \ - content-type:application/json \ - cookie:'{{AUTH_COOKIES}}' -``` - -The 'Bulk transition candidate workflow status' endpoint (and possibly others) will blindly create new release-track snapshots/documents even when no objects (members, staged, candidates) are modified. -We should change the behavior to only create a new document when something actually changes. -A new snapshot/document should NOT be created if no keys/properties are modified. Thus, no new snapshots should be identical. - -Additionally, when a new snapshot/document is created, a summary of the change should be captured in `version_history`. -The first element in the `version_history` list should explain what triggered the document to be created. -The purpose is to minimize guesswork in generating delta descriptions between snapshots. For instance, a developer may be trying to ascertain how a release track evolved over time; and without the proposed change summary, the developer would have to compute document diffs for each successive pair of snapshots that exists for a given release track. -A given snapshot's `version_history` should thus describe every operation, since the release track's inception, that has been made up to the given snapshot, constituting a causal events chain, or histogram, of how the snapshot came to be. - ---- - -**RESOLVED:** - -When I trigger the tag/bump operation for a snapshot which contains a conflict that blocks the release from occurring, the response body only shows the first error that triggered a conflict. Here is an example response body: -```json -{ - "track_id": "release-track--e4e3b098-5755-4525-814b-26fc16beab87", - "snapshot_modified": "2026-02-03T19:20:04.591Z", - "is_already_tagged": false, - "current_version": null, - "next_version_minor": "19.1", - "next_version_major": "20.0", - "staged_count": 3, - "members_count": 2, - "candidates_count": 0, - "conflict_error": "Conflict on attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61: abort policy prevents promotion" -} -``` -In the above response body, we see 1 object that prevents promotion: -``` -"conflict_error": "Conflict on attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61: abort policy prevents promotion" -``` -But there are potentially many conflicts! For example, consider the following snapshot (represented here as a Mongo Document): -``` -{ - "_id": { - "$oid": "69824a64ee981bdd17cefc7a" - }, - "id": "release-track--e4e3b098-5755-4525-814b-26fc16beab87", - "type": "standard", - "modified": { - "$date": "2026-02-03T19:20:04.591Z" - }, - "version": null, - "name": "Updated Track Name", - "description": "Updated description", - "created": { - "$date": "2026-02-03T16:05:44.429Z" - }, - "created_by_ref": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1", - "members": [ - { - "object_ref": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", - "object_modified": { - "$date": "2025-04-15T19:58:01.218Z" - } - }, - { - "object_ref": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", - "object_modified": { - "$date": "2025-04-15T19:58:03.170Z" - } - } - ], - "staged": [ - { - "object_ref": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", - "object_modified": { - "$date": "2025-04-15T19:58:01.218Z" - }, - "object_status": "work-in-progress", - "object_staged_at": { - "$date": "2026-02-03T19:20:04.591Z" - }, - "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" - }, - { - "object_ref": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", - "object_modified": { - "$date": "2025-04-15T19:58:03.170Z" - }, - "object_status": "work-in-progress", - "object_staged_at": { - "$date": "2026-02-03T19:20:04.591Z" - }, - "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" - }, - { - "object_ref": "attack-pattern--097924ce-a9a9-4039-8591-e0deedfb8722", - "object_modified": { - "$date": "2025-04-16T21:26:10.077Z" - }, - "object_status": "work-in-progress", - "object_staged_at": { - "$date": "2026-02-03T19:20:04.591Z" - }, - "object_staged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" - } - ], - "candidates": [], - "config": { - "candidacy_threshold": "reviewed", - "auto_promote": true, - "promotion_conflicts": { - "candidates_to_staged": "prefer_latest", - "staged_to_members": "abort" - } - }, - "version_history": [ - { - "version": "19.0", - "tagged_at": { - "$date": "2026-02-03T18:26:11.653Z" - }, - "tagged_by": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1", - "snapshot_id": { - "$date": "2026-02-03T17:16:02.895Z" - }, - "summary": { - "members_count": 2, - "promoted_count": 2, - "staged_count": 2, - "candidate_count": 0 - } - } - ], - "__v": 0 -} -``` - -The response body correctly shows that STIX object "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61" is in conflict with the `config.promotion_conflicts.staged_to_members` policy. But it fails to show the sme for STIX object "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8". - -The response body should contain a comprehensive list of all conflicts that prevent promotion, not just the first one that the service detected. - -To resolve this: - -1. First read all of the concept/specification documentation for the "release track" feature in `docs/COLLECTIONS_V2/*.md`. Determine the optimal place to document this new behavior. Do we need to update any data structures in `06_ENTITIES.md`? Do we need to amend any intended conflict resolution behavior at `05_RELEASE_WORKFLOW.md#4-abort-taggingrelease-operations-only`? Importantly, before we modify the code, we must ensure that the concept documentation, which is the ultimate source of truth, is up-to-date. -2. Make the appropriate changes to the code base. For an overview of the release-track service architecture, see `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md`. - ---- - -**OBSERVATION**: - -There might appear to be a bug in the bulk status transition endpoint -- but it's not! - -```{{host}}/api/release-tracks/:id/candidates/review``` - -When you transition all candidates with object status `work-in-progress` to `reviewed` in a release track whose `config.candidacy_threshold` is `reviewed` and `config.auto_promote` is `true`, the operation will succeed and, unintuitively, trigger TWO document creations in the database. - -The first document that is created fulfills the endpoint operation. All target candidates have statuses changed from `work-in-progress` to `reviewed`. But importantly, they're still candidates! - -The second document that is created fulfills the auto-promotion configuration. Because the objects surpassed the candidacy threshold, the system will automatically promote them from `candidates` to `staged`. This coincides with yet another document creation. - -Importantly, the second document is the one that gets returned to the user in the response body. - ---- - -During development period (before STIX freeze): - -```json5 -{ - "candidacy_threshold": "awaiting-review", - "auto_promote": true, - - "promotion_conflicts": { - "candidates_to_staged": "prefer_latest", - "staged_to_members": "abort" - } -} -``` - -This will allow developers to continually push and update which objects are staged for the next release. Objects only get staged for the upcomign release if and when its marked as `reviewed`. - -Notably, the "release track owner" (concept not implemented yet) may make the `candidacy_threshold` more or less permissive. For example, it can be set to `awaiting-review` to allow objects that have not undergone a full review to still be queued up for the next release; or it can be set to `reviewed` to restrict the "staged" zone to *only* include objects that have beenf fully reviewed. - -Once the team is approximately 1 month out from the release, the colloquial "STIX freeze" period begins. During this period, the team should be more restrictive about which changes can be queued up for the imminent release. Thus, I recommend the following release track config: - -```json5 -{ - "candidacy_threshold": "reviewed", - "auto_promote": false, - - "promotion_conflicts": { - "candidates_to_staged": "abort", - "staged_to_members": "abort" - } -} -``` - -What changed: - -1. `candidacy_threshold` was changed from `awaiting-review` to `reviewed` -2. `auto_promote` was disabled -3. `promotion_conflicts.candidates_to_staged` was set to `abort` - -This change will block `candidates` from moving into `staged`. - -For last minute changes that need to get included in `staged`, users will need to set `promotion_conflicts.candidates_to_staged` to something other than `abort`. Additionally they should either: -(1) use the "Promote Candidates" endpoint (`{{host}}/api/release-tracks/:id/candidates/promote`) to manually promote candidates to staged... --OR- -(2) ...enable `auto_promote`, BUT be cautious/avoid marking candidates as `reviewed` during the STIX freeze, lest they be included in the release. - - ---- - -**DOCUMENTED IN [08_MEMBER_SYNC_STRATEGIES.md](./08_MEMBER_SYNC_STRATEGIES.md)**: -(but not implemented yet) - -I want to make a change to how object candidacy is managed. At present, there are two "modes" for adding a candidate. Both are via the 'Add Candidates' endpoint (`POST {{host}}/api/release-tracks/:id/candidates`). - -You can either add specific object versions via object `id` and `modified` keys, or you can add the latest version of an object, irrespective of its `modified` key. A request body might resemble the following: -```json5 -{ - "object_refs": [ - // a static reference to a specific object revision - { - "id": "attack-pattern--008b8f56-6107-48be-aa9f-746f927dbb61", - "modified": "$date" - }, - // a dynamic reference to the latest object revision - { - "id": "attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8", - "modified": "latest" - }, - // a dynamic reference to the latest object revision - "attack-pattern--097924ce-a9a9-4039-8591-e0deedfb8722" - ] -} -``` -The latest revision of any given object can be dyanmically referenced by either passing it's `id` as a string literal element, or by setting `modified` to `latest`, e.g.,`{ id, modified: "latest" }`. - -The problem with this approach is that it fails to account for what happens after a release occurs: When a release occurs, all `staged` objects are merged into `members`, and `staged` is emptied. Users continue making revisions to `members`, but because `staged` has been emptied, there are no more dynamic references ensuring that future revisions get pulled in to the next release. Thus, users must remember to "queue up" their object revisions by hitting the 'Add Candidates' endpoint for each object they edit after the previous release. Intuitively, a user might expect that once an object is included in a release track's `members` list, all future revisions will automatically get queued up in `candidates`! Unfortunately, this is not the case. - -Thus, I would like to modify the system to support different "sync strategies" for object references in release tracks. Let's talk through various dimensions of the sync strategy, grounded with some mock/pseudo structures. - -Here is the initial state: - -```yaml -# We have one object with 2 revisions stored in the database: -objects: - - id: attack-pattern1 - modified: 2025-01-01 # We'll refer to this revision as v25 - - id: attack-pattern1 - modified: 2026-01-01 # We'll refer to this revision as v26 -# We have one release track: -release-track1: - # The release track has 1 snapshot: - snapshots: - # The following snapshot is a release snapshot. In it, attack-pattern1 version-2025 (hereinafter "v25" ) has been released - - type: release - candidates: [] - staged: [] - members: - - obj_ref: attack-pattern1 - obj_modified: 2025-01-01 -``` - -We need to first consider: what happens when v26 is created? - -A. Automatically add the v26 revision to `candidates` (resulting in a new draft snapshot) -B. Do nothing (i.e., expect the user to manually add v26 to `candidates`) - -Option A would yield a new draft snapshot like the following: - -```yaml -# We have one release track: -release-track1: - # The release track has 2 snapshots: - snapshots: - # The following snapshot is a release snapshot. In it, attack-pattern1 version-2025 (hereinafter "v25" ) has been released - - type: release - candidates: [] - staged: [] - members: - - obj_ref: attack-pattern1 - obj_modified: 2025-01-01 - - type: draft - candidates: - - obj_ref: attack-pattern1 - obj_modified: 2026-01-01 - staged: [] - members: - - obj_ref: attack-pattern1 - obj_modified: 2025-01-01 -``` - -We also need to consider the scenario where a revision already exists in `candidates` or `staged` at the time a new object revision is created: - -```yaml -# We have one object with 3 revisions stored in the database: -objects: - - id: attack-pattern1 - modified: 2025-01-01 # We'll refer to this revision as v25 - - id: attack-pattern1 - modified: 2026-01-01 # We'll refer to this revision as v26 - - id: attack-pattern1 - modified: 2027-01-01 # We'll refer to this revision as v27 -# We have one release track: -release-track1: - # The release track has 2 snapshots: - snapshots: - # The following snapshot is a release snapshot. In it, v25 has been released - - type: release - candidates: [] - staged: [] - members: - - obj_ref: attack-pattern1 - obj_modified: 2025-01-01 - # The following snapshot is a draft snapshot. In it, v26 has been reviewed and auto-promoted to `staged` - - type: draft - candidates: [] - staged: - - obj_ref: attack-pattern1 - obj_modified: 2026-01-01 - obj_status: 'reviewed' - members: - - obj_ref: attack-pattern1 - obj_modified: 2025-01-01 -``` - -We again need to consider what should happen when v27 is created? The dimensions of consideration are: - -1. Should v26 supplant v27 or not? -2. If v27 supplants v26, should it be added to `candidates` or `staged`? -3. If v27 supplants v26, should it be marked as `work-in-progress` (status reset) or `reviewed` (status preserved)? - -Tangential to these considerations is: - -1. How do we map the aforementioned dimensions to configuration options? -2. Where do we map such configuration options? If we store them on each object ref (in `candidates`, `staged`, and `members`), then we have maximum flexibility, but perhaps too much overhead. If we configure these options globally for the entire release track, we have minimal flexibility, but optimal predictability and ease of use. - -Please reason through this problem and propose a comprehensive solution. Don't worry about the code for now. The main priority is to settle on the desired functionality and document it in `docs/COLLECTIONS_V2/`. We'll deal with coding/implementation after we've fully planned the change. \ No newline at end of file diff --git a/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md b/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md deleted file mode 100644 index f17416e6..00000000 --- a/.nocommit/docs/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,638 +0,0 @@ -# Release Tracks Service Layer — Comprehensive Implementation Plan - -> **Purpose**: Cross-session handoff document. Each phase is self-contained with enough -> context for a new Claude session to pick up implementation without re-reading the full -> codebase. Update the **Status** markers as phases are completed. - ---- - -## Status Tracker - -| Phase | Description | Status | -|-------|-------------|--------| -| 1 | Snapshot Service + Track CRUD + Facade wiring | **DONE** | -| 2 | Standard Track Service (Candidates + Staged) | TODO | -| 3 | Workflow Service (Auto-promotion) | TODO | -| 4 | Versioning Service (Bump/Tag/Preview) | TODO | -| 5 | Virtual Track Service (Composition/Dedup) | TODO | -| 6 | Export Service + Ephemeral Service | TODO (deferred) | - ---- - -## Architecture - -``` -Controller (Zod validation, app/controllers/release-tracks-controller.js) - └─▶ release-tracks-service.js (Facade — delegates only, zero business logic) - ā”œā”€ā–¶ snapshot-service.js Phase 1 Snapshot CRUD, clone, metadata/contents - ā”œā”€ā–¶ standard-track-service.js Phase 2 Candidate/staged/member tier management - ā”œā”€ā–¶ workflow-service.js Phase 3 Auto-promotion, candidacy threshold - ā”œā”€ā–¶ versioning-service.js Phase 4 Bump/tag, version history, preview - ā”œā”€ā–¶ virtual-track-service.js Phase 5 Composition resolution, deduplication - ā”œā”€ā–¶ export-service.js Phase 6 bundle/workbench/filesystemstore (deferred) - └─▶ ephemeral-service.js Phase 6 Stateless domain bundles (deferred) - -All sub-services import these singletons: - registryRepo ← app/repository/release-tracks/release-track-registry.repository.js - dynamicRepo ← app/repository/release-tracks/release-track-dynamic.repository.js - modelFactory ← app/models/release-tracks/model-factory.js -``` - -### Sub-service dependency graph - -``` -snapshot-service (no sub-service deps) -standard-track-service (imports snapshot-service for cloneSnapshot) -workflow-service (imports standard-track-service for tier mutations) -versioning-service (imports snapshot-service, workflow-service) -virtual-track-service (imports snapshot-service for component reads) -export-service (no sub-service deps; cross-reads STIX repos) [deferred] -ephemeral-service (no sub-service deps; cross-reads STIX repos) [deferred] -``` - ---- - -## Design Decisions (settled) - -| Decision | Choice | Rationale | -|---|---|---| -| Module style | Functional `exports.method` (not class) | Matches existing facade + collection-bundles-service pattern | -| `modified: "latest"` resolution in addCandidates | Implement now | Build lightweight object-resolver that queries STIX repos via existing service-map (like import-bundle.js) | -| Bump/tag compound update | Extend `tagSnapshotInPlace` in dynamic repo | Add optional `additionalUpdateOps` param so staged→members promotion is atomic with version set | -| Config get/update | Inline in snapshot-service | Too simple for a separate file | -| Snapshot immutability | All mutations clone → new snapshot (new `modified`) | Exception: tagging is the ONLY in-place update | - ---- - -## Key Reference Files - -| File | Role | -|---|---| -| `app/services/release-tracks/release-tracks-service.js` | Facade (31 stub methods to wire) | -| `app/controllers/release-tracks-controller.js` | Defines exact call signatures the facade must satisfy | -| `app/repository/release-tracks/release-track-registry.repository.js` | Registry CRUD: `create`, `findByTrackId`, `findAll`, `updateByTrackId`, `deleteByTrackId` | -| `app/repository/release-tracks/release-track-dynamic.repository.js` | Snapshot CRUD: `getLatestSnapshot`, `getSnapshotByModified`, `getLatestTaggedSnapshot`, `getSnapshotByVersion`, `getAllSnapshots`, `saveSnapshot`, `tagSnapshotInPlace`, `updateSnapshot`, `deleteSnapshot`, `deleteAllSnapshots`, `dropCollection` | -| `app/models/release-tracks/model-factory.js` | `getModel`, `removeModel`, `ensureIndexes` | -| `app/models/release-tracks/release-track-snapshot-schema.js` | Mongoose schema: members, staged, candidates, quarantine, composition, config, version_history | -| `app/models/release-tracks/release-track-registry-model.js` | Registry schema: track_id, type, name, description, counters, schedule | -| `app/lib/release-tracks/release-track-schemas.js` | All Zod schemas (controller validation + domain types) | -| `app/lib/release-tracks/release-track-validators.js` | Mongoose custom validators wrapping Zod | -| `app/exceptions/index.js` | All exception classes (AlreadyReleasedError, InvalidVersionError, ReleaseConflictError, etc.) | -| `app/services/stix/collection-bundles-service/import-bundle.js` | Reference for cross-service STIX object resolution (serviceMap pattern) | - ---- - -## Shared Lib Utilities - -### `app/lib/release-tracks/version-utils.js` (Phase 4) - -```js -exports.parseVersion = (str) => { major, minor } -exports.compareVersions = (a, b) => -1 | 0 | 1 -exports.calculateNextVersion = (versionHistory, bumpType, explicitVersion) => string -exports.validateVersionProgression = (newVersion, versionHistory) => void | throw InvalidVersionError -``` - -### `app/lib/release-tracks/conflict-resolution.js` (Phase 2) - -```js -exports.applyConflictPolicy = (existingTier, incomingEntries, policy) => { merged, rejected } -// policy: 'always_overwrite' | 'always_reject' | 'prefer_latest' | 'abort' -// 'abort' throws ReleaseConflictError if any conflict exists -// Conflict = same object_ref but different object_modified in target tier -``` - -### `app/lib/release-tracks/deduplication-strategies.js` (Phase 5) - -```js -exports.deduplicate = (allMembers, strategy, componentMeta) => { members, quarantined, report } -// strategy: 'prioritize_latest_object' | 'prioritize_latest_snapshot' | 'prioritize_higher_priority' | 'quarantine' -``` - -### `app/lib/release-tracks/object-resolver.js` (Phase 2) - -```js -exports.resolveLatestModified = async (objectRef) => Date -// Uses STIX service-map to find the latest version of an object by its STIX ID. -// Similar pattern to import-bundle.js serviceMap. -``` - ---- - -## Phase 1: Snapshot Service + Track CRUD - -### Scope - -Implement `snapshot-service.js` and wire the facade for: track listing, creation, retrieval, -metadata/contents updates, cloning, and deletion. - -### New file: `app/services/release-tracks/snapshot-service.js` - -**Dependencies**: `registryRepo`, `dynamicRepo`, `modelFactory`, `uuid` (npm), `logger`, exceptions - -#### Method specifications - -##### `createTrack(data)` → snapshot -- Generate `trackId = 'release-track--' + uuid.v4()` -- `now = new Date()` -- Build initial snapshot document: - ``` - { id: trackId, type: data.type, modified: now, version: null, - name: data.name, description: data.description || '', - created: now, created_by_ref: data.userAccountId, - object_marking_refs: data.object_marking_refs, - members: [], - staged: data.type === 'standard' ? [] : undefined, - candidates: data.type === 'standard' ? [] : undefined, - quarantine: data.type === 'virtual' ? [] : undefined, - composition: data.type === 'virtual' ? data.composition : undefined, - config: {}, version_history: [] } - ``` -- `await modelFactory.ensureIndexes(trackId)` -- `snapshot = await dynamicRepo.saveSnapshot(trackId, initialSnapshot)` -- Create registry entry: - ``` - await registryRepo.create({ - track_id: trackId, type: data.type, name: data.name, - description: data.description, - latest_snapshot_modified: now, snapshot_count: 1, - tagged_release_count: 0, created_at: now, updated_at: now, - snapshot_schedule: data.type === 'virtual' ? data.snapshot_schedule : undefined }) - ``` -- Return snapshot - -##### `listTracks(options)` → `{ data, pagination }` -- Delegate to `registryRepo.findAll(options)` -- `options`: `{ type?, search?, limit?, offset? }` - -##### `getLatestSnapshot(trackId, _options)` → snapshot -- `snapshot = await dynamicRepo.getLatestSnapshot(trackId)` -- If null, throw `TrackNotFoundError(trackId)` -- (Phase 6 will use `_options.format` for export formatting; for now, return raw snapshot) -- Return snapshot - -##### `getSnapshotByModified(trackId, modified, _options)` → snapshot -- `snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified)` -- If null, throw `NotFoundError({ details: '...' })` -- Return snapshot - -##### `cloneSnapshot(trackId, sourceSnapshot, overrides)` → snapshot (internal helper) -- Deep-clone: `const clone = JSON.parse(JSON.stringify(sourceSnapshot))` -- Remove Mongoose metadata: `delete clone._id; delete clone.__v` -- Set `clone.modified = new Date()` -- Set `clone.version = null` (clones are always drafts) -- Apply overrides: merge `overrides` into clone (name, description, etc.) -- `saved = await dynamicRepo.saveSnapshot(trackId, clone)` -- Update registry: `await registryRepo.updateByTrackId(trackId, { latest_snapshot_modified: clone.modified, updated_at: new Date(), $inc-equivalent... })` - - Note: registryRepo.updateByTrackId uses `$set` not `$inc`. Compute new count: call `syncRegistryCounters(trackId)` or pass computed values. -- Return saved - -##### `cloneTrack(trackId, options)` → snapshot -- Load source: `source = await getLatestSnapshot(trackId)` -- Generate new track ID: `newTrackId = 'release-track--' + uuid.v4()` -- `now = new Date()` -- Clone snapshot for new track: - ``` - const clone = JSON.parse(JSON.stringify(source)) - delete clone._id; delete clone.__v - clone.id = newTrackId - clone.modified = now - clone.version = null - clone.name = options.name || source.name + ' (copy)' - clone.created = now - clone.created_by_ref = options.userAccountId - clone.version_history = [] - ``` -- `await modelFactory.ensureIndexes(newTrackId)` -- `saved = await dynamicRepo.saveSnapshot(newTrackId, clone)` -- Create registry entry for new track -- Return saved - -##### `cloneFromSnapshot(trackId, modified, options)` → snapshot -- Same as `cloneTrack` but source = `getSnapshotByModified(trackId, modified)` - -##### `updateMetadata(trackId, updates, _userId)` → snapshot -- `source = await getLatestSnapshot(trackId)` -- Build overrides from `updates`: only `{ name?, description?, object_marking_refs? }` -- Return `cloneSnapshot(trackId, source, overrides)` - -##### `updateMetadataByModified(trackId, modified, updates, _userId)` → snapshot -- `source = await getSnapshotByModified(trackId, modified)` -- Same as above - -##### `updateContents(trackId, contents, _userId)` → snapshot -- `source = await getLatestSnapshot(trackId)` -- Map `contents.x_mitre_contents` to member entries: - ``` - const members = contents.x_mitre_contents.map(c => ({ - object_ref: c.obj_ref, - object_modified: new Date(c.obj_modified) - })) - ``` -- Return `cloneSnapshot(trackId, source, { members })` - -##### `updateContentsByModified(trackId, modified, contents, _userId)` → snapshot -- Same but from specific snapshot - -##### `getConfig(trackId)` → config -- `snapshot = await getLatestSnapshot(trackId)` -- Return `snapshot.config` - -##### `updateConfig(trackId, config, _userId)` → snapshot -- `source = await getLatestSnapshot(trackId)` -- Merge config: `const mergedConfig = { ...source.config, ...config }` -- Handle nested `promotion_conflicts`: `mergedConfig.promotion_conflicts = { ...source.config?.promotion_conflicts, ...config.promotion_conflicts }` -- Return `cloneSnapshot(trackId, source, { config: mergedConfig })` - -##### `deleteTrack(trackId)` → void -- Verify exists: `registry = await registryRepo.findByTrackId(trackId)` -- If null, throw `TrackNotFoundError(trackId)` -- `await dynamicRepo.dropCollection(trackId)` (also cleans up model cache) -- `await registryRepo.deleteByTrackId(trackId)` - -##### `deleteSnapshot(trackId, modified)` → void -- Verify snapshot exists: `snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified)` -- If null, throw `NotFoundError` -- `await dynamicRepo.deleteSnapshot(trackId, modified)` -- `await syncRegistryCounters(trackId)` - -##### `syncRegistryCounters(trackId)` → void (internal) -- `all = await dynamicRepo.getAllSnapshots(trackId, { projection: 'modified version' })` -- Compute: `snapshot_count`, `tagged_release_count`, `latest_snapshot_modified`, `latest_tagged_version` -- `await registryRepo.updateByTrackId(trackId, { ...computed, updated_at: new Date() })` - -### Facade wiring (Phase 1) - -Replace these stubs in `release-tracks-service.js`: - -```js -const snapshotService = require('./snapshot-service'); - -exports.listTracks = (options) => snapshotService.listTracks(options); -exports.createTrack = (data) => snapshotService.createTrack(data); -exports.getLatestSnapshot = (trackId, options) => snapshotService.getLatestSnapshot(trackId, options); -exports.getSnapshotByModified = (trackId, modified, options) => snapshotService.getSnapshotByModified(trackId, modified, options); -exports.updateMetadata = (trackId, updates, userId) => snapshotService.updateMetadata(trackId, updates, userId); -exports.updateMetadataByModified = (trackId, modified, updates, userId) => snapshotService.updateMetadataByModified(trackId, modified, updates, userId); -exports.updateContents = (trackId, contents, userId) => snapshotService.updateContents(trackId, contents, userId); -exports.updateContentsByModified = (trackId, modified, contents, userId) => snapshotService.updateContentsByModified(trackId, modified, contents, userId); -exports.cloneTrack = (trackId, options) => snapshotService.cloneTrack(trackId, options); -exports.cloneFromSnapshot = (trackId, modified, options) => snapshotService.cloneFromSnapshot(trackId, modified, options); -exports.deleteTrack = (trackId) => snapshotService.deleteTrack(trackId); -exports.deleteSnapshot = (trackId, modified) => snapshotService.deleteSnapshot(trackId, modified); -exports.getConfig = (trackId) => snapshotService.getConfig(trackId); -exports.updateConfig = (trackId, config, userId) => snapshotService.updateConfig(trackId, config, userId); -``` - -Methods that remain as `NotImplementedError` after Phase 1: -- `createTrackFromBundle`, `importTrack`, `getEphemeralBundle` -- All candidate/staged/versioning/virtual methods (Phases 2-5) - -### Verification (Phase 1) - -```bash -# Run existing tests (should not break) -npm test - -# Manual verification via curl (requires running server): -# 1. Create standard track -curl -X POST http://localhost:3000/api/release-tracks/new \ - -H 'Content-Type: application/json' \ - -d '{"name":"Test Track","type":"standard"}' - -# 2. List tracks -curl http://localhost:3000/api/release-tracks - -# 3. Get latest snapshot (use track ID from step 1) -curl http://localhost:3000/api/release-tracks/ - -# 4. Update metadata -curl -X POST http://localhost:3000/api/release-tracks//meta \ - -d '{"name":"Renamed Track"}' - -# 5. Clone track -curl -X POST http://localhost:3000/api/release-tracks//clone - -# 6. Delete track -curl -X DELETE http://localhost:3000/api/release-tracks/ -``` - ---- - -## Phase 2: Standard Track Service (Candidates + Staged) - -### Prerequisites -- Phase 1 complete -- Create `app/lib/release-tracks/conflict-resolution.js` -- Create `app/lib/release-tracks/object-resolver.js` - -### New file: `app/services/release-tracks/standard-track-service.js` - -**Dependencies**: `dynamicRepo`, `snapshotService` (for `cloneSnapshot`, `getLatestSnapshot`), `objectResolver`, `conflictResolution`, `logger`, exceptions - -#### Method specifications - -##### `addCandidates(trackId, objectRefs, userId)` → snapshot -- `source = await snapshotService.getLatestSnapshot(trackId)` -- Validate track type is 'standard' -- `now = new Date()` -- For each ref in `objectRefs`: - - Normalize: if string, convert to `{ id: ref, modified: undefined }` - - If `modified` is `'latest'` or undefined: `modified = await objectResolver.resolveLatestModified(entry.id)` - - Else: parse as Date - - Validate no duplicate in `source.candidates` (same `object_ref` + `object_modified`) - - Build: `{ object_ref: entry.id, object_modified: modified, object_status: 'work-in-progress', object_added_at: now, object_added_by: userId }` -- Clone snapshot with `candidates = [...source.candidates, ...newEntries]` -- Return via `snapshotService.cloneSnapshot(trackId, source, { candidates: merged })` - -##### `listCandidates(trackId, options)` → `{ candidates }` -- Load latest snapshot -- Filter `snapshot.candidates` by `options.status` if provided -- Return filtered candidates - -##### `removeCandidate(trackId, objectRef)` → void -- Load latest snapshot -- Find candidate(s) where `object_ref === objectRef` -- If none found, throw `NotFoundError` -- Clone snapshot with those candidates removed - -##### `reviewCandidates(trackId, reviewData, userId)` → snapshot -- `reviewData = { from, to, object_refs? }` -- Validate `from` < `to` (forward-only: wip→awaiting→reviewed) -- Load latest snapshot -- Find matching candidates: status === `from`, optionally filtered to `object_refs` -- Update their `object_status` to `to` -- Clone snapshot with updated candidates -- If `source.config.auto_promote === true`, call `workflowService.evaluateAutoPromotion(trackId, newSnapshot)` (Phase 3; for now, skip auto-promotion) -- Return snapshot - -##### `promoteCandidates(trackId, objectRefs, userId)` → snapshot -- Load latest snapshot -- Find candidates matching `objectRefs` -- Build staged entries: `{ object_ref, object_modified, object_status, object_staged_at: now, object_staged_by: userId }` -- Apply `config.promotion_conflicts.candidates_to_staged` policy via `conflictResolution.applyConflictPolicy(source.staged, newStagedEntries, policy)` -- Clone snapshot: remove promoted from candidates, add to staged -- Return snapshot - -##### `updateCandidateVersion(trackId, objectRef, data)` → snapshot -- `data = { old_modified, new_modified }` -- Load latest snapshot -- Find candidate where `object_ref === objectRef` and `object_modified === data.old_modified` -- If not found, throw `NotFoundError` -- Update `object_modified` to `data.new_modified` -- Clone snapshot - -##### `listStaged(trackId)` → `{ staged }` -- Load latest snapshot, return `snapshot.staged` - -##### `demoteStaged(trackId, objectRefs, userId)` → snapshot -- `objectRefs = [{ id, modified }]` -- Load latest snapshot -- For each ref: find in staged tier, remove, create candidate entry preserving `object_status` -- Clone snapshot with updated staged/candidates - -##### `listObjectVersions(trackId, objectRef)` → versions -- Load latest snapshot -- Search all tiers (members, staged, candidates) for `object_ref === objectRef` -- Return `[{ tier: 'members'|'staged'|'candidates', object_modified, object_status? }]` - -### `app/lib/release-tracks/object-resolver.js` - -```js -// Pattern from import-bundle.js serviceMap -const types = require('../../lib/types'); -const serviceMap = { /* same mapping as import-bundle.js */ }; - -exports.resolveLatestModified = async function(objectRef) { - const type = objectRef.split('--')[0]; - const service = serviceMap[type]; - if (!service) throw new BadRequestError({ message: `Unknown object type: ${type}` }); - // Use service.retrieveById or equivalent to get all versions, pick latest modified - const result = await service.retrieveById(objectRef, { versions: 'all' }); - if (!result || result.length === 0) throw new NotFoundError({ details: `Object ${objectRef} not found` }); - // Return the most recent modified timestamp - return new Date(result[0].stix.modified); // assuming sorted desc -}; -``` - -### `app/lib/release-tracks/conflict-resolution.js` - -```js -const { ReleaseConflictError } = require('../../exceptions'); - -exports.applyConflictPolicy = function(existingTier, incomingEntries, policy) { - const merged = [...existingTier]; - const rejected = []; - - for (const incoming of incomingEntries) { - const conflictIdx = merged.findIndex(e => e.object_ref === incoming.object_ref); - if (conflictIdx === -1) { - merged.push(incoming); // No conflict - continue; - } - - const incumbent = merged[conflictIdx]; - switch (policy) { - case 'always_overwrite': merged[conflictIdx] = incoming; break; - case 'always_reject': rejected.push(incoming); break; - case 'prefer_latest': - if (new Date(incoming.object_modified) > new Date(incumbent.object_modified)) { - merged[conflictIdx] = incoming; - } else { - rejected.push(incoming); - } - break; - case 'abort': - throw new ReleaseConflictError(`Conflict on ${incoming.object_ref}: abort policy`); - } - } - return { merged, rejected }; -}; -``` - -### Facade wiring (Phase 2) - -```js -const standardTrackService = require('./standard-track-service'); - -exports.addCandidates = (trackId, objectRefs, userId) => standardTrackService.addCandidates(trackId, objectRefs, userId); -exports.listCandidates = (trackId, options) => standardTrackService.listCandidates(trackId, options); -exports.removeCandidate = (trackId, objectRef) => standardTrackService.removeCandidate(trackId, objectRef); -exports.reviewCandidates = (trackId, reviewData, userId) => standardTrackService.reviewCandidates(trackId, reviewData, userId); -exports.promoteCandidates = (trackId, objectRefs, userId) => standardTrackService.promoteCandidates(trackId, objectRefs, userId); -exports.updateCandidateVersion = (trackId, objectRef, data) => standardTrackService.updateCandidateVersion(trackId, objectRef, data); -exports.listStaged = (trackId) => standardTrackService.listStaged(trackId); -exports.demoteStaged = (trackId, objectRefs, userId) => standardTrackService.demoteStaged(trackId, objectRefs, userId); -exports.listObjectVersions = (trackId, objectRef) => standardTrackService.listObjectVersions(trackId, objectRef); -``` - ---- - -## Phase 3: Workflow Service - -### New file: `app/services/release-tracks/workflow-service.js` - -```js -const STATUS_RANK = { 'work-in-progress': 0, 'awaiting-review': 1, 'reviewed': 2 }; - -exports.meetsThreshold = (status, threshold) => STATUS_RANK[status] >= STATUS_RANK[threshold]; - -exports.evaluateAutoPromotion = async function(trackId, snapshot) { - if (!snapshot.config.auto_promote) return null; - const threshold = snapshot.config.candidacy_threshold || 'reviewed'; - const qualifying = snapshot.candidates.filter(c => exports.meetsThreshold(c.object_status, threshold)); - if (qualifying.length === 0) return null; - - // Promote qualifying candidates to staged - // Uses standardTrackService internally or does inline tier mutation + clone - // ... (detailed implementation in Phase 3) -}; -``` - -### Integration point - -`standard-track-service.reviewCandidates` calls `workflowService.evaluateAutoPromotion` after status update. - ---- - -## Phase 4: Versioning Service - -### Prerequisites -- Create `app/lib/release-tracks/version-utils.js` -- Extend `dynamicRepo.tagSnapshotInPlace` to accept `additionalUpdateOps` - -### Repository change: `release-track-dynamic.repository.js` - -Extend `tagSnapshotInPlace(trackId, modified, versionData)`: -```js -// Current: only $set version + $push version_history -// Extended: accept versionData.additionalOps = { members: [...], staged: [], ... } -// Merge into the atomic findOneAndUpdate call -``` - -### New file: `app/lib/release-tracks/version-utils.js` - -```js -exports.parseVersion = (str) => { - const [major, minor] = str.split('.').map(Number); - return { major, minor }; -}; - -exports.calculateNextVersion = (versionHistory, bumpType, explicitVersion) => { - if (explicitVersion) { - // Validate > all existing versions - return explicitVersion; - } - if (versionHistory.length === 0) return '1.0'; - const latest = versionHistory[versionHistory.length - 1].version; - const { major, minor } = exports.parseVersion(latest); - return bumpType === 'major' ? `${major + 1}.0` : `${major}.${minor + 1}`; -}; -``` - -### New file: `app/services/release-tracks/versioning-service.js` - -##### `bumpLatest(trackId, options)` → snapshot -- Load latest snapshot -- Validate `snapshot.version === null` → else throw `AlreadyReleasedError(snapshot.version)` -- Calculate version: `versionUtils.calculateNextVersion(snapshot.version_history, options.type, options.version)` -- Validate monotonic: `versionUtils.validateVersionProgression(version, snapshot.version_history)` -- Promote staged → members: `conflictResolution.applyConflictPolicy(snapshot.members, stagedAsMembers, config.promotion_conflicts.staged_to_members)` -- Build `versionHistoryEntry = { version, tagged_at: now, tagged_by: options.userAccountId, snapshot_id: snapshot.modified, summary: { members_count, promoted_count, staged_count, candidate_count } }` -- If `options.dry_run`, return preview object without persisting -- Call extended `dynamicRepo.tagSnapshotInPlace(trackId, snapshot.modified, { version, versionHistoryEntry, additionalOps: { members: merged, staged: [] } })` -- Update registry: `latest_tagged_version`, increment `tagged_release_count` -- Return updated snapshot - -##### `bumpByModified(trackId, modified, options)` → snapshot -- Same as above but loads specific snapshot - -##### `previewBump(trackId, _format)` → preview -- Load latest snapshot, compute version + staged→members diff, return structured preview - -### Facade wiring (Phase 4) - -```js -const versioningService = require('./versioning-service'); -exports.bumpLatest = (trackId, options) => versioningService.bumpLatest(trackId, options); -exports.bumpByModified = (trackId, modified, options) => versioningService.bumpByModified(trackId, modified, options); -exports.previewBump = (trackId, format) => versioningService.previewBump(trackId, format); -``` - ---- - -## Phase 5: Virtual Track Service - -### Prerequisites -- Create `app/lib/release-tracks/deduplication-strategies.js` - -### New file: `app/lib/release-tracks/deduplication-strategies.js` - -```js -exports.deduplicate = function(allMembers, strategy, componentMeta) { - // allMembers = [{ object_ref, object_modified, _source_track_id, _source_priority }] - // Group by object_ref, resolve duplicates per strategy - // Returns { members: [], quarantined: [], report: { before, after, duplicates, conflicts } } -}; -``` - -### New file: `app/services/release-tracks/virtual-track-service.js` - -##### `updateComposition(trackId, composition, userId)` → snapshot -- Validate track is virtual type -- Validate all component tracks exist and are standard (not virtual → throw `InvalidComponentTypeError`) -- Validate no duplicate track_ids in component list -- Load latest snapshot, clone with updated `composition` - -##### `createVirtualSnapshot(trackId, options)` → snapshot -- Load latest snapshot, read `composition` -- For each component: resolve snapshot via strategy (latest_tagged / specific_version / specific_snapshot) -- Validate resolved snapshot is tagged (throw `NoTaggedSnapshotsError` if not) -- Extract members, apply filters (object_types, domains) -- Deduplicate across all components -- Build `composition_resolution` metadata -- Save new snapshot with resolved members + quarantine + resolution metadata - -##### `previewVirtualSnapshot(trackId)` → preview -- Same as above but return preview without saving - -### Facade wiring (Phase 5) - -```js -const virtualTrackService = require('./virtual-track-service'); -exports.updateComposition = (...) => virtualTrackService.updateComposition(...); -exports.createVirtualSnapshot = (...) => virtualTrackService.createVirtualSnapshot(...); -exports.previewVirtualSnapshot = (...) => virtualTrackService.previewVirtualSnapshot(...); -``` - ---- - -## Phase 6: Export + Ephemeral (Deferred) - -| Item | Reason for deferral | -|---|---| -| `export-service.js` | Requires cross-service reads to hydrate STIX object refs into full objects | -| `ephemeral-service.js` | Requires querying all STIX repos by domain; orthogonal to core | -| `createTrackFromBundle` | Requires STIX bundle parsing via existing collection-bundles infra | -| `importTrack` | Already returns NotImplementedError in controller | -| Format-aware snapshot retrieval | `queryOptions.format` param ignored until export-service exists | - ---- - -## All Files Created/Modified (Complete) - -| File | Phase | Action | -|---|---|---| -| `app/services/release-tracks/snapshot-service.js` | 1 | **Create** | -| `app/services/release-tracks/release-tracks-service.js` | 1+ | **Modify** (progressive stub replacement) | -| `app/lib/release-tracks/conflict-resolution.js` | 2 | **Create** | -| `app/lib/release-tracks/object-resolver.js` | 2 | **Create** | -| `app/services/release-tracks/standard-track-service.js` | 2 | **Create** | -| `app/services/release-tracks/workflow-service.js` | 3 | **Create** | -| `app/lib/release-tracks/version-utils.js` | 4 | **Create** | -| `app/repository/release-tracks/release-track-dynamic.repository.js` | 4 | **Modify** (extend tagSnapshotInPlace) | -| `app/services/release-tracks/versioning-service.js` | 4 | **Create** | -| `app/lib/release-tracks/deduplication-strategies.js` | 5 | **Create** | -| `app/services/release-tracks/virtual-track-service.js` | 5 | **Create** | -| `app/services/release-tracks/export-service.js` | 6 | **Create** (deferred) | -| `app/services/release-tracks/ephemeral-service.js` | 6 | **Create** (deferred) | diff --git a/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md b/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md deleted file mode 100644 index ffd7396b..00000000 --- a/.nocommit/docs/99_SERVICE_LAYER_IMPL_PROMPTS.md +++ /dev/null @@ -1,50 +0,0 @@ -Service Layer Phased Impl Prompts - -The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. - -I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. - -I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. - -Phase 1 through 3 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): - -- Phase 4: versioning-service.js + version-utils.js + extend tagSnapshotInPlace -- Phase 5: virtual-track-service.js + deduplication-strategies.js -- Phase 6: export-service.js + ephemeral-service.js - -Please commence phase 4. - - ---- - -The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. - -I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. - -I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. - -Phase 1 through 4 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): - -- Phase 5: virtual-track-service.js + deduplication-strategies.js -- Phase 6: export-service.js + ephemeral-service.js - -Please commence phase 5. - - ---- - -The `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` document explains the implementation plan for a new feature called "release tracks". It is exhaustively described in the various markdown files stored in `docs/COLLECTIONS_V2/`. Please start by familiarizing with the feature by reading those files. - -I've already implemented the Mongoose schemas/models, DAO/repository modules, controller, router, and OpenAPI spec files. Now I'm working on implementing the service layer, which itself is split into 6 phases due to its complexity. - -I need your help implementing the service layer following the multi-phase plan outlined in `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`. - -Phase 1 through 5 are complete. What's next (per the plan at `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN`): - -- Phase 6: Export + Ephemeral - - `export-service.js`: Requires cross-service reads to hydrate STIX object refs into full objects. For cross-service operations, please use the existing event-bus architecture (see `docs/EVENT_BUS_ARCHITECTURE.md` and `docs/CROSS_SERVICE_READS_PATTERN.md` for details) - - `ephemeral-service.js`: Requires querying all STIX repos by domain; orthogonal to core - - `createTrackFromBundle`: Requires STIX bundle parsing. However, I don't want to rely on the existing collection-bundles infrastructure. Instead, I want to to implement from scratch. The new infrastructure will supplant the existing collection-bundles infrastructure once its been tested, validated, and shipped. - - Format-aware snapshot retrieval: `queryOptions.format` param ignored until export-service exists - -Please commence phase 6. \ No newline at end of file diff --git a/.nocommit/field-requirements-probe.js b/.nocommit/field-requirements-probe.js deleted file mode 100644 index 7ada1514..00000000 --- a/.nocommit/field-requirements-probe.js +++ /dev/null @@ -1,457 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -/** - * Field Requirements Probe for ATT&CK Workbench API - * - * This script systematically tests which fields are required when posting - * "awaiting-review" objects to identify which fields the frontend needs to include. - * - * It tests each STIX object type by: - * 1. Creating a base valid object with "awaiting-review" status - * 2. Removing one field at a time to see which ones cause validation failures - * 3. Generating a report of required vs optional fields - */ - -const request = require('supertest'); -const database = require('./app/lib/database-in-memory'); -const databaseConfiguration = require('./app/lib/database-configuration'); -const login = require('./app/tests/shared/login'); - -const logger = require('./app/lib/logger'); -logger.level = 'debug'; -const { - techniqueSchema, - tacticSchema, - campaignSchema, - groupSchema, - mitigationSchema, - relationshipSchema, - identitySchema, - collectionSchema, - matrixSchema, - dataSourceSchema, - dataComponentSchema, - detectionStrategySchema, - assetSchema, - analyticSchema, -} = require('@mitre-attack/attack-data-model'); - -// Schema to route mapping -const SCHEMA_TO_ENDPOINT = { - techniqueSchema: '/api/techniques', - tacticSchema: '/api/tactics', - campaignSchema: '/api/campaigns', - groupSchema: '/api/groups', - mitigationSchema: '/api/mitigations', - relationshipSchema: '/api/relationships', - identitySchema: '/api/identities', - collectionSchema: '/api/collections', - matrixSchema: '/api/matrices', - dataSourceSchema: '/api/data-sources', - dataComponentSchema: '/api/data-components', - detectionStrategySchema: '/api/detection-strategies', - assetSchema: '/api/assets', - analyticSchema: '/api/analytics', -}; - -const SCHEMAS = { - techniqueSchema, - tacticSchema, - campaignSchema, - groupSchema, - mitigationSchema, - relationshipSchema, - identitySchema, - collectionSchema, - matrixSchema, - dataSourceSchema, - dataComponentSchema, - detectionStrategySchema, - assetSchema, - analyticSchema, -}; - -/** - * Generate base template objects for each STIX type - */ -function generateBaseTemplates() { - const now = new Date().toISOString(); - - return { - techniqueSchema: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'T9999', - collections: [], - }, - stix: { - type: 'attack-pattern', - spec_version: '2.1', - id: 'attack-pattern--' + require('uuid').v4(), - created: now, - modified: now, - name: 'Test Technique', - description: 'Test technique description', - x_mitre_is_subtechnique: false, - x_mitre_domains: ['enterprise-attack'], - x_mitre_attack_spec_version: '3.3.0', - x_mitre_version: '1.0', - x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/techniques/T9999', - }, - ], - created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], - }, - }, - - tacticSchema: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'TA9999', - collections: [], - }, - stix: { - type: 'x-mitre-tactic', - spec_version: '2.1', - id: 'x-mitre-tactic--' + require('uuid').v4(), - created: now, - modified: now, - name: 'Test Tactic', - description: 'Test tactic description', - x_mitre_shortname: 'test-tactic', - x_mitre_domains: ['enterprise-attack'], - x_mitre_attack_spec_version: '3.3.0', - x_mitre_version: '1.0', - x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'TA9999', - url: 'https://attack.mitre.org/tactics/TA9999', - }, - ], - created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], - }, - }, - - campaignSchema: { - workspace: { - workflow: { state: 'awaiting-review' }, - collections: [], - }, - stix: { - type: 'campaign', - spec_version: '2.1', - id: 'campaign--' + require('uuid').v4(), - created: now, - modified: now, - name: 'Test Campaign', - description: 'Test campaign description', - aliases: ['Test Campaign'], - first_seen: now, - last_seen: now, - x_mitre_first_seen_citation: '(Citation: Test)', - x_mitre_last_seen_citation: '(Citation: Test)', - x_mitre_domains: ['enterprise-attack'], - x_mitre_attack_spec_version: '3.3.0', - x_mitre_version: '1.0', - x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - external_references: [ - { - source_name: 'Test', - description: 'Test reference', - }, - ], - created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], - revoked: false, - }, - }, - - // Add more templates as needed... - }; -} - -/** - * Extract all field paths from a nested object - */ -function extractFieldPaths(obj, prefix = '') { - const paths = []; - - for (const [key, value] of Object.entries(obj)) { - const currentPath = prefix ? `${prefix}.${key}` : key; - - if (value && typeof value === 'object' && !Array.isArray(value)) { - paths.push(...extractFieldPaths(value, currentPath)); - } else { - paths.push(currentPath); - } - } - - return paths; -} - -/** - * Remove a field from an object by path (e.g., 'stix.name' or 'workspace.workflow.state') - */ -function removeFieldByPath(obj, path) { - const copy = JSON.parse(JSON.stringify(obj)); - const parts = path.split('.'); - let current = copy; - - for (let i = 0; i < parts.length - 1; i++) { - if (!current[parts[i]]) return copy; - current = current[parts[i]]; - } - - delete current[parts[parts.length - 1]]; - return copy; -} - -/** - * Test field requirements for a specific schema - */ -async function probeSchemaFieldRequirements(schemaName, schema, baseTemplate, endpoint, app, passportCookie) { - console.log(`\nšŸ” Probing ${schemaName}...`); - - const results = { - schemaName, - endpoint, - totalFields: 0, - requiredFields: [], - optionalFields: [], - errors: [], - }; - - // Get all field paths from the base template - const fieldPaths = extractFieldPaths(baseTemplate); - results.totalFields = fieldPaths.length; - - console.log(` Found ${fieldPaths.length} fields to test`); - - // Test each field by removing it - for (const fieldPath of fieldPaths) { - try { - const testObject = removeFieldByPath(baseTemplate, fieldPath); - - const response = await request(app) - .post(endpoint) - .send(testObject) - .set('Content-Type', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); - - if (response.status === 400) { - // Field is required - results.requiredFields.push({ - field: fieldPath, - error: response.body.error, - details: response.body.details, - }); - console.log(` āŒ Required: ${fieldPath}`); - } else if (response.status >= 200 && response.status < 300) { - // Field is optional - results.optionalFields.push(fieldPath); - console.log(` āœ… Optional: ${fieldPath}`); - } else { - // Unexpected response - results.errors.push({ - field: fieldPath, - status: response.status, - error: response.body, - }); - console.log(` āš ļø Unexpected (${response.status}): ${fieldPath}`); - } - } catch (error) { - results.errors.push({ - field: fieldPath, - error: error.message, - }); - console.log(` šŸ’„ Error testing ${fieldPath}: ${error.message}`); - } - - // Small delay to avoid overwhelming the server - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - return results; -} - -/** - * Main execution function - */ -async function main() { - console.log('šŸš€ Starting Field Requirements Probe for ATT&CK Workbench API'); - console.log('='.repeat(80)); - - let app; - let passportCookie; - - try { - // Initialize the database and app (following test pattern) - await database.initializeConnection(); - await databaseConfiguration.checkSystemConfiguration(); - - // Initialize the express app - app = await require('./app/index').initializeApp(); - - // Log into the app - passportCookie = await login.loginAnonymous(app); - - console.log('āœ… App initialized successfully'); - - const baseTemplates = generateBaseTemplates(); - const allResults = []; - - // Test each schema - for (const [schemaName, endpoint] of Object.entries(SCHEMA_TO_ENDPOINT)) { - if (!baseTemplates[schemaName]) { - console.log(`āš ļø Skipping ${schemaName} - no base template defined`); - continue; - } - - try { - const results = await probeSchemaFieldRequirements( - schemaName, - SCHEMAS[schemaName], - baseTemplates[schemaName], - endpoint, - app, - passportCookie - ); - allResults.push(results); - } catch (error) { - console.error(`šŸ’„ Failed to probe ${schemaName}:`, error.message); - } - } - - // Generate comprehensive report - generateReport(allResults); - - } finally { - // Clean up database connection - if (database) { - await database.closeConnection(); - } - } -} - -/** - * Generate comprehensive analysis report - */ -function generateReport(allResults) { - console.log('\n' + '='.repeat(80)); - console.log('šŸ“Š FIELD REQUIREMENTS ANALYSIS REPORT'); - console.log('='.repeat(80)); - - // Summary statistics - const totalSchemas = allResults.length; - const totalFieldsTested = allResults.reduce((sum, r) => sum + r.totalFields, 0); - const totalRequiredFields = allResults.reduce((sum, r) => sum + r.requiredFields.length, 0); - const totalOptionalFields = allResults.reduce((sum, r) => sum + r.optionalFields.length, 0); - - console.log(`\nšŸ“ˆ SUMMARY:`); - console.log(` Schemas tested: ${totalSchemas}`); - console.log(` Total fields tested: ${totalFieldsTested}`); - console.log(` Required fields: ${totalRequiredFields}`); - console.log(` Optional fields: ${totalOptionalFields}`); - - // Common required fields across all schemas - const commonRequired = findCommonFields( - allResults.map((r) => r.requiredFields.map((rf) => rf.field)), - ); - console.log(`\nšŸ”’ FIELDS REQUIRED ACROSS ALL SCHEMAS:`); - commonRequired.forEach((field) => console.log(` - ${field}`)); - - // Backend-set fields that are causing issues - const backendFields = allResults.flatMap((r) => - r.requiredFields.filter( - (rf) => - rf.field.includes('x_mitre_attack_spec_version') || - rf.field.includes('x_mitre_domains') || - rf.field.includes('x_mitre_modified_by_ref') || - rf.field.includes('created_by_ref') || - rf.field.includes('object_marking_refs'), - ), - ); - - console.log(`\nāš ļø BACKEND-SET FIELDS CAUSING VALIDATION FAILURES:`); - backendFields.forEach((field) => { - console.log(` - ${field.field} (${field.error})`); - }); - - // Detailed per-schema breakdown - console.log(`\nšŸ“‹ DETAILED BREAKDOWN BY SCHEMA:`); - allResults.forEach((result) => { - console.log(`\n ${result.schemaName} (${result.endpoint}):`); - console.log(` Required: ${result.requiredFields.length} fields`); - console.log(` Optional: ${result.optionalFields.length} fields`); - console.log(` Errors: ${result.errors.length} fields`); - - if (result.requiredFields.length > 0) { - console.log(` Required fields:`); - result.requiredFields.forEach((rf) => { - console.log(` - ${rf.field}`); - }); - } - }); - - // Frontend action items - console.log(`\nšŸŽÆ FRONTEND ACTION ITEMS:`); - console.log( - `\n The frontend should ensure these fields are included when posting "awaiting-review" objects:`, - ); - - const frontendRequiredFields = new Set(); - allResults.forEach((result) => { - result.requiredFields.forEach((rf) => { - // Filter out fields that should be handled by backend - if ( - !rf.field.includes('created') && - !rf.field.includes('modified') && - !rf.field.includes('id') && - !rf.field.includes('spec_version') && - !rf.field.includes('type') - ) { - frontendRequiredFields.add(rf.field); - } - }); - }); - - [...frontendRequiredFields].sort().forEach((field) => { - console.log(` - ${field}`); - }); - - console.log(`\nāœ… Probe complete! Check the detailed results above.`); -} - -/** - * Find fields that appear in all result sets - */ -function findCommonFields(fieldSets) { - if (fieldSets.length === 0) return []; - - return fieldSets[0].filter((field) => fieldSets.every((set) => set.includes(field))); -} - -// Run the probe -if (require.main === module) { - main().catch((error) => { - console.error('šŸ’„ Probe failed:', error); - process.exit(1); - }); -} - -module.exports = { - generateBaseTemplates, - probeSchemaFieldRequirements, - extractFieldPaths, - removeFieldByPath, -}; diff --git a/.nocommit/plans/workflow-response-ontology.md b/.nocommit/plans/workflow-response-ontology.md deleted file mode 100644 index 5aee4312..00000000 --- a/.nocommit/plans/workflow-response-ontology.md +++ /dev/null @@ -1,106 +0,0 @@ -# Plan: Universal Workflow Response Ontology - -## Problem - -Backend workflow endpoints (revoke, convert-to-subtechnique, convert-to-technique) orchestrate -multi-step operations but return inconsistent response shapes. The revoke endpoint returns a -bespoke `{ revokedObject, revokedByRelationship, relationshipsSummary }`. The convert endpoints -return only the bare technique object — side-effect documents (created/deprecated relationships) -are invisible to the caller. - -## Universal Response Schema - -Every workflow endpoint returns the same top-level shape: - -```json -{ - "workflow": "convert-to-subtechnique", - "primary": { /* the main object acted upon — full workspace+stix document */ }, - "sideEffects": { - "created": [ /* full documents */ ], - "modified": [ /* full documents */ ], - "deprecated": [ /* full documents */ ], - "deleted": { "count": 0, "stixIds": [] } - }, - "warnings": [] -} -``` - -| Field | Purpose | -|---|---| -| `workflow` | Discriminator string (`"revoke"`, `"convert-to-subtechnique"`, `"convert-to-technique"`) | -| `primary` | The one object the user directly acted on — always exactly one, always a full document | -| `sideEffects.created` | Full documents created as consequences (e.g., `revoked-by` or `subtechnique-of` relationships) | -| `sideEffects.modified` | Full documents modified as consequences (reserved for future use) | -| `sideEffects.deprecated` | Full documents where `x_mitre_deprecated` was set to `true` | -| `sideEffects.deleted` | Count + STIX IDs of hard-deleted documents (only IDs — the docs no longer exist) | -| `warnings` | Non-fatal issues (failed transfers, handler errors, etc.) | - -Counts are derived from array lengths. The `deleted` category is the exception because deleted -documents can't be returned in full. - -## Implementation Sequence - -### 1. WorkflowResult DTO — `app/lib/workflow-result.js` - -Builder class with: -- `setPrimary(doc)` -- `addCreated(docOrDocs)`, `addModified(docOrDocs)`, `addDeprecated(docOrDocs)` -- `addDeleted(stixIds)` -- `addWarning(msg)`, `addWarnings(msgs)` -- `mergeEventResults(eventResults)` — merges `{ created, deprecated, warnings }` from handlers -- `toJSON()` — defensively calls `.toObject()` on Mongoose docs - -### 2. EventBus — return handler results from `emit()` - -Modify `emit()` to collect and return fulfilled values from `Promise.allSettled`. Non-breaking — -no existing caller inspects the return value. - -### 3. RelationshipsService event handlers — return side-effect documents - -Each handler returns a `{ created, deprecated, warnings }` object: -- `handleTechniqueConvertedToSubtechnique` → `{ created: [rel] }` -- `handleSubtechniqueConvertedToTechnique` → `{ deprecated: [rel1, ...] }` -- `handleObjectRevoked` → `{ deprecated: [...] }` - -Catch blocks return `{ warnings: [...] }` instead of swallowing errors silently. - -### 4. Update `base.service.js` `revoke()` - -Replace bespoke return object with `WorkflowResult`. Merge event handler results via -`result.mergeEventResults()`. Remove the post-hoc cross-service read that counts deprecated -relationships (now derived from the returned array). - -### 5. Update `techniques-service.js` convert methods - -Both methods: create `WorkflowResult`, set primary, capture `EventBus.emit()` return value, -merge, return `result.toJSON()`. - -### 6. OpenAPI schema — `app/api/definitions/components/workflow-response.yml` - -Define reusable `workflow-response` and `side-effects` component schemas. Update -`techniques-paths.yml` to reference them for all three workflow endpoints. - -### 7. Tests and documentation - -- Unit tests for `WorkflowResult` -- Update integration tests for new response shape -- Developer doc at `docs/developer/workflow-response-pattern.md` -- Update user docs (revoke-workflow.md, technique-conversion-workflow.md) - -## Key Design Decisions - -- **Full documents in arrays, not just IDs** — the frontend never needs a follow-up GET. -- **`deleted` is count + IDs only** — deleted documents can't be returned in full. -- **Breaking change to revoke response is acceptable** — the endpoint is new. - `revokedObject` → `primary`, `revokedByRelationship` → `sideEffects.created[0]`. -- **Single PR** — contained change surface: one new file, four service mods, one EventBus - tweak, OpenAPI updates. - -## Mapping: Old Revoke Response → New Shape - -``` -revokedObject → primary -revokedByRelationship → sideEffects.created[0] -relationshipsSummary → (derived from sideEffects arrays + warnings) -``` diff --git a/.nocommit/prompts/adm-validation-explainer.md b/.nocommit/prompts/adm-validation-explainer.md deleted file mode 100644 index d4bfbe19..00000000 --- a/.nocommit/prompts/adm-validation-explainer.md +++ /dev/null @@ -1,29 +0,0 @@ -I recently introduced a new feature to the ATT&CK Workbench REST API that uses the ATT&CK Data Model (ADM) library (`@mitre-attack/attack-data-model`) to validate request bodies for POST and PUT requests. The ADM provides a comprehensive set of Zod schemas for parsing/validating any ATT&CK type, whether fields and objects alike. Thus, it provides us an opportunity to add much more granular, robust data validation checks to ensure that Workbench does not permit users to create non-compliant objects. This validation takes the form of a middleware function called `validateWorkspaceStixdata`. Here is an example of it in action in the techniques routing module, `app/routes/techniques-routes.js`: -```javascript -'use strict'; - -const express = require('express'); - -const techniquesController = require('../controllers/techniques-controller'); -const authn = require('../lib/authn-middleware'); -const authz = require('../lib/authz-middleware'); -const { validateWorkspaceStixData } = require('../lib/validation-middleware'); - -const router = express.Router(); - -router - .route('/techniques') - .get( - authn.authenticate, - authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), - techniquesController.retrieveAll, - ) - .post( - authn.authenticate, - authz.requireRole(authz.editorOrHigher), - validateWorkspaceStixData('attack-pattern'), - techniquesController.create, - ); -``` - -Behind the scenes, the middleware crafts a custom Zod schema using the underlying ADM-source schema. There are certain backend-controlled fields, like `x_mitre_attack_spec_version` that need to be omitted from the schema because users will never set them and thereby never includes them in the request body. There are also workflow states wherein users might create a draft of an object and curate it over time, in which case we don't want the validation middleware to prematurely throw if an otherwise required field is excluded while the object is a work-in-progress. Lastly, there are additional metadata fields we track in Workbench that aren't in-scope of ATT&CK or STIX. These things are all dynamically handled under the hood by the `validateWorkspaceStixData` middleware. \ No newline at end of file diff --git a/.nocommit/prompts/fix-import-pipeline.md b/.nocommit/prompts/fix-import-pipeline.md deleted file mode 100644 index 64d0eea8..00000000 --- a/.nocommit/prompts/fix-import-pipeline.md +++ /dev/null @@ -1,117 +0,0 @@ -We recently made a lot of changes to the core `create` and `updateFull` pipelines for STIX objects. See `BaseService` for details. - -One of the biggest changes was the introduction of the ATT&CK Data Model (ADM) for validating STIX objects. After the STIX object has been composed from the user's request body and right before it's actually created or updated via the repository, we now validate the composed object against the ADM. If there are validation errors, we throw a `ValidationError` with details about the validation errors and warnings (unless the `dryRun` option is set, in which case we just return the validation errors and warnings in the response without throwing an error). - -This change has broken the 'Import Collection Bundle' workflow. - -The 'Import Collection Bundle' workflow allows users to import a STIX bundle containing multiple objects. It is orchestrated by the `app/services/stix/collection-bundles-service/import-bundle.js` module, which processes each object in the bundle and creates it using the appropriate service. The function is attached to the `POST /collection-bundles` endpoint in the `app/routes/collection-bundles-routes.js` module and `app/controllers/collection-bundles-controller.js` controller. - -In `import-bundle.js`, we call each service's `create` method with the `import` option set to `true`: -```javascript -try { - // TODO should we bypass validation for imports? - // or possibly fail open on validation errors where we record the validation error on the object but still allow the import to proceed? - // for validation errors, the object may need to be placed into a quarantined state where it is visible but read-only except through a PUT operation that allows updates to be made to fix the validation errors - await service.create(newObject, { import: true }); -} catch (err) { - if (err.message === service.errors?.duplicateId || err instanceof DuplicateIdError) { - throw err; - } - // Record save error but continue import - const importError = { - object_ref: importObject.id, - object_modified: importObject.modified, - error_type: importErrors.saveError, - error_message: err.message, - }; - logger.verbose( - `Import Bundle Error: Unable to save object. id=${importObject.id}, modified=${importObject.modified}, ${err.message}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); -} -``` -The `import` option is used to indicate that the object is being created as part of an import operation. In the `BaseService`, we check for this option and run a different `create` pipeline that bypasses certain steps. In actuality, we have not given sufficient thought to how the `import` option should affect the `create` pipeline. - -To ground the discussion, let's consider the case of importing the ATT&CK Mobile bundle. 1997 new objects were added. 538 objects could not be imported. The import errors are captured in `import-errors.json`. In addition, I modified the `BaseService._createFromImport` method to log the validation errors and warnings right before throwing the `ValidationError`. The logged validation errors and warnings are captured in `import_bundle_runtime_logs.txt`. In here, we see the exact Zod validation errors that are occurring during the import. - -Let's look at at some examples. - -The following object is triggering one ADM error: -``` -{ - workspace: { - collections: [ - { - collection_ref: "x-mitre-collection--dac0d2d7-8653-445c-9bff-82f934c1e858", - collection_modified: "2025-11-13T14:00:00.188Z", - }, - ], - attack_id: "DET0680", - }, - stix: { - type: "x-mitre-detection-strategy", - spec_version: "2.1", - id: "x-mitre-detection-strategy--9935655b-cd9b-485f-84ea-1b3b4b765413", - created: "2025-10-21T15:10:28.402Z", - created_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", - external_references: [ - { - source_name: "mitre-attack", - url: "https://attack.mitre.org/detectionstrategies/DET0680", - external_id: "DET0680", - }, - ], - object_marking_refs: [ - "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", - ], - modified: "2025-10-21T15:10:28.402Z", - name: "Detection of Security Software Discovery", - x_mitre_modified_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", - x_mitre_version: "1.0", - x_mitre_attack_spec_version: "3.3.0", - x_mitre_domains: [ - "mobile-attack", - ], - x_mitre_analytic_refs: [ - "x-mitre-analytic--87d2ccc4-f82e-493d-9c6f-03303253aec2", - "x-mitre-analytic--9c721bd4-75df-4381-bd70-29679aa78a4b", - ], - x_mitre_deprecated: false, - }, -} -``` - -Here is the ADM error: -``` -[ - { - message: "x_mitre_contributors is Invalid input: expected array, received undefined", - path: [ - "x_mitre_contributors", - ], - code: "invalid_type", - input: undefined, - }, -] -``` - -This is an _actual_ issue. The detection strategy should absolutely have an `x_mitre_contributors` field. - -This begs the question: how should we handle it? - -One options is that we can permit the object to be imported, but block subsequent POST requests for the object and instead permit only PUT operations. For context, the POST operations results in new permutations of the object being created, i.e., when you POST an object, a new document is created in the database. PUT on the other hand modifies an existing document in-place. Thus, the idea here is that we can permit users to do in-place modifications to the imported document to make it compliant. - -In addition, it might be beneficial to capture the validation errors in the entity document. This would make it easy to query a list of objects in the database with known validation issues, and it would also make it easy for GET/retrieve requests to return responses that include the known errors so the user can be informed that the retrieved object is not fully STIX/ATT&CK compliant. - -Another issue is that we are validating the ATT&CK bundles which contain objects that pre-date the ADM; there are bound to be validation issues that we can simply ignore, at least until we can review them and decide whether to retroactively amend them or update the ATT&CK specification (the ADM) to accommodate them. Thus, we need to identify non-revoked and non-deprecated objects and create bypass rules for them. - -Additionally, we need to implement logic for bypassing ADM validation on revoked and deprecated objects when importing. We don't retroactively amend revoked objects or deprecated objects by design, so there's no need to validate them (or if we do validate them, we should fail open). - -Please devise a plan to address the following: - -1. Capture all of the validation errors that occur during bundle importation so we can review them. -2. Update the Mongoose models to enable storing validation errors to make querying for objects with known validation errors easier. Make sure to capture information about which ADM version was used. There are two values we should capture: the ATT&CK Specification version, and the ADM TypeScript library version. The former can be retrieved from a global const which is exported from the ADM library root path: `export const ATTACK_SPEC_VERSION = '3.3.0' as const;`. The latter can be inferred/retrieved from the ADM's `package.json`, or perhaps we can grab it from the server's `@mitre-attack/attack-data-model` dependency version. -3. During import, fail-open or skip validation for revoked objects. -4. During import, fail-open or skip validation for deprecated objects. -5. Amend the create pipeline to reject requests if object has known validation issues. Restrict writing of objects with known validation issues to PUT/updateFull operations only. - diff --git a/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md b/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md deleted file mode 100644 index bcd95b8a..00000000 --- a/.nocommit/prompts/namespace-prefix-and-adm-validation-bypass-rules.md +++ /dev/null @@ -1,105 +0,0 @@ -The backend is responsible for generating and setting the ATT&CK ID for new objects created in the system. The ATT&CK ID is a unique identifier that follows a specific format, typically consisting of a prefix (e.g., "T" for techniques, "S" for software) followed by a number (e.g., "T1234"). - -When a new object is created, the backend will check the type of the object and generate an appropriate ATT&CK ID based on the existing IDs in the system. This ensures that each object has a unique identifier that can be easily referenced and linked to other objects within the ATT&CK framework. - -The backend supports another feature called the namespace. You can optionally configure a namespace for your ATT&CK IDs to prevent conflicts with objects created by ATT&CK or other organizations and to uniquely identify any objects created by your organization. - -The namespace prefix will appear in upper-case letters and will be prepended to the ATT&CK ID of newly-created objects. The namespace range is a 4-digit number that specifies the lower-bound from which to start generating new object IDs. - -Your namespace prefix will be shown to anyone who downloads collections that you publish. Subsequent changes by others to those objects will not modify the prefix. Updates to your organization namespace prefix will not automatically update the contents of your knowledge base and will only apply to new objects. - -For example, a namespace prefix of "FOOBAR" with a namespace range of "1000" would generate ATT&CK IDs like "FOOBAR-T1000", "FOOBAR-T1001", and so on for new objects created within that namespace. This allows for clear identification of objects created by your organization while maintaining compatibility with the broader ATT&CK framework. - -Please modify the backend code to implement the generation of ATT&CK IDs with optional namespace support. If a namespace is configured, the generated ATT&CK IDs should include the namespace prefix and follow the specified format. If no namespace is configured, the backend should generate ATT&CK IDs in the standard format without a prefix. Notably, the backend already has the capability to generate ATT&CK IDs, so all that is needed is to modify the existing code to incorporate the namespace functionality. - -The ATT&CK ID is generated and set in `BaseService.create`: -``` -// Generate a new ATT&CK ID -attackId = await attackIdGenerator.generateAttackId( - this.type, - this.repository, - isSubtechnique, - parentTechniqueId, -); -logger.debug(`Generated new ATT&CK ID: ${attackId}`); -} - -data.workspace = data.workspace || {}; -data.workspace.attack_id = attackId; -``` - -`attackIdGenerator.generateAttackId` is the function responsible for generating the ATT&CK ID. It is deifned in `app/lib/attack-id-generator.js`. Please modify this function to incorporate the namespace functionality as described above. The function should check if a namespace is configured and generate the ATT&CK ID accordingly, ensuring that it follows the specified format and maintains uniqueness within the system. - ---- - -The behavior is implemented, but shed light on an issue with data validation. The backend currently validates objects using the ATT&CK Data Model (ADM) Zod schemas (via `@mitre-attack/attack-data-model`) during the creation process. These schemas strictly reflect the standard ATT&CK ID format (e.g., "T1234") and do not account for the optional namespace prefix (e.g., "FOOBAR-T1234"). As a result, when a namespace is configured and the backend generates ATT&CK IDs with the prefix, the validation process fails because the generated IDs do not match the expected format defined in the ADM Zod schemas. - -Example error: -``` -external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.### -``` - -We can't modify the ADM Zod schemas to accommodate the namespace prefix, as they are designed to reflect the standard ATT&CK ID format. Therefore, we need to implement a solution that allows for the generation of ATT&CK IDs with the namespace prefix while ensuring that the validation process can still succeed. - -I propose the following: - -Right after we perform validation, but before we throw a `ValidationError`, we should check if any of the validation errors are permissible. -``` -// ────────────────────────────────────────────── -// 5. VALIDATE WITH ADM -// ────────────────────────────────────────────── -const { errors, warnings } = this.validateComposedObject(data); - -if (errors.length > 0) { - for (const error of errors) { - if (!errorIsPermissible(error)) { - throw new ValidationError('ADM validation failed', { details: errors, warnings }); - } - } -} -``` - -We should add a new database collection to store permissible validation errors. This collection will allow us to define specific validation errors that can be ignored during the validation process. Each entry in this collection should include details about the error, such as the error message, the field it pertains to, and any conditions under which it should be considered permissible. For this to work, we'll need to implement a new Mongoose model that defines the collection and the entity that represents a document within that collection. - -Entity definition: -- fieldPath: - - description: The path to the field that the validation error pertains to - - type: [String] - - example: ["external_references", "0", "external_id"] -- errorCode: - - description: The Zod error code that should be considered permissible - - type: String - - example: "invalid_type", "invalid_value", etc. -- stixType: - - description: The STIX object type that the validation error pertains to - - type: String - - example: "attack-pattern", "course-of-action", etc. -- suppressError: - - description: A boolean flag indicating whether to suppress the error (i.e., consider it permissible). This makes it easy to toggle the permissibility of specific errors without needing to delete entries from the collection. - - type: Boolean - - example: true - -We will then implement a function `errorIsPermissible` that checks if a given validation error matches any of the entries in the permissible errors collection. This function will take the validation error as input, extract relevant details (such as the field path, error code, and STIX type), and query the permissible errors collection to determine if it should be ignored. - -Circling back to the namespace prefix issue, we can have the backend automatically create an entry in the permissible errors collection for the specific validation error related to the ATT&CK ID format whenever a namespace is configured. This entry would specify the field path (e.g., ["external_references", "0", "external_id"]), the error code (e.g., "invalid_format"), and the relevant STIX type (e.g., "attack-pattern"). By doing this, we can ensure that when the backend generates ATT&CK IDs with the namespace prefix, the validation process will recognize the specific error as permissible and allow it to pass without throwing a `ValidationError`. - -This is a complex solution that will involve create a new router, controller, service, repository, and Mongoose model for managing the permissible validation errors. We will need to implement CRUD operations for this new entity, as well as the logic for checking permissible errors during the validation process. However, this approach will provide a flexible and scalable way to handle validation errors that may arise from the use of namespaces or other custom configurations in the future. - -Please plan and implement the necessary changes to the backend codebase to support this solution, ensuring that the generation of ATT&CK IDs with optional namespace support is seamlessly integrated with the existing validation process while maintaining the integrity of the data and the overall functionality of the system. Note that the proposed entity definition and the `errorIsPermissible` function are just examples to illustrate the concept. The actual implementation may require additional fields or logic based on the specific requirements and constraints of the system. - -To get you started, here is the literal `errors` list that is returned from the `validateComposedObject` function, which contains the validation errors that we will be checking against the permissible errors collection: - -``` -[ - { - message: "external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.###.", - path: [ - "external_references", - 0, - "external_id", - ], - code: "custom", - input: undefined, - }, -] -``` \ No newline at end of file diff --git a/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md b/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md deleted file mode 100644 index d0bba1c7..00000000 --- a/.nocommit/prompts/notes-for-revoke-and-subtechnique-workflows.md +++ /dev/null @@ -1,115 +0,0 @@ -When you post/create a new object with `workspace: {}` a TypeError will throw: -``` - message: "catch all: TypeError: Cannot set properties of undefined (setting 'created_by_user_account')", -``` - -The root cause is this line in `BaseService.create`: -``` - if (options.userAccountId) { - data.workspace.workflow.created_by_user_account = options.userAccountId; - } -``` - -Here's the `data` payload it's trying to process: -``` -{ - workspace: { - attack_id: "M1077", - }, - stix: { - type: "course-of-action", - spec_version: "2.1", - created: "2026-03-26T20:16:47.000Z", - modified: "2026-03-26T20:16:47.000Z", - id: "course-of-action--059ba11e-e3dc-49aa-84ca-88197f40d4eb", - created_by_ref: "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", - external_references: [ - { - source_name: "mitre-attack", - external_id: "M1077", - url: "https://attack.mitre.org/mitigations/M1077", - }, - ], - object_marking_refs: [ - "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", - ], - name: "Application Isolation and Sandboxing", - description: "Restrict the execution of code to a virtual environment on or in-transit to an endpoint system.", - labels: [ - "IEC 62443-3-3:2013 - SR 5.4", - "IEC 62443-4-2:2019 - CR 5.4", - "NIST SP 800-53 Rev. 5 - SI-3", - ], - x_mitre_modified_by_ref: "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", - x_mitre_deprecated: false, - x_mitre_domains: [ - "ics-attack", - ], - x_mitre_version: "1.0", - x_mitre_attack_spec_version: "3.3.0", - }, -} -``` - - ---- - -This error pops up during the revoke workflow due to the repository layer returning Date objects in returned entities. -``` -[ - { - message: "created is Invalid STIX timestamp format: must be an RFC3339 timestamp with a timezone specification of 'Z'.", - path: [ - "created", - ], - code: "invalid_type", - input: undefined, - }, - { - message: "modified is Invalid STIX timestamp format: must be an RFC3339 timestamp with a timezone specification of 'Z'.", - path: [ - "modified", - ], - code: "invalid_type", - input: undefined, - }, -] -``` - -The service layer needs to coerce them before running through ADM validation. -The proper solution would be to address this inside the repository layer. Essentially, the DAO should normalize dates when returning aggregation results. Since its the boundary between MongoDB and the application layer, consumers shouldn't need to know Mongoose return Date objects, let alone Mongoose artifacts like `__t` and `__v`. -However, updating the repository layer to consistently return STIX compliant date strings as opposed to Date objects would break a lot of things. At least 9 files would break, including critical paths: - - - relationships-service.js and reports-service.js — sort by Date subtraction: b.stix.modified - a.stix.modified - (works with Dates, not strings) - - sync-collection-indexes-task.js — calls .getTime() directly on stix.modified - - Release tracks layer (4+ files) — heavy use of .getTime() for conflict resolution, deduplication, export schemas - - detection-strategies-repository.js — direct > comparison on stix.modified Date objects - -The compromise solution: normalize dates in create()'s input pipeline. It's a single fix point, it doesn't change the repository contract, and it protects any future code path that feeds DB data back through create(). -``` -/** -* Coerces any STIX date fields that are JavaScript Date objects into ISO-8601 strings. -* -* Mongoose schemas define timestamp fields (created, modified, start_time, stop_time) -* as `{ type: Date }`, so documents retrieved from MongoDB carry JS Date objects. -* The ADM validation layer (Zod) expects RFC3339 strings. This method bridges that -* gap so that data originating from the repository can safely pass through create() -* without manual per-call-site coercion. -* -* @param {Object} data - The request data ({ stix, workspace }) -*/ -normalizeDateFields(data) { -const stix = data.stix; -if (!stix) return; - -const dateFields = ['created', 'modified', 'start_time', 'stop_time']; -for (const field of dateFields) { - if (stix[field] instanceof Date) { - stix[field] = stix[field].toISOString(); - } -} -} -``` - -We will likely address this more holistically using systems design principles in the future. For now, this is the most pragmatic solution. \ No newline at end of file diff --git a/.nocommit/prompts/organization-identity-syncing.md b/.nocommit/prompts/organization-identity-syncing.md deleted file mode 100644 index 2bdb2d98..00000000 --- a/.nocommit/prompts/organization-identity-syncing.md +++ /dev/null @@ -1,69 +0,0 @@ -There are two global settings: the namespace prefix, and the organization identity. - -The namespace prefix is used to generate attack IDs in a specific format. The configuration is comprised of two values: the prefix (e.g. "ACME") and the rangeStart (e.g., 1000). When generating attack IDs, the system will look for existing attack IDs with the specified prefix, find the maximum number used, and generate the next ID by incrementing that maximum. If no existing IDs are found, it will start from the rangeStart value. For example, if the prefix is "ACME" and the rangeStart is 1000, and the existing IDs are ACME-1000, ACME-1001, and ACME-1002, the next generated ID would be ACME-1003. If there are no existing IDs with the "ACME" prefix, the first generated ID would be ACME-1000. - -The organization identity is used to set the `created_by_ref` and `x_mitre_modified_by_ref` properties on all created and modified objects, respectively. This ensures that all objects are attributed to the organization identity. The organization identity is also used in the bypass rules for validation, specifically to bypass the rule that requires `x_mitre_modified_by_ref` to be set to a valid identity when the value is set to the organization identity. This is necessary because the ATT&CK Data Model (ADM) validation explicitly requires that the `x_mitre_modified_by_ref` property is set to the _MITRE_ organization identity (which happens to be `'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5'`) -- the ADM specifically reflects the official ATT&CK namespace, and therefore requires that any object that has `x_mitre_modified_by_ref` set must be set to the _MITRE_ organization identity. However, in the ATT&CK Workbench application, we want to allow organizations/teams/users to set `x_mitre_modified_by_ref` to their own organization identity (which is a common use case), so we need to have a bypass rule that allows objects with `x_mitre_modified_by_ref` set to the organization identity to bypass the validation rule that requires `x_mitre_modified_by_ref` to be set to a valid identity. Luckily, this scaffolding is already in place via the validation bypass rules. - -The issue that I would like to discuss is related to the organization identity. There are two relevant endpoints: - -1. `POST /api/config/organization-identity` - this endpoint is used to set the `organization_identity_ref` field in the one entity that lives int he `systemconfiguration` Mongo Collection. Here is a copy of the entity as shown in MongoDB Compass: - -```json -{ - "_id": { - "$oid": "6969691cd1aed3fbdf8f8007" - }, - "organization_identity_ref": "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", - "default_marking_definitions": [], - "organization_namespace": { - "range_start": 0, - "prefix": "SEAN" - }, - "__v": 0, - "anonymous_user_account_id": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" -} -``` - -2. `POST /api/identities` and `PUT /api/identities/:stixId/modified/:modified` - these endpoints are used to create and update STIX identity SDO objects in the `attackObjects` Mongo Collection. Identities are first class objects in STIX and are handled as such in the ATT&CK Workbench application. - -The issue is twofold: - -When the original creators of Workbench implemented the frontend, they wired up the frontend mechanism for setting the organization identity to the `POST /api/identities` endpoint. The `POST /api/config/organization-identity` endpoint is not currently wired up to the frontend, and in fact, it is not currently used at all in the backend. - -The problem with the current implementation is that if a user sets the organization identity via the `POST /api/identities` endpoint, there is no mechanism in place to set the `organization_identity_ref` field in the `systemconfiguration` collection to the newly created organization identity. This means that there is a disconnect between the organization identity that is created and the organization identity that is used in the application configuration. - -The solution: - -Despite being first class objects in STIX, identities are not fully supported as first class objects in the current frontend implementation. The frontend only supports creating identities via the aforementioned global organization identity mechanism, which provides two fields: "organization name" and "organization description", which map to the `name` and `description` fields of the identity SDO, respectively. A better solution would be to fully support identities as first class objects in the frontend, enabling users to create and manage identities in the frontend via the standard create and update workflows that are used for all other objects. - -Then, the `POST /api/config/organization-identity` endpoint can be refactored to simply set the `organization_identity_ref` field in the `systemconfiguration` collection to point to an existing identity object in the `attackObjects` collection, and the frontend can be wired up to use this endpoint when a user selects an existing identity to be the organization identity. This would ensure that there is a clear connection between the organization identity that is created and the organization identity that is used in the application configuration, and it would also provide a more robust and flexible mechanism for managing identities in the application. - -For our purposes, we only need to make changes to the backend, specifically the `POST /api/config/organization-identity` endpoint's service logic: - -```javascript -async setOrganizationIdentity(stixId) { - const systemConfig = await this.repository.retrieveOne(); - - if (systemConfig) { - systemConfig.organization_identity_ref = stixId; - await this.repository.constructor.saveDocument(systemConfig); - } else { - const systemConfigData = { organization_identity_ref: stixId }; - const newConfig = this.repository.createNewDocument(systemConfigData); - await this.repository.constructor.saveDocument(newConfig); - } -} -``` - -Notice that the above code assumes that the `stixId` being passed in is already the ID of an existing identity object in the `attackObjects` collection. This means that the frontend will need to ensure that it is passing in a valid `stixId` when calling this endpoint, and it will also need to ensure that the identity object with that `stixId` already exists in the `attackObjects` collection before calling this endpoint. - -We should refactor this to check if the provided `stixId` corresponds to an existing identity object in the `attackObjects` collection (or more specifically, in the `identities` repository), and if it does, then we can proceed to set the `organization_identity_ref` field in the `systemconfiguration` collection. If it does not, then we should throw an error indicating that the specified identity does not exist. - -Tangential to this, we should also consider how the organization identity affects downstream logic, specifically the inheritance logic for the `created_by_ref` and `x_mitre_modified_by_ref` properties. The intended behavior is that `x_mitre_modified_by_ref` should always be set to the organization identity. `created_by_ref` has two potential intended behaviors: it can either be set to the organization identity _if_ the object was not imported from an external source, _or_ it can be set to the organization identity if the object's provenance is the current organization. We thus need to start tracking the provenance of the entity in the `systemconfiguration` collection (currently, we edit the `organization_identity_ref` field in the one and only document in the `systemconfiguration` collection; it is an in-place update to that document, so we can't infer how the organization identity has changed over time based on the history of that document, because there is only one document and it is being updated in place. Therefore, we need to start tracking the provenance of the organization identity itself in order to determine whether a created object should have its `created_by_ref` set to the organization identity based on whether the object's provenance is the current organization or an external source.) - -Consider an example: - -1. A user creates an identity object in the frontend with the name "ACME Corporation" and description "A fictional company". This identity object is created via the `POST /api/identities` endpoint, and it is assigned a `stixId` of `identity--0001`. -2. The user creates a technique object with the name "Spear Phishing" and description "A technique used to target specific individuals". This technique object is created via the `POST /api/techniques` endpoint, and it has its `created_by_ref` set to `identity--0001` and its `x_mitre_modified_by_ref` set to `identity--0001`. -3. The user then changes the organization identity to `identity--0002` via the `POST /api/config/organization-identity` endpoint. This updates the `organization_identity_ref` field in the `systemconfiguration` collection to point to `identity--0002`. -4. This should propagate to all existing objects in the system, such that the technique object that was created in step 2 now has its `created_by_ref` and `x_mitre_modified_by_ref` properties updated to `identity--0002`, because the provenance of that object is now the current organization (since it was created by an identity that is part of the current organization). Importantly, only the latest version of the technique object should be updated, and it should NOT be an in-place update to the existing object version; rather, a new version of the technique object should be created with the updated `created_by_ref`. This is important for maintaining the integrity of the version history of the object, as well as for ensuring that the ADM validation rules are properly applied to the new version of the object with the updated organization identity. This propagation should only apply to objects that were created by identities that are part of the current organization; if an object was created by an identity that is not part of the current organization (e.g. an imported object with a `created_by_ref` that points to an identity that is not in the `attackObjects` collection), then that object's `created_by_ref` should not be updated when the organization identity changes, because the provenance of that object is not the current organization. This means that we need to have a way to determine whether an object's provenance is the current organization or an external source, which brings us back to the need to track the provenance of the organization identity itself in order to make this determination. \ No newline at end of file diff --git a/.nocommit/prompts/refactor-export-service.md b/.nocommit/prompts/refactor-export-service.md deleted file mode 100644 index 98c3a89c..00000000 --- a/.nocommit/prompts/refactor-export-service.md +++ /dev/null @@ -1,66 +0,0 @@ -Please read the specification and concept documentation for the "release tracks" feature, which is actively in development, at `docs/COLLECTIONS_V2/`. Once you have a lay of the land, focus on helping me refactoring the export service (`app/services/release-tracks/export-service.js`), and update the application code and documentation (only if necessary). - -Context: -I am interested in using Zod v4 to encapsulate DTO transformation logic. The benefit being that we can define a Zod schema for each of the output formats (`workbench`, `bunde`, `filesystemstore`), which are self-contained, easy to read, and well supported. The input data would be the `snapshot` object, which is retrieved from the snapshot service and passed to the `exportSnapshot` function as the first positional argument (also labeled `snapshot`). - - -## Transforms - -Note: For bi-directional transforms, use codecs. - -Transforms are a special kind of schema that perform a unidirectional transformation. Instead of validating input, they accept anything and perform some transformation on the data. To define a transform: - -```javascript -const castToString = z.transform((val) => String(val)); - -castToString.parse("asdf"); // => "asdf" -castToString.parse(123); // => "123" -castToString.parse(true); // => "true" -``` - -Transform functions should never throw. Thrown errors are not caught by Zod. - -To perform validation logic inside a transform, use `ctx`. To report a validation issue, push a new issue onto `ctx.issues` (similar to the `.check()` API). - -```javascript -const coercedInt = z.transform((val, ctx) => { - try { - const parsed = Number.parseInt(String(val)); - return parsed; - } catch (e) { - ctx.issues.push({ - code: "custom", - message: "Not a number", - input: val, - }); - - // this is a special constant with type `never` - // returning it lets you exit the transform without impacting the inferred return type - return z.NEVER; - } -}); -``` - -Most commonly, transforms are used in conjunction with Pipes. This combination is useful for performing some initial validation, then transforming the parsed data into another form. - -```javascript -const stringToLength = z.string().pipe(z.transform(val => val.length)); -stringToLength.parse("hello"); // => 5 -``` - -## `.transform()` - -Piping some schema into a transform is a common pattern, so Zod provides a convenience `.transform()` method. - -```javascript -const stringToLength = z.string().transform(val => val.length); -``` - - -The source code is located in the `app/` folder. Additionally, the following documentation files may help contextualize some critical aspects of the software design: - -- `docs/EVENT_BUS_ARCHITECTURE.md` -- `docs/CROSS_SERVICE_READS_PATTERN.md` -- `docs/LIFECYCLE_HOOKS_GUIDE.md` -- `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` -- `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md` diff --git a/.nocommit/prompts/remove the include_candidates_in_snapshots property.md b/.nocommit/prompts/remove the include_candidates_in_snapshots property.md deleted file mode 100644 index 07333f95..00000000 --- a/.nocommit/prompts/remove the include_candidates_in_snapshots property.md +++ /dev/null @@ -1,36 +0,0 @@ -Please read the specification and concept documentation for the "release tracks" feature, which is actively in development, at `docs/COLLECTIONS_V2/`. Once you have a lay of the land, focus on helping me remove the `include_candidates_in_snapshots` property from the release-track schema, and update the application code and documentation accordingly. - -Context: -We don't need to statefully track retrieval/export filter properties in the release-track config. It makes more sense to treat such filters as stateless query parameters that users can set during export operations. In other words, when users retrieve a release-track snapshot (draft or release), they should be able to specify or constrain the resultant snapshot to their liking. - -There are several ways the user can export a release-track snapshot via simple Get/Retreive operations: - -- Get Latest Snapshot: `GET /api/release-tracks/:id` -- Get Specific Snapshot: `GET /api/release-tracks/:id/snapshots/:modified` - -Such retrieval endpoints should support two query parmeters: - -- `include`: Allows users to filter/specify which tiers of objects will be included in the output: - - If unset, only `members` are included - - If `include=candidates`, then members and candidates are included - - If `include=staged`, then members and staged are included - - If `include=all`, then members,staged, and candidates are included - -- `format`: Allows users to specify the output structure (DTO shape) - - `bundle` (default): produces a standard STIX 2.1 bundle - - `filesystemstore`: STIX FileSystemStore directory structure (will be implemented in a future release) - - `workbench`: Custom format with workflow metadata for UI. This is meant to structure the data in as convenient a way as possible for the frontend to hydrate/render the content - -Importantly, the `include` filter should NOT be supported by bump dry-runs operations (`POST /api/release-tracks/:id/bump` with `dry_run: true` in `req.body`), nor bump preview operations (`GET /api/release-tracks/:id/bump/preview`). Bump dry-runs and bump previews are meant to show the user what *will* happen when a bump occurs; filters will only confuse the user because they allow the user to ad-hoc transform the resultant release snapshot even though that ad-hoc transformation will not actually be reflected in the final release snapshot. - -Please start by updating the specification and concept documentation in `docs/COLLECTIONS_V2/`. This folder contains the source of truth for how the release tracks feature operates. - -Once the documentation reflects what we want, please familiarize yourself with the software design/architecture. The source code is located in the `app/` folder. Additionally, the following documentation files may help contextualize some critical aspects of the software design: - -- `docs/EVENT_BUS_ARCHITECTURE.md` -- `docs/CROSS_SERVICE_READS_PATTERN.md` -- `docs/LIFECYCLE_HOOKS_GUIDE.md` -- `docs/COLLECTIONS_V2/99_IMPLEMENTATION_PLAN.md` -- `docs/COLLECTIONS_V2/99_SERVICE_LAYER_IMPLEMENTATION_PLAN.md` - -Finally, once you fully understand the state of the software and have an implementation plan for removing `include_candidates_in_snapshots` from the release track schema and tightening up support for the `include` and `format` query parameters, you may commence the refactor. \ No newline at end of file diff --git a/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md b/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md deleted file mode 100644 index 99e9f90a..00000000 --- a/.nocommit/prompts/remove-x-mitre-version-from-existing-relationships.md +++ /dev/null @@ -1,72 +0,0 @@ -Please create a database migration script and place it in `migrations/` that retroactively removes the `x_mitre_version` -field from all non-deprecated relationship documents in the `relationships` Mongo collection. - -Context: Historically, we used to store `x_mitre_version` on relationships. But we've since changed the ATT&CK specification, specifying that the field is no longer permitted on SROs. There are old relationships lingering in some Workbench instances that still contain this field. If you try to amend those relationship documents using the standard POST workflow, the operation will fail due to ADM validation errors, because the ADM will detect the presence of `x_mitre_version` on the posted object (since we're just posting the preexisting object with whatever fields modified) and throw a `ValidationError`. - -Here's an example of an invalid relationship: - -```javascript -{ - workspace: { - workflow: { - state: "reviewed", - created_by_user_account: "identity--b5220818-f881-4f4a-9091-00a07cf2a576", - }, - validation: { - errors: [ - { - message: " is Unrecognized key: \"x_mitre_version\"", - path: [ - ], - code: "unrecognized_keys", - }, - ], - attack_spec_version: "3.3.0", - adm_version: "4.10.0", - validated_at: "2026-04-10T15:27:01.953Z", - }, - }, - stix: { - object_marking_refs: [ - "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168", - ], - type: "relationship", - id: "relationship--06068b8a-0bfe-499c-8c7c-3cf3123a3541", - created: "2022-07-08T13:57:50.268Z", - x_mitre_version: "0.1", - external_references: [ - { - source_name: "Microsoft POLONIUM June 2022", - url: "https://www.microsoft.com/security/blog/2022/06/02/exposing-polonium-activity-and-infrastructure-targeting-israeli-organizations/", - description: "Microsoft. (2022, June 2). Exposing POLONIUM activity and infrastructure targeting Israeli organizations. Retrieved July 1, 2022.", - }, - ], - x_mitre_deprecated: true, - description: "(LinkById: S1023) has the ability to disable OneDrive protections that prevent the theft of token and client secrets.(Citation: Microsoft POLONIUM June 2022)", - spec_version: "2.1", - modified: "2022-08-10T13:01:17.510Z", - created_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", - relationship_type: "uses", - source_ref: "malware--750eb92a-7fdf-451e-9592-1d42357018f1", - target_ref: "attack-pattern--cb715638-29a5-425c-bf77-c805ef3d7cb1", - x_mitre_modified_by_ref: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", - x_mitre_attack_spec_version: "3.3.0", - revoked: false, - }, -} -``` - -And here's a validation error that would occur on the above object: -``` -[ - { - message: " is Unrecognized key: \"x_mitre_version\"", - path: [ - ], - code: "unrecognized_keys", - input: undefined, - }, -] -``` - -We need to apply a database fix to bring preexisting documents into compliance with the new business logic. \ No newline at end of file diff --git a/.nocommit/prompts/revoke-workflow-bug.md b/.nocommit/prompts/revoke-workflow-bug.md deleted file mode 100644 index 8c591776..00000000 --- a/.nocommit/prompts/revoke-workflow-bug.md +++ /dev/null @@ -1,48 +0,0 @@ -I think there might be a logic bug in the `BaseService.revoke` method. - -Please read the method, as well as the documentation in `docs/user/revoke-workflow.md` before we get started. - -I think there is a bug in the way we migrate relationships from the revoked object to the revoking object when the `preserveRelationships` query parameter is set. - -The fundamental goal is to enable a pathway for carrying over relationships that exist on the revoked object onto the revoking object. You might imagine an object being the `source_ref` or `target_ref` or hundreds of relationships, and so this workflow should enable migrating such relationships onto some new/different object the _revoking_ object). - -But I think there's an issue with the logic that determines exactly which relationships to carry over. It says: - -> If the revoking object (Object B) already participates in a relationship with the same source, target, and relationship type as an existing relationship of the revoked object (Object A), the transfer is skipped and a warning is included in the response. - -But this is wrong. It's not possible for Object 'foo' to participate in an identical SRO as Object 'bar' unless the SRO were literally between the two of them. Just think about it: - -```json -// relationship1 -{ - "relationship_type": "mitigates", - "source_ref": "course-of-action--1234", - "target_ref": "attack-pattern-1234" // <-- this is 'foo' -}, -// relationship2 -{ - "relationship_type": "mitigates", - "source_ref": "course-of-action--1234", - "target_ref": "attack-pattern-5678" // <-- this is 'bar' -} -``` - -Let's assume here that we're revoking 'foo' with 'bar'. foo will become revoked, and all of its relationships will be preserved on 'bar'. - -Here, we show situation where we'd want to skip the "preserving" process for relationship1. In other words, we should not preserve the relationship above, dubbed 'relationship1', on 'bar', because 'bar' already has that relationship —— it's relationship2! - -I think we need to amend our business logic to say: "If the revoking object already participates in a relationship with the same relationship type AND the same source OR target, the transfer should be skipped. In other words, if there exists an essentially identical relationship already where the only difference is the revoking and revoked STIX IDs are hotswappable in either the `source_ref` or `target_ref` fields, then that relationship should be skipped. - -Moreover, we need to consider potential edge cases. For example: - -We can see that the server throws a `BadRequestError` if the revoking object is not of the same type as the revoked object, but this does not cover the case of subtechniques (subs) and techniques (parents). I think it should be permissible for subs to revoke parents and vice versa, but we need to reason through the implications and limitations to avoid from putting the database into an invalid state. - -If a subtechnique (Object B) revokes a parent (Object A), then we should require that Object B is NOT a subtechnique of Object A. Otherwise weirdness might ensue if we set `preserveRelationships=true`: - - If Object A has _other_ subtechniques, they would be migrated to Object B, leaving us with a state where subtechniques are subtechniques of other subtechniques. This is not permissible: subtechniques can only be subtechniques of parent techniques, i.e., nested subtechniques are not permitted. - - We would also end up with Object B being orphaned (a subtechnique without a parent). If this is the desired operation, then Object B should first be converted to a parent. There are separate workflow endpoints for converting techniques to subtechniques and vice versa. - -Are there other edge cases we need to consider? - -Is our logic for identifying out-of-scope relationships that should be skipped during the preservation process actually correct? - -Please reason about the current state of the revoke workflow and the concerns brought forth and determine if we need to make any modifications. diff --git a/.nocommit/prompts/revoke-workflow.md b/.nocommit/prompts/revoke-workflow.md deleted file mode 100644 index 715f8abf..00000000 --- a/.nocommit/prompts/revoke-workflow.md +++ /dev/null @@ -1,269 +0,0 @@ -Currently, we have the following workflow in place: - -When a user revokes an object, all relationships referencing the revoked object are deleted. - -This workflow is facilitated by the frontend. In effect, a single POST request containing a revoke operation will cascade into potentially dozens or hundreds of subsequent DELETE requests. Not only is this workflow inefficient, it creates an inconsistent user experience — users interacting with the backend through means other than the frontend SPA (e.g., Python, cURL, etc.) will have to re-implement their own equivalent logic for handling relationships that become orphaned as a consequence of revoking an object. - -Moreover, the workflow doesn't provide an option for preserving preexisting relationships on the _revoking_ object. A user may desire to revoke Object A with Object B — perhaps they differ only in some trivial semantic way — but preserve all of Object A's relationships on Object B. This is not currently possible. The user must separately identify the relationships _before_ revoking the object, then recreate them after Object A is revoked and its relationships are destroyed. - -There is also the problem of the revoke operation itself: it's currently treated as "just another STIX operation" wherein the user makes a POST (for creating new objects as well as creating new revisions for existing objects) or PUT request (for editing existing objects in-place) to the requisite endpoint, such as `POST /api/attack-objects` for techniques and `POST /api/tactics` for tactics. Because the Revoke Workflow is orchestrated by the frontend, the backend has no conception/awareness of revocation. To the backend, toggling `revoked` is no different than modifying the object's `description`. Thus, I want to introduce a new backend workflow for handling revocations, and I want to make it accessible over a explicit endpoint (or set of endpoints), e.g., `POST /api/{type}/revoke`. - -The endpoint should support the following query parameters: - -- name: `preserveRelationships` - type: `boolean` - default: `false` - description: Tells the backend whether _revoked_ object's relationships (i.e., the relationships of Object A) should be carried over to the _revoking_ object (Object B). If true, Object A's relationships should be destroyed AND recreated on Object B. - -As for specifying the _revoked_ and _revoking_ objects themselves, we have two options: - -1. We can use a path parameter to specify the _revoked_ object and thus expose a Revoke Operation on each of the existing STIX type-specific routers, e.g., `/api/:type/:stixId/revoke`. In this solution, `stixId` would refer to the _revoked_ object, and we would specify the _revoking_ object in the request body, e.g.: -```json -{ - "revoking": { - "id": "attack-pattern--0002", - "modified": $Date - } -} -``` -1. Alternatively, we can setup a new, dedicated, type-agnostic router (e.g., `/api/revoke`) and specify _all_ required information in the request body: -```json -{ - "revoking": { - "id": "attack-pattern--0001", - "modified": $Date - }, - "revoking": { - "id": "attack-pattern--0002", - "modified": $Date - } -} -``` - -Read the supporting architectural documentation in the `docs/` folder. Pay particular attention to the following: -- `docs/CROSS_SERVICE_READS_PATTERN.md` -- `docs/EVENT_BUS_ARCHITECTURE.md` -- `docs/LIFECYCLE_HOOKS_GUIDE.md` -- `docs/SERVICE_REQUEST_MIDDLEWARE.md` - -The backend uses OOP makes ample use of inheritance throughout the service layer. All STIX-oriented services extend the `BaseService` class. Core worfklows such as `create` and `updateFull` are orchestrated by the `BaseService` and should NOT be overridden by child classes. Instead, child classes (such as `TechniquesService` and `TacticsService`) should implement lifecycle hook functions like `beforeCreate` and `afterCreate` to augment or modify core workflow behavior. - -In this particular case, we may want to implement a new "core" workflow in the `BaseService`. It might be a method called `revoke`. This would coincide with a new set of router endpoints and controller functions. - -Notably, the backend is an Express.js REST API, and the frontend is an Angular SPA. - -- Backend code: `/Users/ssica/Development/attack/workbench/attack-workbench-rest-api` -- Frontend code: `/Users/ssica/Development/attack/workbench/attack-workbench-frontend` - -Please weigh the pros and cons of each approach and propose a plan for implementing this feature, starting with the backend. - ---- - - Revoke Workflow — Implementation Plan - - Context - - Revoking an object (replacing Object A with Object B) is currently orchestrated by the frontend SPA. The backend has no - concept of revocation — setting stix.revoked = true is indistinguishable from modifying description. This means: - - 1. Non-SPA consumers (Python, cURL) must reimplement the cascade logic themselves - 2. There's no option to preserve Object A's relationships on Object B - 3. A single revoke cascades into dozens of DELETE requests from the frontend - - This plan introduces a backend-orchestrated Revoke Workflow accessible via dedicated endpoints. - - Endpoint Design: Type-Specific Routes (Approach 1) - - Chosen: POST /api/techniques/:stixId/revoke (and equivalent for each type) - - Why not type-agnostic (POST /api/revoke)? - - Every existing route is type-specific — a cross-type endpoint would be the only exception - - Auth middleware is applied per-type in each router file - - Controllers already import their singleton service — no dispatch logic needed - - Service resolution from STIX ID prefix would be fragile and unprecedented in this codebase - - Request format: - POST /api/techniques/attack-pattern--0001/revoke?preserveRelationships=true - - Body: - { - "revoking": { - "stixId": "attack-pattern--0002", - "modified": "2026-03-20T00:00:00.000Z" - } - } - - Response format: - { - "revokedObject": { }, - "revokedByRelationship": { }, - "relationshipsSummary": { - "deleted": 5, - "transferred": 5, - "warnings": [] - } - } - - Revoke Workflow (BaseService.revoke) - - 1. VALIDATE INPUTS - - stixId required (path param = Object A) - - body.revoking.stixId required (Object B) - - body.revoking.modified required - - A !== B (no self-revocation) - - 2. RETRIEVE OBJECTS - - objectA = this.repository.retrieveLatestByStixId(stixId) - - Throw NotFoundError if null - - Throw AlreadyRevokedError if objectA.stix.revoked === true - - objectB = this.repository.retrieveOneByVersion(revokingStixId, revokingModified) - - Throw NotFoundError if null - - 3. LIFECYCLE HOOK: beforeRevoke(objectA, objectB, options) - - 4. MARK OBJECT A AS REVOKED - - Clone objectA's data, set stix.revoked = true - - Call this.create() internally to create a new version - - This reuses the full create pipeline (ATT&CK ID reuse, field composition, ADM validation) - - 5. CREATE REVOKED-BY RELATIONSHIP - - Call relationshipsService.create() with: - { stix: { type: 'relationship', relationship_type: 'revoked-by', - source_ref: objectA.stix.id, target_ref: objectB.stix.id } } - - 6. HANDLE RELATIONSHIPS - - Query all relationships where source_ref OR target_ref = objectA.stix.id - - Exclude the revoked-by relationship just created - - If preserveRelationships === true: - - For each relationship, clone it with objectB substituted for objectA - - Save clones via relationshipsService.create() - - Delete all originals referencing objectA - - 7. LIFECYCLE HOOK: afterRevoke(revokedDocument, objectB, options) - - 8. EMIT EVENT: emitRevokedEvent(revokedDocument, objectB, options) - - 9. RETURN result with revokedObject, revokedByRelationship, relationshipsSummary - - Note on cross-service writes: Step 5-6 involve writing to the relationships collection from BaseService. Per the - architecture docs, cross-service writes should use events. However, the revoke workflow is inherently transactional — the - caller needs to know if relationship transfer failed. Since EventBus.emit() uses Promise.allSettled and swallows errors, - I'll use direct orchestration (calling relationshipsService.create() directly from BaseService.revoke()). This is the same - pragmatic approach used in TechniquesService._propagateShortnameViaNewVersions which directly calls this.create() for - related objects. The event is still emitted afterward for observability. - - Implementation Sequence - - Step 1: Add Exception Classes - - File: app/exceptions/index.js - - Add AlreadyRevokedError (→ 409 Conflict) - - Add SelfRevocationError (→ 400 Bad Request) - - File: app/lib/error-handler.js - - Register AlreadyRevokedError in 409 block - - Register SelfRevocationError in 400 block - - Step 2: Add Event Constants - - File: app/lib/event-constants.js - - Add *_REVOKED constants for each STIX type (following existing pattern) - - Step 3: Add Lifecycle Hooks - - File: app/services/meta-classes/hooks.service.js - - Add beforeRevoke(objectA, objectB, options) — no-op default - - Add afterRevoke(revokedDocument, objectB, options) — no-op default - - Add emitRevokedEvent(revokedDocument, revokingDocument, options) — emits ${this.type}::revoked - - Step 4: Add Repository Methods - - File: app/repository/relationships-repository.js - - Add retrieveAllBySourceOrTarget(stixId) — finds latest version of all relationships referencing a STIX ID as source or - target - - Add deleteManyBySourceOrTarget(stixId, excludeStixIds) — deletes all relationship documents (all versions) where - source_ref or target_ref matches, excluding specified stix.ids - - Step 5: Add revoke() to BaseService - - File: app/services/meta-classes/base.service.js - - Add the revoke(stixId, data, options) method implementing the workflow above - - Import relationshipsService and relationshipsRepository at the top - - Step 6: Add Controller Method - - Files: All type-specific controllers that support revocation: - - app/controllers/techniques-controller.js - - app/controllers/tactics-controller.js - - app/controllers/software-controller.js - - app/controllers/groups-controller.js - - app/controllers/mitigations-controller.js - - app/controllers/campaigns-controller.js - - app/controllers/data-sources-controller.js - - app/controllers/data-components-controller.js - - app/controllers/assets-controller.js - - app/controllers/matrices-controller.js - - Each gets a revoke export: - exports.revoke = async function (req, res, next) { - try { - const options = { - preserveRelationships: req.query.preserveRelationships === 'true', - userAccountId: req.user?.userAccountId, - }; - const result = await service.revoke(req.params.stixId, req.body, options); - return res.status(200).send(result); - } catch (err) { - return next(err); - } - }; - - Step 7: Add Routes - - Files: All matching router files: - - app/routes/techniques-routes.js - - app/routes/tactics-routes.js - - app/routes/software-routes.js - - app/routes/groups-routes.js - - app/routes/mitigations-routes.js - - app/routes/campaigns-routes.js - - app/routes/data-sources-routes.js - - app/routes/data-components-routes.js - - app/routes/assets-routes.js - - app/routes/matrices-routes.js - - Each gets: - router - .route('//:stixId/revoke') - .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), controller.revoke); - - Edge Cases - - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ Case │ Handling │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Object A not found │ 404 NotFoundError │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Object A already revoked │ 409 AlreadyRevokedError │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Object B not found │ 404 NotFoundError │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Self-revocation (A === B) │ 400 SelfRevocationError │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Object A has no relationships │ Steps 5-6 are no-ops; revoked-by still created │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Relationship clone fails mid-way │ Log warning, continue; return warnings array │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Concurrent revocation │ First writer wins (create() throws DuplicateIdError) │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - │ Cross-type revocation (A and B differ) │ Allow — STIX spec permits it │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - - Verification - - 1. Unit tests: Test revoke() in isolation with mocked repositories - 2. Integration tests: - - Revoke without preserving relationships → verify A is revoked, relationships deleted, revoked-by created - - Revoke with preserving relationships → verify relationships cloned to B then deleted from A - - Error cases (not found, already revoked, self-revocation) - 3. Manual API test: POST /api/techniques/:stixId/revoke via cURL \ No newline at end of file diff --git a/.nocommit/prompts/synthetic-data-generator.md b/.nocommit/prompts/synthetic-data-generator.md deleted file mode 100644 index 627e66f2..00000000 --- a/.nocommit/prompts/synthetic-data-generator.md +++ /dev/null @@ -1,625 +0,0 @@ -I need your help solving a critical issue in the source code for the ATT&CK Workbench REST API application: All of our regression tests in `app/tests/` must be run with ATT&CK Data Model (ADM) validation turned off. - -```typescript -describe('Techniques Convert API', function () { - let app; - let passportCookie; - - before(async function () { - await database.initializeConnection(); - await databaseConfiguration.checkSystemConfiguration(); - - config.validateRequests.withAttackDataModel = false; // <-- this is the key line - config.validateRequests.withOpenApi = false; - - app = await require('../../../index').initializeApp(); - passportCookie = await login.loginAnonymous(app); - }); -``` - -The ADM refers to a JavaScript library, `@mitre-attack/attack-data-model`, maintained by us at MITRE, that provides a set of Zod schemas for validating STIX objects against the ATT&CK Data Model. It is the source of truth for what constitutes a valid ATT&CK STIX object, and it was recently integrated into the REST API's request validation logic to ensure that all incoming requests are compliant with the ADM validation rules. - -The reason that the regression tests must be run with ADM disabled is because the ADM makes the validation of requests significantly stricter than it was before, and many of our existing regression tests were not designed with this level of strictness in mind. Most, if not all, of the regression tests in `app/tests/` build request bodies from manually constructed STIX objects, most of which do not pass the ADM validation rules. This is not necessarily a bad thing, as it allows us to test the API's behavior with a wider variety of inputs, including those that may not be fully compliant with the ADM. However, it does mean that we cannot run these tests with ADM validation turned on without first updating them to ensure that their request bodies are compliant with the ADM. - -The way I see it, we have three solutions: - -1. Update all existing regression tests in `app/tests/` to ensure that their request bodies are compliant with the ADM. This would involve reviewing each test case, identifying any non-compliant STIX objects, and modifying them to adhere to the ADM validation rules. While this approach would allow us to run all tests with ADM validation turned on, it would require a significant amount of time and effort, especially if there are many tests that need to be updated. - -2. Create a separate set of regression tests that are specifically designed to be compliant with the ADM. This would allow us to maintain our existing regression tests as they are, while also having a new set of tests that can be run with ADM validation turned on. This approach would require less effort than updating all existing tests, but it would also mean that we have two sets of regression tests to maintain, which could lead to confusion and duplication of effort in the long run. - -3. Implement a synthetic data generator that can create compliant STIX objects for use in our regression tests. This approach is appealing because it presents an opportunity to expose an API for generating fake STIX objects directly from the `@mitre-attack/attack-data-model` library, which could be useful for other purposes beyond just our regression tests. Each regression test would still need to be updated to use the synthetic data generator, but this would likely be less time-consuming than manually updating each test case to use compliant, mock data. Additionally, this approach would likely save time in the long run, as it would allow us to easily generate compliant STIX objects for any future tests we may need to create, without having to manually construct them each time. - -Constraints & Issues: - -The ADM exposes a set of Zod schemas that define the structure and validation rules for STIX objects. However, these on their own are insufficient for validating the request bodies in our regression tests. Importantly, the REST API is more permissiveness than the ADM, allowing the user to omit certain fields that are required the ADM, specifically when the object is flagged as "work-in-progress": - -```typescript -const baseTechniqueData = { - workspace: { - workflow: { - state: 'work-in-progress', - }, - }, - stix: { - name: 'convert-test-technique', - type: 'attack-pattern', - description: 'A technique for conversion tests.', - object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], - created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], - x_mitre_is_subtechnique: false, - x_mitre_platforms: ['Linux'], - }, -}; -``` - -In other words, while the ADM validates fully baked STIX objects that are ready for production use, the REST API also needs to validate draft STIX objects that are still being authored and may not yet be fully compliant with the ADM validation rules. - -Under the hood, the REST API tags the ADM/Zod schema with `.partial()` when the object is flagged as "work-in-progress". However, when we attempt to validate objects with the "awaiting-review" or "reviewed" workflow states, the ADM/Zod schemas are not tagged with `.partial()`, thereby deferring to the underlying ATT&CK & STIX validation rules. - -This presents an engineering challenge because it means that we cannot simply implement a synthetic data generator and expose it as an API in the `@mitre-attack/attack-data-model` library, as the validation rules for compliant STIX objects would differ based on the workflow state of the object. We would need to implement logic in the synthetic data generator to determine which fields are required based on the workflow state, and generate compliant STIX objects accordingly. This not only adds complexity to the implementation of the synthetic data generator, but it is arguably out of scope for the `@mitre-attack/attack-data-model` library, which is intended to be a general-purpose library for working with valid ATT&CK content represented in STIX. - -One option would be to implement the synthetic data generator in the REST API codebase instead of the `@mitre-attack/attack-data-model` library. This would allow us to tailor the generated STIX objects to be compliant with the specific validation rules of the REST API, including the handling of different workflow states. However, this approach would limit the reusability of the synthetic data generator, as it would be tightly coupled to the REST API's validation logic and may not be suitable for use in other contexts where different validation rules apply. - -Another option would be to implement the synthetic data generator in the `@mitre-attack/attack-data-model` library, but to wrap it with yet another API in the REST API codebase that applies the necessary logic to generate compliant STIX objects based on the workflow state. This would allow us to maintain the general-purpose nature of the synthetic data generator while still ensuring that it can be used to generate compliant STIX objects for our regression tests. However, this approach would add an additional layer of complexity to our codebase, as we would need to maintain both the synthetic data generator and the wrapper API in the REST API codebase, and ensure that they remain in sync with each other as we make updates and changes over time. - -The REST API does technically already have a utility function for dynamically yielding status-adjusted Zod schemas based on the workflow state of the object being validated: -``` -// in app/lib/validation-schemas.js -'use strict'; - -const { - tacticSchema, - - /** techniques */ - techniqueSchema, - techniquePartialSchema, - - /** groups */ - groupSchema, - groupPartialSchema, - - /** malware */ - malwareSchema, - malwarePartialSchema, - - /** tools */ - toolSchema, - toolPartialSchema, - - /** campaigns */ - campaignSchema, - campaignPartialSchema, - - /** relationships */ - relationshipSchema, - relationshipPartialSchema, - - /** simple schemas (no checks/refinements) */ - identitySchema, - mitigationSchema, - assetSchema, - dataSourceSchema, - dataComponentSchema, - detectionStrategySchema, - analyticSchema, - matrixSchema, - collectionSchema, - markingDefinitionSchema, -} = require('@mitre-attack/attack-data-model/dist/index.cjs'); - -// The ADM package exposes two validation shapes for several STIX types: -// - a full schema for normal validation -// - a prebuilt partial schema for draft/work-in-progress validation -// -// Workbench treats `work-in-progress` objects differently from objects in -// later workflow states. WIP objects are allowed to omit fields that are still -// being authored, while `awaiting-review` and `reviewed` objects should be -// held to the complete schema. -// -// We prefer the ADM-provided `*PartialSchema` exports when they exist rather -// than deriving them ourselves at call time. That keeps this layer aligned -// with however ADM composes partial validation for schemas that may include -// additional checks or refinements. -const STIX_SCHEMAS = { - 'x-mitre-tactic': tacticSchema, - 'attack-pattern': { - full: techniqueSchema, - partial: techniquePartialSchema, - }, - 'intrusion-set': { - full: groupSchema, - partial: groupPartialSchema, - }, - malware: { - full: malwareSchema, - partial: malwarePartialSchema, - }, - tool: { - full: toolSchema, - partial: toolPartialSchema, - }, - campaign: { - full: campaignSchema, - partial: campaignPartialSchema, - }, - relationship: { - full: relationshipSchema, - partial: relationshipPartialSchema, - }, - identity: identitySchema, - 'course-of-action': mitigationSchema, - 'marking-definition': markingDefinitionSchema, - 'x-mitre-asset': assetSchema, - 'x-mitre-data-source': dataSourceSchema, - 'x-mitre-data-component': dataComponentSchema, - 'x-mitre-detection-strategy': detectionStrategySchema, - 'x-mitre-analytic': analyticSchema, - 'x-mitre-matrix': matrixSchema, - 'x-mitre-collection': collectionSchema, -}; - -// Cache for locally-derived partial schemas. ADM does not export prebuilt -// partials for every STIX type; for those types we call `.partial()` ourselves. -// That call is expensive enough to show up in bulk-import profiles, so we -// memoize the result per STIX type. -const derivedPartialCache = new Map(); - -/** - * Get the schema to use for validating a STIX object. - * - * Some STIX types define both a full schema and a prebuilt partial schema, - * while others only define a single schema (no partial variant). This helper - * selects the correct schema based on the STIX type and workflow status. - * - * Determination rules: - * - `work-in-progress` uses partial validation so drafts can omit required fields - * - every other workflow state uses full validation - * - if ADM exports a dedicated partial schema, use it directly - * - otherwise, derive a partial schema locally with `.partial()` (memoized) - * - * @param {string} stixType - The STIX `type` being validated (e.g. "attack-pattern") - * @param {string} status - The workflow state (e.g. "work-in-progress", "awaiting-review", "reviewed") - * @returns {Object|null} Zod schema, or null if the STIX type is unknown - */ -function getSchema(stixType, status) { - const admSchemaRef = STIX_SCHEMAS[stixType]; - if (!admSchemaRef) return null; - - // Only draft objects get partial validation. Once an object leaves the - // work-in-progress state, we validate it against the full schema. - const isWip = status === 'work-in-progress'; - - if (admSchemaRef.full && admSchemaRef.partial) { - return isWip ? admSchemaRef.partial : admSchemaRef.full; - } - - if (!isWip) return admSchemaRef; - - let derived = derivedPartialCache.get(stixType); - if (!derived) { - derived = admSchemaRef.partial(); - derivedPartialCache.set(stixType, derived); - } - return derived; -} - -module.exports = { - STIX_SCHEMAS, - getSchema, -}; -``` - -One final option could be to use `getSchema()` to retrieve the appropriate Zod schema, and then use that schema to generate synthetic data using an established Zod data generation library. Zod officially advertises three libraries for mocking: - - -Name Stars Description -1. `@traversable/zod-test`: 157 GitHub Stars; Random zod schema generator built for fuzz testing; includes generators for both valid and invalid data -2. `zod-schema-faker`: 113 GitHub Stars; Generate mock data from zod schemas. Powered by @faker-js/faker and randexp.js. -3. `zocker`: 98 GitHub Stars; Generates valid, semantically meaningful data for your Zod schemas. - -I think this approach is worth exploring. Let's start by assessing the capabilities of these libraries to determine which one would be the best fit for our needs. - -The following markdown is the README for [raversable/zod-test](https://github.com/traversable/schema/tree/main/packages/zod-test): - - -
-

įÆ“š˜š—æš—®š˜ƒš—²š—æš˜€š—®š—Æš—¹š—²/š˜‡š—¼š—±-š˜š—²š˜€š˜

-
- -

- Testing utility that generates arbitrary, pseudorandom zod schemas, powered by fast-check -

- -
- NPM Version -   - TypeScript -   - License -   - npm -   -
- -
- - Static Badge -   - Static Badge -   - Static Badge -   -
-
-
- -## Requirements - -`@traversable/zod-test` has 2 peer dependencies: - -1. [`zod`](https://zod.dev/) (v4) -2. [`fast-check`](https://fast-check.dev/) - -## Usage - -```bash -$ pnpm add -D @traversable/zod-test zod fast-check -``` - -Here's an example of importing the library: - -```typescript -import { z } from 'zod' -import { zxTest } from '@traversable/zod-test' - -// see below for specifc examples -``` - -## Track record - -`@traversabe/zod-test` has found several upstream bugs in `zod`: - -1. Security exploit: `z.object` pollutes the global `Object` prototype - - [Issue](https://github.com/colinhacks/zod/issues/4357) - - [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-ypelnmjv?file=test%2Frepro.test.ts&initialpath=__vitest__/) - -2. Bug: `z.literal` escaping bug - - [Issue](https://github.com/colinhacks/zod/issues/4894) - - [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-w1um2qny?file=test%2Frepro.test.ts&initialpath=__vitest__/) - -3. Bug: "Diagonal" objects passed to `z.enum` produce false negatives -- [Issue](https://github.com/colinhacks/zod/issues/4353) -- [Sandbox](https://stackblitz.com/edit/vitest-dev-vitest-srmahjsw?file=package.json,test%2Fenum.test.ts&initialpath=__vitest__/) - -4. Bug: `z.file` output type incompatible with `globalThis.File` - - [Issue](https://github.com/colinhacks/zod/issues/4973) - - [Sandbox](https://stackblitz.com/edit/zod-file-bug-repro?file=test%2Frepro.test.ts&initialpath=__vitest__/) - - -## Table of contents - -- [`zxTest.fuzz`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestfuzz) -- [`zxTest.seedToSchema`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoschema) -- [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata) -- [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata) -- [`zxTest.seedToValidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddatagenerator) -- [`zxTest.seedToInvalidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddatagenerator) -- [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator) -- [`zxTest.SeedValidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedvaliddatagenerator) -- [`zxTest.SeedInvalidDataGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedinvaliddatagenerator) - - -### `zxTest.fuzz` - -Convert a Zod schema into a [fast-check](https://github.com/dubzzz/fast-check) arbitrary. - -Configure how fuzzed values will be generated via the 2nd argument (`options`). - -Override individual arbitraries via the 3rd argument (`overrides`). - -> [!NOTE] -> -> `zxTest.fuzz` is the __only__ schema-to-generator function that has itself -> been fuzz tested to ensure that no matter what schema you give it, the data-generator it -> returns will always produce valid data. -> -> This excludes schemas that make it impossible to generate valid data, for example: -> -> - `z.never` -> - `z.nonoptional(z.undefined())` -> - `z.enum([])` -> - `z.union([])` -> - `z.intersection(z.number(), z.string())` - -#### Example - -```typescript -import * as vi from 'vitest' -import * as fc from 'fast-check' -import { fuzz } from '@traversable/zod-test' - -const Schema = z.record( - z.string(), - z.union( - z.number(), - z.string(), - ) -) - -const generator = fuzz( - Schema, - { record: { minKeys: 1 }, number: { noDefaultInfinity: true } }, - { string: () => fc.stringMatching(/[\S\s]+[\S]+/) }, -) - -vi.test('fuzz test example', () => { - fc.assert( - fc.property(generator, (data) => { - vi.assert.doesNotThrow(() => Schema.parse(data)) - }), - { numRuns: 1_000 } - ) -}) -``` - -#### See also -- the [fast-check docs](https://fast-check.dev) - - -### `zxTest.seedToSchema` - -Use `zxTest.seedToSchema` to convert a seed generated by `zxTest.SeedGenerator` into a -zod schema that satisfies the configuration options you specified. - -#### Example - -```typescript -import { zxTest } from '@traversable/zod-test' -import * as fc from 'fast-check' - -const builder = zxTest.SeedGenerator()['*'] -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType -``` - - -### `zxTest.seedToValidData` - -Use `zxTest.seedToValidData` to convert a seed generated by `zxTest.SeedGenerator` into -data that satisfies the schema that the seed represents. - -#### Example - -```typescript -import { zxTest } from '@traversable/zod-test' -import * as fc from 'fast-check' - -const builder = zxTest.SeedGenerator()['*'] -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType - -const validData = zxTest.seedToValidData(mySeed) - -mySchema.parse(validData) // will never throw -``` - - -### `zxTest.seedToInvalidData` - -Use `zxTest.seedToInvalidData` to convert a seed generated by `zxTest.SeedGenerator` into -data that does **not** satisfy the schema that the seed represents. - -#### Example - -```typescript -import { zxTest } from '@traversable/zod-test' -import * as fc from 'fast-check' - -const builder = zxTest.SeedGenerator()['*'] -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType - -const invalidData = zxTest.seedToValidData(mySeed) - -mySchema.parse(invalidData) // should always throw -``` - - -### `zxTest.seedToValidDataGenerator` - -Like [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata), except `zxTest.seedToValidDataGenerator` accepts a seed and returns a valid data arbitrary (which can then be used to produce valid data). - -#### Example - -```typescript -import { zxTest } from '@traversable/zod-test' -import * as fc from 'fast-check' - -const builder = zxTest.SeedGenerator()['*'] -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType - -const validDataGenerator = zxTest.seedToValidDataGenerator(mySeed) -const [validData] = fc.sample(validDataGenerator, 1) - -mySchema.parse(validData) // will never throw -``` - - -### `zxTest.seedToInvalidDataGenerator` - -Like [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata), except `zxTest.seedToValidDataGenerator` accepts a seed and returns an invalid data arbitrary (which can then be used to produce invalid data). - -#### Example - -```typescript -import type * as z from 'zod' -import * as fc from 'fast-check' -import { zxTest } from '@traversable/zod-test' - -const builder = zxTest.SeedGenerator()['*'] -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType - -const invalidDataGenerator = zxTest.seedToInvalidDataGenerator(mySeed) -const [invalidData] = fc.sample(invalidDataGenerator, 1) - -mySchema.parse(invalidData) // will always throw -``` - - -### `zxTest.SeedGenerator` - -> [!NOTE] -> -> `zxTest.SeedGenerator` is fairly low-level. All of the other exports of this library have been implemented in terms of `zxTest.SeedGenerator`. - -Generates a configurable, pseudo-random "seed builder". - -- Use [`zxTest.seedToSchema`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoschema) to convert a seed into a zod schema -- Use [`zxTest.seedToValidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtovaliddata) to convert a seed into valid data -- Use [`zxTest.seedToInvalidData`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedtoinvaliddata) to convert a seed into invalid data - -#### Example - -```typescript -import { zxTest } from '@traversable/zod-test' -import * as fc from 'fast-check' - -const builder = zxTest.SeedGenerator({ - include: ["boolean", "string", "object"], - // š™˜ use `include` to only include certain schema types - exclude: ["boolean", "any"], - // š™˜ use `exclude` to exclude certain schema types altogether (overrides `include`) - object: { maxKeys: 5 }, - // š™˜ specific arbitraries are configurable by name -}) - -// included schemas are present as properties on your generator... -builder.string -builder.object - -// ...excluded schemas are not present... -builder.boolean // 🚫 TypeError - -// ...a special wildcard `"*"` property (pronounced "surprise me") is always present: -builder["*"] - -/** - * `fast-check` will generate a seed, which is a data structure containing - * integers that represent a kind of AST. - * - * To use a seed, you need to pass it to an interpreter like `zxTest.seedToSchema`, - * `zxTest.seedToValidData` or `zxTest.seedToInvalidData`: - */ - -const [mySeed] = fc.sample(builder.object, 1) - -const mySchema = zxTest.seedToSchema(mySeed) -// ^? const mySchema: z.ZodType - -const validData = zxTest.seedToValidData(mySeed) -// ^? since the `mySeed` was also used to generate `mySchema`, -// parsing `validData` should always succeed - -const invalidData = zxTest.seedToInvalidData(mySeed) -// ^? since the `mySeed` was also used to generate `mySchema`, -// parsing `invalidData` should always fail -``` - - -### `zxTest.SeedValidDataGenerator` - -Like [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator), except `zxTest.SeedValidDataGenerator` comes pre-configured to exclude schemas that make it impossible to reliably generate valid data. - -> [!NOTE] -> -> `zxTest.SeedValidDataGenerator` does not accept any options. If you need more fine-grained control of the schemas being generated, use [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator). - - - -### `zxTest.SeedInvalidDataGenerator` - -Like [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator), except `zxTest.SeedValidDataGenerator` comes pre-configured to exclude schemas that make it impossible to reliably generate invalid data. - -> [!NOTE] -> -> `zxTest.SeedInvalidDataGenerator` does not accept any options. If you need more fine-grained control of the schemas being generated, use [`zxTest.SeedGenerator`](https://github.com/traversable/schema/tree/main/packages/zod-test#zxtestseedgenerator). -
- -The second option is [Zocker](https://zocker.sigrist.dev/?id=zocker). I have personally tested this one and verified basic functionality seems to work: - -```typescript -import { zocker } from "zocker"; -import { z } from 'zod/v4'; - -// Manually create a compatible schema based on the structure -const compatibleSchema = z.object({ - id: z.string(), - type: z.enum([ - "attack-pattern", - "bundle", - "campaign", - "course-of-action", - "extension-definition", - "identity", - "intrusion-set", - "malware", - "tool", - "marking-definition", - "x-mitre-analytic", - "x-mitre-data-component", - "x-mitre-detection-strategy", - "x-mitre-tactic", - "x-mitre-asset", - "x-mitre-data-source", - "x-mitre-log-source", - "x-mitre-matrix", - "x-mitre-collection", - "relationship", - "file", - "artifact" - ]), - spec_version: z.enum(["2.0", "2.1"]), - created: z.string(), // Should be a timestamp - modified: z.string(), // Should be a timestamp - created_by_ref: z.string().optional(), - labels: z.array(z.string()).optional(), - revoked: z.boolean().optional(), - confidence: z.int().min(1).max(99).optional(), - lang: z.string().optional(), - external_references: z.array(z.object({ - source_name: z.string(), - description: z.string().optional(), - url: z.url().optional(), - external_id: z.string().optional() - })).optional(), - object_marking_refs: z.array(z.string()).optional(), - granular_markings: z.array(z.object({ - marking_ref: z.string(), - selectors: z.array(z.string()) - })).optional(), - extensions: z.record(z.string(), z.unknown()).optional() -}).strict(); - -const out = zocker(compatibleSchema).generate(); -console.log(out); -``` - -The last option is [zod-schema-faker](https://github.com/soc221b/zod-schema-faker). I have not personally tested this one, but based on the documentation it appears to be a straightforward library for generating mock data from Zod schemas. It is powered by `@faker-js/faker` and `randexp.js`, which are both well-known libraries for generating fake data and regular expression-based data, respectively. - -Please check out the documentation for all three libraries and let me know which one you think would be the best fit for our needs. - -Afterwards, please familiarize yourself with the implementation of the `getSchema()` function in `app/lib/validation-schemas.js`, as well as the structure of the STIX objects being used in our regression tests, so that you can design a synthetic data generator that can produce compliant STIX objects based on the appropriate Zod schemas and workflow states. \ No newline at end of file diff --git a/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md b/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md deleted file mode 100644 index fd494563..00000000 --- a/.nocommit/prompts/technique-sub-parent-conversion-partial-fix.md +++ /dev/null @@ -1,23 +0,0 @@ -Historically, the REST API treated all STIX fields as opaque, meaning that users could set, unset, or modify any field on a STIX object using a generic `create` or `update` endpoint, and the API would simply persist those changes without any special handling. This approach worked well for most fields, but it caused issues with certain server-controlled fields that have specific semantics or constraints, such as `created`, `modified`, and `revoked`. - -There exists a particular problem with the `x_mitre_is_subtechnique` field on Technique objects. This field is intended to be a boolean that indicates whether a Technique is a sub-technique (i.e., it has a parent technique) or not. However, since the REST API treated all fields as opaque, users could set this field to `true` or `false` arbitrarily, which could lead to inconsistencies in the data. For example, a user could create a Technique with `x_mitre_is_subtechnique` set to `true`, but without providing a valid parent technique reference, which would violate the intended semantics of the field. - -Moreover, there exists a conflict between two functionalities: - -1. The REST API blocks users from directly setting the ATT&CK ID (`workspace.attack_id`, `external_references[0].external_id`) on Technique objects, as these fields are meant to be generated by the server. -2. The REST API allows users to set the `x_mitre_is_subtechnique` field on Techniqe objects. - -What can happen is the folowing: - -1. A user creates a Technique object and sets `x_mitre_is_subtechnique` to `true`. The REST API accepts this and sets/generates the ATT&CK ID fields as usual. -2. The user then realizes that the Technique is actually a sub-technique and tries to update `x_mitre_is_subtechnique` to `false` using the `create` or `update` endpoint. The REST API accepts this change and updates the object, but it does not update the ATT&CK ID fields, which remain as if the object were a regular Technique. - -Essentially, once a Technique is created, its ATT&CK ID fields are sticky and do not change based on updates to the `x_mitre_is_subtechnique` field, which can lead to inconsistencies in the data and confusion for users. - -Viable solutions to this problem include: - -1. Adjust the `create` and `update` endpoints for Technique objects to include special handling for the `x_mitre_is_subtechnique` field. If the initial object state is being created or updated with `x_mitre_is_subtechnique` set to `true`, the API should generate the ATT&CK ID fields as if it were a regular Technique. If the object is being updated and the `x_mitre_is_subtechnique` field is changing from `true` to `false`, the API should either prevent this change or update the ATT&CK ID fields accordingly to reflect that it is no longer a sub-technique. - -2. Implement a new, dedicated endpoint for converting a Technique to a sub-technique or vice versa. This endpoint would handle all the necessary logic for updating the `x_mitre_is_subtechnique` field and ensuring that the ATT&CK ID fields are consistent with the new state of the object. This approach would provide a clear and explicit way for users to perform this conversion, while also allowing the API to maintain data integrity. - -Please reason about these options and determine the best course of action for addressing the issue with the `x_mitre_is_subtechnique` field and the ATT&CK ID fields on Technique objects. Consider factors such as user experience, data integrity, and maintainability when making your decision. \ No newline at end of file diff --git a/.nocommit/prompts/technique-sub-parent-conversion-workflow.md b/.nocommit/prompts/technique-sub-parent-conversion-workflow.md deleted file mode 100644 index e7d0cd58..00000000 --- a/.nocommit/prompts/technique-sub-parent-conversion-workflow.md +++ /dev/null @@ -1,19 +0,0 @@ -Let's plan a workflow to handle converting techniques to and from sub-technique status. - -Before we get started, let's level-set on some terminology: -- I refer to "standard" techniques as "parents", irrespective of whether they actually contain a sub-technique or not. These are any technique whose `x_mitre_is_subtechnique` field is equal to `False` -- I refer to sub-techniques as "subs" or "children". These are any technique whose `x_mitre_is_subtechnique` field is equal to `True` - -Currently, the backend/REST API treats toggling this field as just like any other STIX modification: it will happily accept a POST or PUT request where the value of `x_mitre_is_subtechnique` flips in either direction (False<-->True). There are no built-in guards to protect against the user from putting the data into an invalid state (described below). We rely solely on the frontend SPA to handle orchestrating and blocking conversion requests which would put the data into an invalid state. - -So what constitutes an invalid state? Consider the following: - -If a parent (let's call it $parent_A$) contains one or more subs, then the subs must be rehomed or converted to parents themselves before $parent_A$ can be converted to a sub. Otherwise the end state will contain subs that don't have a parent. We informally refer to these as "orphans" and they are not permissible in final STIX bundle outputs. - -Here are some potential solutions that I am considering. Notably, none of them are mutually exclusive: - -1. We can introduce backend guardrails to block requests that would result in orphans being created. -2. We can introduce a new query parameter that allows users to override the aforementioned guardrail, e.g., `permitOrphans: bool`. -3. We can introduce a backend-driven "parent-to-sub conversion" workflow that allows users to bulk-edit all of $parent_A$'s subs as part of the conversion operation, thereby giving the REST API all of the information needed to reach a valid end state without creating orphans. In such a workflow, the user could specify, for each sub, whether to re-home the sub to a new parent or convert it to a parent itself. -4. Optionally, we can build in more tolerances to support "invalid states". Theoretically, it should be fine as long as the "orphans" are tagged/marked accordingly and easy to query. For example, we could introduce a new metadata field like `workspace.is_orphaned: bool` and add a new query parameter to allow users to retrieve orphans, e.g., `GET /api/techniques?include={parent,sub,orphan,all}`, `GET /api/techniques?includeOrphans=true`, etc. -5. In addition, we need to consider the "sub-to-parent conversion" workflow. I believe the only dimension to consider here is the sub's ATT&CK ID. It will need to be assigned a new ATT&CK ID without the sub-technique suffix. Thus, we will need to add support for regenerating a new ATT&CK ID specifically for subs that are being converted to parents. \ No newline at end of file diff --git a/.nocommit/prompts/validation-bypass-rules.md b/.nocommit/prompts/validation-bypass-rules.md deleted file mode 100644 index e08b2aaf..00000000 --- a/.nocommit/prompts/validation-bypass-rules.md +++ /dev/null @@ -1,424 +0,0 @@ -Feedback - -I don't mind amending the validation bypass rule model to accommdate distinguishing between different types of trigger events. i.e., I don't mind the spirit of adding `autoCreatedReason`. However, the approach seems under developed. If we stick with this approach, we should constrain the possible values of `autoCreatedReason` to a specific set of strings that are defined in a central location, and we should also add a `triggerEvent` field that specifies the event that triggered the bypass rule to be created. This way, we can easily query bypass rules based on the trigger event and reason, and we can also ensure consistency in the values of `autoCreatedReason`. - -Creating an entirely new service to handle the propagation of identity changes seems like overkill. Since the propogation service will rely on the `AttackObject` model/repository to make updates to objects, it seems like we could just add methods to the `AttackObjectService` to handle the propogation logic. We could then call these methods from the `IdentitiesService` when an identity is created, updated, or deleted. The `AttackObjectsService` would just listen for the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event and then execute the necessary updates to attack objects based on the identity changes. - -Rather than add `organization_identity_history` to the one system configuration entity/document, I think it would be better to create new system configuration document for each organization identity change. This way, we can maintain a clear history of all system configuration changes over time, and we can also easily query this history if needed. Each time the system configuration is changed, we would create a new system configuration document with the new updated values and a timestamp (which we can track via a new `created_at` field). This approach aligns better with the way we version control other types of entities in the system. - -Currently, system configuration documents look like the following: - -```json -{ - "_id": { - "$oid": "6969691cd1aed3fbdf8f8007" - }, - "organization_identity_ref": "identity--6c14b02a-d7d6-49e3-a1f6-1a8b9f0ff24f", - "default_marking_definitions": [], - "organization_namespace": { - "range_start": 0, - "prefix": "SEAN" - }, - "__v": 0, - "anonymous_user_account_id": "identity--69d554cd-8ce0-41d0-8aef-502a821525f1" -} -``` - -Thus, we'll need to edit the workflows for creating/updating organization identities (which maps to the `organization_identity_ref` field in the system configuration), as well as the workflows for editing the namespace prefix (which maps to the `organization_namespace.prefix` field in the system configuration). Each time either of these fields is updated, we would create a new system configuration document with the updated values and a timestamp. This way, we can maintain a clear history of all changes to the organization identity and namespace prefix over time. - ---- - -This draft looks good overall. I just want to clarify an important point about the propagation of identity changes: - -1. An object that has been imported into Workbench but not edited yet: In this case, we expect `created_by_ref` and `x_mitre_modified_by_ref` to both point to an external identity that is not part of the provenance chain. In this case, we would NOT update either field, because the object was created externally and has not been modified by any internal identities. -2. An object that has been created within Workbench and has been edited by internal identities: In this case, we expect `created_by_ref` to point to an external identity that is not part of the provenance chain, and we expect `x_mitre_modified_by_ref` to point to one or more internal identities that are part of the provenance chain. In this case, we would update `x_mitre_modified_by_ref` values to point to the organization identity, but we would NOT update `created_by_ref`, because the object was created externally. -3. An object that was created within Workbench: In this case, we expect both `created_by_ref` and `x_mitre_modified_by_ref` to point to one or more internal identities that are part of the provenance chain. In this case, we would update both `created_by_ref` and `x_mitre_modified_by_ref` values to point to the organization identity, because the object was created and modified by internal identities. - ---- - -Observed issues from looking through the code changes: - -1. For the change to the `systemConfigurationSchema`, we need to add a startup script that will backfill existing system configuration documents with the new `created_at` field. This script will need to query all existing system configuration documents and add a `created_at` field with the current timestamp to each document. This way, we can ensure that all system configuration documents have a `created_at` field, which will allow us to maintain a clear history of changes over time. Luckily, we already have a workflow for running startup scripts: see `www/bin` calling `migrateDatabase` for details. - -2. In the `system-configuration-controller.js` module, we should edit all endpoints to pass `next` so we can stop handling each service layer exception manually and instead return `next(err)` and let the global error handler take care of it. See any of the other controllers for examples of this pattern. - -3. In `attack-objects-service.js`, you imported the `AttackObject` Mongoose model. This violates the separation of concerns between the service layer and the repository layer. The service layer should not be directly importing Mongoose models, as this creates tight coupling between the layers and makes it harder to maintain and test the code. Instead, the service layer should only interact with the repository layer, which is responsible for managing the Mongoose models and database interactions. - -The integration test results from `npm test` are in `npm_test_output.txt`. The 777 core tests are passing, but there are 9 failing tests in `adm-validation-middleware.spec.js`: -``` - 8 passing (1s) - 9 failing - - 1) ADM Validation Middleware - POST operations - work-in-progress (partial validation) - should accept valid complete data in work-in-progress state: - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 201 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:184:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 2) ADM Validation Middleware - POST operations - work-in-progress (partial validation) - should accept partial data in work-in-progress state (missing optional fields): - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 201 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:217:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 3) ADM Validation Middleware - POST operations - reviewed (full validation) - should accept valid complete data in reviewed state: - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 201 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:273:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 4) ADM Validation Middleware - PUT operations - work-in-progress (partial validation) - "before each" hook for "should accept valid updates in work-in-progress state": - Error: expected 201 "Created", got 400 "Bad Request" - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:388:10) - at process.processImmediate (node:internal/timers:491:21) - ---- - at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) - at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 - at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) - at Test.assert (node_modules/supertest/lib/test.js:195:23) - at localAssert (node_modules/supertest/lib/test.js:138:14) - at Server. (node_modules/supertest/lib/test.js:152:11) - at Object.onceWrapper (node:events:632:28) - at Server.emit (node:events:518:28) - at emitCloseNT (node:net:2416:8) - at process.processTicksAndRejections (node:internal/process/task_queues:89:21) - - 5) ADM Validation Middleware - PUT operations - reviewed (full validation) - "before each" hook for "should accept valid complete updates in reviewed state": - Error: expected 201 "Created", got 400 "Bad Request" - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:516:10) - at process.processImmediate (node:internal/timers:491:21) - ---- - at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) - at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 - at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) - at Test.assert (node_modules/supertest/lib/test.js:195:23) - at localAssert (node_modules/supertest/lib/test.js:138:14) - at Server. (node_modules/supertest/lib/test.js:152:11) - at Object.onceWrapper (node:events:632:28) - at Server.emit (node:events:518:28) - at emitCloseNT (node:net:2416:8) - at process.processTicksAndRejections (node:internal/process/task_queues:89:21) - - 6) ADM Validation Middleware - Server-controlled field stripping - should silently strip x_mitre_attack_spec_version from client input: - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 201 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:673:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 7) ADM Validation Middleware - Server-controlled field stripping - should silently strip ATT&CK external references from client input: - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 201 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:705:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 8) ADM Validation Middleware - dryRun support - should return composed object without persisting on POST with dryRun=true: - Error: expect(received).toBe(expected) // Object.is equality - -Expected: 200 -Received: 400 - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:739:26) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - - 9) ADM Validation Middleware - dryRun support - should return composed object without persisting on PUT with dryRun=true: - Error: expected 201 "Created", got 400 "Bad Request" - at Context. (app/tests/middleware/adm-validation-middleware.spec.js:798:10) - at process.processImmediate (node:internal/timers:491:21) - ---- - at Test._assertStatus (node_modules/supertest/lib/test.js:309:14) - at /Users/ssica/Development/attack/workbench/attack-workbench-rest-api/node_modules/supertest/lib/test.js:365:13 - at Test._assertFunction (node_modules/supertest/lib/test.js:342:13) - at Test.assert (node_modules/supertest/lib/test.js:195:23) - at localAssert (node_modules/supertest/lib/test.js:138:14) - at Server. (node_modules/supertest/lib/test.js:152:11) - at Object.onceWrapper (node:events:632:28) - at Server.emit (node:events:518:28) - at emitCloseNT (node:net:2416:8) - at process.processTicksAndRejections (node:internal/process/task_queues:89:21) -``` - ---- - -The tests are failing because the validation bypass rules that are needed to allow the test objects to be created/updated without `x_mitre_modified_by_ref` values are not being created. Here's an example of one of the validation errors that occurs in one of the tests: -``` -{ - success: false, - error: { - name: "ZodError", - message: `[ - { - "code": "invalid_value", - "values": [ - "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5" - ], - "path": [ - "x_mitre_modified_by_ref" - ], - "message": "Invalid input: expected \\"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\\"" - } -]`, - }, -} -``` - -This is occurring because the necessary bypass rules are not being created at startup when the placeholder organization identity is created. - -The app starts via `bin/www`. In this startup script, we run `await databaseConfiguration.checkSystemConfiguration()`, which in turn calls `checkForOrganizationIdentity`; that then calls `createPlaceholderOrganizationIdentity()`. This is where we should add logic to create the necessary validation bypass rules for `x_mitre_modified_by_ref`. All we need to do is emit the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event from `createPlaceholderOrganizationIdentity()`; that will trigger the `ValidationBypassesService` to create the necessary bypass rules for `x_mitre_modified_by_ref` validation. - -This should ensure that the necessary bypass rules are always in place whenever the app is started, which will allow the tests to pass successfully. - ---- - -I don't think this fix resolves the issue and here's why: When the application starts up, it runs `await databaseConfiguration.checkSystemConfiguration()`. -This is turn calls `createPlaceholderOrganizationIdentity()`, which calls `systemConfigurationService.setOrganizationIdentity()`. -`SystemConfigurationService.setOrganizationIdentity()` only emits the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event if there is a change in the organization identity. However, when the app starts up for the first time and creates the placeholder organization identity, there is no "change" per se, because there was no existing organization identity before. Thus, the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event is not emitted during startup, and the validation bypass rules are not created. I verified this in the debugger by putting a breakpoint in the `setOrganizationIdentity()` method and observed that `currentConfig` is `null` when the placeholder identity is created, so the condition to emit the event is not met. - ---- - -The issue is persisting because there are no listeners for the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event at the time when the placeholder organization identity is created during startup. The listeners for this event are registered in the `ValidationBypassesService`, which is initialized in the `services/index.js` module. However, the `checkSystemConfiguration()` function (which calls `createPlaceholderOrganizationIdentity()`) is executed before the services are initialized in the startup sequence. - -```javascript -// in bin/www - -// Check for valid database configuration -const databaseConfiguration = require('../app/lib/database-configuration'); -await databaseConfiguration.checkSystemConfiguration(); - -// Create the app -const app = await require('../app').initializeApp(); // <-- services, including event listeners, are initialized in this step -``` - -Thus, when the placeholder organization identity is created and the `SYSTEM_CONFIGURATION_IDENTITY_CHANGED` event is emitted, there are no listeners registered to handle that event, so the validation bypass rules are not created. - - ---- - -All tests passing. -Summary of changes: - -feat: version system config, validate org identity, and propagate identity changes to objects - -- Refactor system-configuration-controller to use \`next\` for error handling; defer to global error handler instead of handling service layer exceptions manually in the controller. -- Add new \`app/lib/bypass-rule-constants.js\` module to define constants for validation bypass rule reasons and trigger events. -- Update \`app/lib/database-configuration.js\` to load the \`ValidationBypassesService\` before calling \`checkSystemConfiguration()\`. This is critical to ensure that the event listeners for \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` are registered before we attempt to emit that event during the creation of the placeholder organization identity. This way, when the placeholder identity is created and the event is emitted, the listeners will be in place to create the necessary validation bypass rules for \`x_mitre_modified_by_ref\`, which will allow the tests to pass successfully. -- Add new event type, \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\`, to \`app/lib/event-constants.js\`. -- Add new field, \`created_at\`, to \`systemConfigurationSchema\` in \`app/models/system-configuration.js\` to track when each system configuration document is created. This will allow us to maintain a clear history of changes to the system configuration over time. -- Add new field, \`autoCreatedReason\`, to the validation bypass rule model in \`app/models/validation-bypass-rule.js\` to track the reason why a validation bypass rule was automatically created. This will allow us to easily identify and manage validation bypass rules that were created due to identity changes. -- Add new repository method, \`AttackObjectsRepository.retrieveAllLatestByOrgIdentityRefs()\`, to retrieve all latest attack objects that have \`created_by_ref\` or \`x_mitre_modified_by_ref\` values matching any of the provided organization identity refs. This will allow us to easily find all attack objects that are associated with any of the organization identities in the history of organization identity changes. -- Add new repository method, \`SystemConfigurationRepository.retrieveAllDistinctIdentityRefs()\`, to retrieve a list of all distinct organization identity refs that are currently referenced in any system configuration documents. This will allow us to easily track all organization identities that have been used in the system configuration over time. -- Modify \`SystemConfigurationRepository.retrieveOne\` to retrieve the latest system configuration document based on the \`created_at\` timestamp, rather than just retrieving a single document without any sorting. This will ensure that we always get the most recent system configuration, which is important now that we are creating a new system configuration document each time there is a change to the organization identity or namespace prefix. -- Add new repository method, \`ValidationBypassesRepository.deleteByReason\`, to delete validation bypass rules based on the reason they were created. This will allow us to easily clean up old validation bypass rules that were created due to previous identity changes when a new identity change occurs. -- Add new event handler to \`AttackObjectsService\` to handle the \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` event. This handler will retrieve all attack objects that are associated with any of the organization identities in the history of identity changes, and it will update their \`created_by_ref\` and \`x_mitre_modified_by_ref\` values to point to the new organization identity as appropriate based on the rules outlined in the implementation plan. This will ensure that all attack objects are properly updated to reflect the new organization identity whenever an identity change occurs. -- Refactor \`system-configuration-service.js\` to create a new system configuration document each time there is a change to the organization identity or namespace prefix, rather than updating an existing document. This will allow us to maintain a clear history of all changes to the system configuration over time, and it will also allow us to easily query this history if needed. Each time there is a change, we will create a new system configuration document with the updated values and a \`created_at\` timestamp. This way, we can track the evolution of the system configuration over time and have a clear record of when changes occurred. -- Add event hanlder to \`validation-bypasses-service.js\` to handle the \`SYSTEM_CONFIGURATION_IDENTITY_CHANGED\` event. This handler will create new validation bypass rules for \`x_mitre_modified_by_ref\` based on the new organization identity, and it will also clean up old validation bypass rules that were created due to previous identity changes. This will ensure that the necessary validation bypass rules are always in place whenever an identity change occurs, which will allow the tests to pass successfully and will also ensure that the system continues to function properly after identity changes. -- Add startup migration script to backfill existing system configuration documents with the new \`created_at\` field. This script will query all existing system configuration documents and add a \`created_at\` field with the current timestamp to each document. This way, we can ensure that all system configuration documents have a \`created_at\` field, which will allow us to maintain a clear history of changes over time. This script can be run as part of the existing database migration workflow that is executed at startup. - -Please provide a conventional commit message for this PR that summarizes the list above. I plan to include the list above in the PR description, so the commit message can be a concise summary of the changes. - - ---- - -Findings: - -Tactics: -Frontend: -```txt -x_mitre_shortname: x_mitre_shortname is Invalid option: expected one of "credential-access"|"execution"|"impact"|"persistence"|"privilege-escalation"|"lateral-movement"|"defense-evasion"|"exfiltration"|"discovery"|"collection"|"resource-development"|"reconnaissance"|"command-and-control"|"initial-access"|"inhibit-response-function"|"evasion"|"impair-process-control"|"network-effects"|"remote-service-effects" -``` -dryRun res.body: -```json -{ - "message": "ADM validation failed", - "details": [ - { - "message": "x_mitre_shortname is Invalid option: expected one of \"credential-access\"|\"execution\"|\"impact\"|\"persistence\"|\"privilege-escalation\"|\"lateral-movement\"|\"defense-evasion\"|\"exfiltration\"|\"discovery\"|\"collection\"|\"resource-development\"|\"reconnaissance\"|\"command-and-control\"|\"initial-access\"|\"inhibit-response-function\"|\"evasion\"|\"impair-process-control\"|\"network-effects\"|\"remote-service-effects\"", - "path": [ - "x_mitre_shortname" - ], - "code": "invalid_value" - } - ], - "warnings": [] -} -``` - -Possible solutions: -1. By default, run a migration script that preloads the database with the necessary validation bypass rules for `x_mitre_tactic_shortname` validation. -2. Add an option to load validation bypass rules from a JSON file at startup, and provide a default JSON file that includes the necessary bypass rules for `x_mitre_tactic_shortname` validation. -3. Nothing -- explicitly require teams add (POST) the necessary validation bypass rules for `x_mitre_tactic_shortname` validation as part of their setup process. We can provide documentation and examples to guide them through this process. - -When namespace prefix changes, we need to propagate to all objects with that prefix that originated from the current Workbench deployment. - - ---- - -Notably, tactic creation will break if you set a non-compliant name because I reversed the rule that converts the ZodError to a warning — so you'll see something like this in the dryRun response: - -{ - "message": "ADM validation failed", - "details": [ - { - "message": "x_mitre_shortname is Invalid option: expected one of \"credential-access\"|\"execution\"|\"impact\"|\"persistence\"|\"privilege-escalation\"|\"lateral-movement\"|\"defense-evasion\"|\"exfiltration\"|\"discovery\"|\"collection\"|\"resource-development\"|\"reconnaissance\"|\"command-and-control\"|\"initial-access\"|\"inhibit-response-function\"|\"evasion\"|\"impair-process-control\"|\"network-effects\"|\"remote-service-effects\"", - "path": [ - "x_mitre_shortname" - ], - "code": "invalid_value" - } - ], - "warnings": [] -} - -Possible solutions: -1. By default, run a migration script that preloads the database with the necessary validation bypass rules for x_mitre_tactic_shortname validation. -2. Add an option to load validation bypass rules from a JSON file at startup, and provide a default JSON file that includes the necessary bypass rules for x_mitre_tactic_shortname validation. -3. Do nothing — explicitly require teams add (POST) the necessary validation bypass rules for x_mitre_tactic_shortname validation as part of their setup process. We can provide documentation and examples to guide them through this process. -4. Tangential to this — we can/should build out a frontend CRUD interface for managing bypass rules - -There are additional rules that were inadvertently left out of the original list of bypass rules that we will need to address: -```javascript -const ERROR_TRANSFORMATION_RULES = [ - // Server always sets x_mitre_attack_spec_version - { - fieldPath: ['x_mitre_attack_spec_version'], - errorCode: 'invalid_type', - stixType: 'all', - suppressError: true, - }, - // Server sets x_mitre_modified_by_ref based on authenticated user - user does not need to supply it - { - fieldPath: ['x_mitre_modified_by_ref'], - errorCode: 'invalid_value', - stixType: 'all', - suppressError: true, - }, - // Warn about non-standard tactic shortnames - { - fieldPath: ['x_mitre_shortname'], - errorCode: 'invalid_value', - stixType: 'x-mitre-tactic', - warningMessage: - 'Tactic shortname does not match predefined ATT&CK tactics. This may prevent compatibility with official ATT&CK data but can be used for custom taxonomies.', - }, - // Server sets x_mitre_domains for certain types (assigned during bundle export) - { - fieldPath: ['x_mitre_domains'], - errorCode: 'invalid_type', - stixType: ['intrusion-set', 'campaign', 'x-mitre-matrix', 'x-mitre-detection-strategy'], - suppressError: true, - }, - // Server sets object_marking_refs for certain types - { - fieldPath: ['object_marking_refs'], - errorCode: 'invalid_type', - stixType: ['campaign', 'identity'], - suppressError: true, - }, - // Server sets created_by_ref for certain types - { - // catch Zod error pertaining to field: - fieldPath: ['created_by_ref'], - // catch Zod error type: - errorCode: 'invalid_type', - // applicable to: - stixType: ['campaign', 'x-mitre-matrix', 'x-mitre-asset', 'course-of-action'], - suppressError: true, - }, -]; -``` - -Here's an example of how to interpret the above rules: -```json -{ - // catch Zod error pertaining to field: - "fieldPath": ['created_by_ref'], - // catch Zod error type: - "errorCode": 'invalid_type', - // applicable to: - "stixType": ['campaign', 'x-mitre-matrix', 'x-mitre-asset', 'course-of-action'], - "suppressError": true, -}, -``` - -Please build out the necessary functionality to load bypass rules via JSON file at startup. Create a default JSON file that addresses the above rules. Add an environment variable that allows teams to specify a custom JSON file if they want to override the default path. This way, we can ensure that the necessary bypass rules are always in place for the above scenarios, which will allow the tests to pass successfully and will also ensure that the system continues to function properly in these scenarios. - ---- - -When I create (via `POST /api/campaigns`) a new campaign in the `awaiting-review` status, it unexpectedly fails validation with the following errors: -```json -{"workspace":{"workflow":{"state":"awaiting-review"}},"stix":{"type":"campaign","id":"campaign--920c66ad-eff2-40f2-904e-6700b53a23eb","created":"2026-04-07T17:40:11.145Z","modified":"2026-04-07T17:40:11.145Z","x_mitre_version":"0.1","x_mitre_deprecated":false,"revoked":false,"spec_version":"2.1","name":"campane","first_seen":"2003-04-01T05:00:00.000Z","last_seen":"2026-04-01T04:00:00.000Z","x_mitre_first_seen_citation":"(Citation: Sean)","x_mitre_last_seen_citation":"(Citation: Sica)","aliases":["placeholder"]}} -``` -Response: -```json -{ - "message": "ADM validation failed", - "details": [ - { - "message": "revoked is Invalid input: expected nonoptional, received undefined", - "path": [ - "revoked" - ], - "code": "invalid_type" - }, - { - "message": "description is Invalid input: expected string, received undefined", - "path": [ - "description" - ], - "code": "invalid_type" - } - ], - "warnings": [] -} -``` -The error related to `description` is expected. No issues there. -But the error related to `related` is NOT expected. The request is clearly setting `revoked` to `false`, so why does the ADM validation think it's `undefined`? - ---- - -I changed a bunch of Mongoose model properties from `[String]` to `{ type: [String], default: undefined }` to protect from writing empty arrays to entities (Mongo documents) because that would cause response bodies to contain empty list properties, which would be a violation of the STIX 2.1 specification, which states that lists cannot be empty. - -Example: -``` -// before -x_mitre_aliases: [String], -// after -x_mitre_aliases: { type: [String], default: undefined } -``` - -Please provide a database migration script in the `migrations/` folder that target the models which are staged in git to bring the database into harmony with the updated models. - -app/models/campaign-model.js -app/models/data-source-model.js -app/models/detection-strategy-model.js -app/models/group-model.js -app/models/identity-model.js -app/models/matrix-model.js -app/models/software-model.js -app/models/tactic-model.js \ No newline at end of file diff --git a/.nocommit/prompts/validation-bypasses-impl-plan.md b/.nocommit/prompts/validation-bypasses-impl-plan.md deleted file mode 100644 index 15f8bc0c..00000000 --- a/.nocommit/prompts/validation-bypasses-impl-plan.md +++ /dev/null @@ -1,165 +0,0 @@ -# Plan: Database-Backed Validation Bypass Rules - -## Context - -When an organization namespace is configured (e.g., prefix "FOOBAR", range 1000), the backend generates ATT&CK IDs like "FOOBAR-T1000". The ADM Zod schemas strictly enforce the standard format (e.g., "T####") and cannot be modified. This causes validation failures: - -``` -external_references.0.external_id is The first external_reference must match the ATT&CK ID format T#### or T####.### -``` - -**Goal**: Create a system that (1) provides CRUD for stateful validation bypass rules, (2) auto-manages namespace-related bypass rules, and (3) exposes a function `isErrorBypassed(error, stixType) → boolean` for callers to use. - -As part of this effort, we also remove the legacy `validate-service.js`, `validate-controller.js`, `validate-routes.js`, `validate-paths.yml`, and `ERROR_TRANSFORMATION_RULES` from `validation-schemas.js`. - ---- - -## New Files to Create - -### 1. Mongoose Model — `app/models/validation-bypass-rule-model.js` -Pattern: `app/models/reference-model.js` (standalone `mongoose.model()`) - -Fields: -- `fieldPath`: `[String]`, required — e.g., `["external_references", "0", "external_id"]` -- `errorCode`: `String`, required — e.g., `"custom"` -- `stixType`: `String`, required — e.g., `"attack-pattern"` or `"all"` -- `suppressError`: `Boolean`, default `true` -- `autoCreated`: `Boolean`, default `false` — marks entries auto-generated by namespace setup - -Compound unique index on `{ fieldPath, errorCode, stixType }`. - -### 2. Repository — `app/repository/validation-bypasses-repository.js` -Pattern: `app/repository/references-repository.js` - -Methods: -- `retrieveAll(options)` — aggregation with pagination, returns `[{ totalCount, documents }]` -- `save(data)` — create, handle duplicate key (11000) -- `retrieveById(id)` — `findById` -- `deleteById(id)` — `findByIdAndDelete` -- `deleteAutoCreated()` — `deleteMany({ autoCreated: true })` -- `findAll()` — lightweight `find({}).lean()` for the bypass-check path - -### 3. Service — `app/services/system/validation-bypasses-service.js` -Pattern: `app/services/system/references-service.js` - -CRUD methods: -- `retrieveAll(options)` — paginate via `BaseService.paginate` -- `create(data)` — `repository.save(data)` -- `retrieveById(id)` — `repository.retrieveById(id)` -- `deleteById(id)` — `repository.deleteById(id)` - -Bypass logic: -- `isErrorBypassed(error, stixType)` — loads all rules from DB, checks if the error matches any active rule by comparing `fieldPath` (with `.map(String)` coercion for numeric indices), `errorCode`, and `stixType` (supports `"all"` or specific type). Returns `true`/`false`. - -Namespace auto-management: -- `createNamespaceRules(stixTypes)` — bulk-create entries with `autoCreated: true` for each ATT&CK-ID-bearing STIX type, targeting `fieldPath: ["external_references", "0", "external_id"]`, `errorCode: "custom"` -- `removeNamespaceRules()` — `repository.deleteAutoCreated()` - -### 4. Controller — `app/controllers/validation-bypasses-controller.js` -Pattern: `app/controllers/references-controller.js` - -- `retrieveAll(req, res)` → 200 -- `create(req, res)` → 201, 409 on duplicate -- `retrieveById(req, res)` → 200 or 404 -- `deleteById(req, res)` → 204 or 404 - -### 5. Routes — `app/routes/validation-bypasses-routes.js` -Auto-discovered by `app/routes/index.js`: - -``` -GET /api/config/validation-bypasses (visitor+) -POST /api/config/validation-bypasses (admin) -GET /api/config/validation-bypasses/:id (visitor+) -DELETE /api/config/validation-bypasses/:id (admin) -``` - ---- - -## Existing Files to Modify - -### 6. `app/services/meta-classes/base.service.js` - -**6a.** Remove import of `processValidationIssues` from `validate-service.js` (line 25). Keep `getSchema` import but change it to import from `../../lib/validation-schemas` instead (since `validate-service.js` is being deleted). - -**6b.** Rewrite `validateComposedObject(data)` (line 409) to be `async`. Instead of calling `processValidationIssues`, directly process Zod issues into the error format, then filter using `isErrorBypassed`: - -```javascript -async validateComposedObject(data) { - const empty = { errors: [], warnings: [] }; - if (!config.validateRequests.withAttackDataModel) return empty; - - const stixType = data.stix?.type; - const status = data.workspace?.workflow?.state || 'reviewed'; - - const schema = getSchema(stixType, status); - if (!schema) return empty; - - const result = schema.safeParse(data.stix); - if (result.success) return empty; - - // Convert Zod issues to error objects - const allErrors = result.error.issues.map(issue => ({ - message: `${issue.path.join('.')} is ${issue.message}`, - path: issue.path, - code: issue.code, - input: issue.input, - })); - - // Filter out bypassed errors - const validationBypassesService = require('../system/validation-bypasses-service'); - const errors = []; - for (const error of allErrors) { - const bypassed = await validationBypassesService.isErrorBypassed(error, stixType); - if (!bypassed) { - errors.push(error); - } - } - - return { errors, warnings: [] }; -} -``` - -**6c.** Update 3 call sites (lines 587, 628, 727) to `await this.validateComposedObject(data)`. - -### 7. `app/lib/validation-schemas.js` - -**7a.** Remove `ERROR_TRANSFORMATION_RULES` (lines 124-168) and its export (line 172). - -**7b.** Move `getSchema` function here from `validate-service.js` and export it. (It already imports `STIX_SCHEMAS` which is defined here, so this is a natural home.) - -### 8. `app/services/system/system-configuration-service.js` - -In `setOrganizationNamespace(namespace)` (line 240): -1. Call `validationBypassesService.removeNamespaceRules()` to clear previous auto-created entries -2. After saving, if `namespace?.prefix` is truthy, call `validationBypassesService.createNamespaceRules(stixTypes)` where `stixTypes = Object.keys(stixTypeToAttackIdMapping)` - -### 9. Delete Legacy Files -- `app/services/system/validate-service.js` -- `app/controllers/validate-controller.js` -- `app/routes/validate-routes.js` -- `app/api/definitions/paths/validate-paths.yml` -- Remove the validate path reference from `app/api/definitions/openapi.yml` - ---- - -## Implementation Order - -1. Create model (`validation-bypass-rule-model.js`) -2. Create repository (`validation-bypasses-repository.js`) -3. Create service with `isErrorBypassed()` + namespace rule helpers (`validation-bypasses-service.js`) -4. Create controller + routes -5. Move `getSchema` to `validation-schemas.js`, remove `ERROR_TRANSFORMATION_RULES` -6. Rewrite `validateComposedObject` in `base.service.js` — make async, use `isErrorBypassed` -7. Modify `system-configuration-service.js` — auto-create/remove rules on namespace set -8. Delete legacy files (`validate-service.js`, `validate-controller.js`, `validate-routes.js`, `validate-paths.yml`, openapi ref) - ---- - -## Verification - -1. **CRUD test**: Create/read/delete validation bypass rules via `/api/config/validation-bypasses` -2. **Namespace auto-creation**: Set namespace via `POST /api/config/organization-namespace`, verify bypass rules auto-created via `GET /api/config/validation-bypasses` -3. **Namespace removal**: Clear namespace, verify auto-created bypass rules removed -4. **Integration**: With namespace configured, create a technique — confirm no validation error for the namespaced ATT&CK ID -5. **Negative**: Create a technique with a genuinely invalid field — confirm real validation errors still throw -6. **Regression**: `npm test` — ensure existing tests pass diff --git a/.nocommit/prompts/virtual tracks.md b/.nocommit/prompts/virtual tracks.md deleted file mode 100644 index 27c9af40..00000000 --- a/.nocommit/prompts/virtual tracks.md +++ /dev/null @@ -1,102 +0,0 @@ -Virtual tracks sync from candidate tracks. Candidate tracks are just standard (non-virtual) release tracks. - -Virtual tracks must target a specific tier for each candidate track. Virtual tracks can sync from a candidate tracks `members`, `staged` or `candidates` list, but not a combination. - -Virtual tracks must each define a resolution policy to handle deduplication, which is to say: users must specify how virtual tracks should handle objects that exist in multiple candidate tracks. Here are some possible ideas: - -1. Prioritize newer object: Keep the newest version of the object, as defined by the object's `modified` timestamp. -2. Prioritize newer snapshot: Keep whichever version of the object came from the newest snapshot, as defined by the snapshot's `modified` timestamp. Note that this strategy makes it possible to sync older copies of an object! -3. Prioritize candidate priority: Keep whichever version of the object came from the higher priority candidate track. This would entail making it possible for users to specify which candidate tracks should be prioritized; for example: -``` - composition: { - component_tracks: [ - { - track_id: "release-track--uuid-1", - resolution_strategy: "latest_tagged", - priority: 1, - filters: { - object_types: ["intrusion-set"], - // Additional filters... - } - }, - { - track_id: "release-track--uuid-2", - resolution_strategy: "latest_tagged", - priority: 2, - filters: { - object_types: ["attack-pattern"] - } - } - ], -``` -4. Quarantine: Don't include either permutation. Instead, store both versions of the object as `candidates` in the virtual track. This will give the users the opportunity to review and decide which one to promote. - -The last point brings up another important topic about the promotion process. There are three tiers of membership: - -- `members`: cannot include duplicate objects. Cannot include multiple versions of the same object, as defined by the objects' `(id, modified)` pair. -- `staged`: same thing as members -- `candidates`: Cannot include duplicate objects. CAN include multiple versions of the same object. - -We need to define what happens if the release track tries to promote an conflicting object from `candidates` to `staged`: - - **Always overwrite**: This mode preserves the incoming object. - - **Prefer reject**: This mode rejects the incoming object/preserves the incumbent object. - -An additional setting/mode is supported when objects are being promoted from `staged` to `members`; recall that this particular promotion path can only be done when a snapshot is being tagged/released (i.e., `POST /api/release-tracks/:id/bump`). Importantly, once a snapshot is tagged/released, it becomes immutable. Therefore, we need to provide additional guardrails to ensure that the release process goes smoothly. Thus, in addition to the two aforementioned settings (`always-overwrite` and `always-reject`), an additional "abort" setting is supported for the release/tagging operation: - - **Abort**: If there is a conflict between an incumbent object in `members` and an incoming object, reject/abort the entire release/tagging operation. An immutable snapshot will not be created. Notify the user what caused the failure. - ---- - -On second thought, I think we need to make some modifications: - -DEDUPLICATION POLICY/STRATEGY FOR VIRTUAL TRACK SYNCING: - -Virtual tracks may only sync/pull from each candidate track's `members` list. Given that virtual tracks may only sync from tagged/release snapshots, this makes perfect sense. - -Virtual tracks support two object tiers: `members` and `quarantine`. - - `quarantine` is for objects that couldn't be reconciled during the sync/pull operation (i.e., duplicate objects). - - `members` contains everything that was synced from the release track's candidate tracks. - -During a sync operation, conflicts may occur -- it's possible that a virtual track contains an object with conflicts with an object in one of its candidate tracks. In such situations, virutal track sync dictate how to proceed. They are defined as follows: - - 1. **Prioritize latest object**: Keep the newest version of the object, as defined by the object's `modified` timestamp. - 2. **Prioritize latest snapshot**: Keep whichever version of the object came from the newest snapshot, as defined by the snapshot's `modified` timestamp. Note that this strategy makes it possible to sync older copies of an object! - 3. **Prioritize higher priority**: Keep whichever version of the object came from the candidate track with the higher priority. When a candidate track is added to a virtual track, the user must specify a numerical value specifying its priority. Candidate tracks may not have the same priority level. For example: - ```javascript - composition: { - component_tracks: [ - { - track_id: "release-track--uuid-1", - resolution_strategy: "latest_tagged", - priority: 1, - filters: { - object_types: ["intrusion-set"], - // Additional filters... - } - }, - { - track_id: "release-track--uuid-2", - resolution_strategy: "latest_tagged", - priority: 2, - filters: { - object_types: ["attack-pattern"] - } - } - ], - ``` - 1. Quarantine: Don't include either permutation. Instead, store both versions of the object in the virtual track's `quarantine`. This will give the users the opportunity to review and decide which one to promote. The objects will remain in quarantine until manual user intervention occurs. - -DEDUPLICATION POLICY/STRATEGY FOR PROMOTIONS IN STANDARD TRACKS: - -A promotion constitutes moving an object from `candidates` to `staged` or from `staged` to `members`. Promotions can occur in three ways: - - - **Manually** via REST API endpoint - - Via **auto-promotion** policy (e.g., an object transitions from `work-in-progress` to `awaiting-review` and the release track's `candidacy_threshold` is set to `awaiting-review`, therefore auto-promotion will promote the object to `staged`). - - Via **tagging/release/bump operations** - -The following policies can be used to reconcile promotion conflicts in standard tracks for any of the 3 aforementioned situations: - - - **Always overwrite**: This mode preserves the incoming object. - - **Always reject**: This mode rejects the incoming object/preserves the incumbent object. The rejected object is kept in `candidates`. - - **Prefer latest**: The object with the newer `modified` timestamp is kept. - - **Abort**: If there is a conflict between an incumbent object in `members` and an incoming object, reject/abort the entire release/tagging operation. An immutable snapshot will not be created. Notify the user what caused the failure. - diff --git a/.nocommit/run-field-probe.js b/.nocommit/run-field-probe.js deleted file mode 100644 index e7378cfd..00000000 --- a/.nocommit/run-field-probe.js +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -/** - * Field Requirements Probe Runner - * - * This script safely runs the field requirements tests without requiring - * a running server, by directly testing the validation middleware logic. - */ - -const { validateWorkspaceStixData } = require('./app/lib/validation-middleware'); -const { - techniqueSchema, - tacticSchema, - campaignSchema, -} = require('@mitre-attack/attack-data-model'); -const { v4: uuidv4 } = require('uuid'); - -/** - * Mock Express request/response for testing middleware - */ -function createMockReqRes(body) { - const req = { - body, - path: '/test', - method: 'POST' - }; - - const res = { - statusCode: 200, - responseBody: null, - status: function(code) { - this.statusCode = code; - return this; - }, - json: function(body) { - this.responseBody = body; - return this; - } - }; - - const next = function() { - // Middleware passed validation - }; - - return { req, res, next }; -} - -/** - * Test middleware validation for a given object - */ -function testValidation(schema, testObject) { - const middleware = validateWorkspaceStixData(schema); - const { req, res, next } = createMockReqRes(testObject); - - return new Promise((resolve) => { - // Override next to capture success - const nextOverride = () => { - resolve({ success: true, status: 200 }); - }; - - // Override response methods to capture errors - res.status = function(code) { - this.statusCode = code; - return this; - }; - res.json = function(body) { - this.responseBody = body; - resolve({ - success: false, - status: this.statusCode, - error: body - }); - return this; - }; - - try { - middleware(req, res, nextOverride); - } catch (error) { - resolve({ - success: false, - status: 500, - error: { message: error.message } - }); - } - }); -} - -/** - * Create base test objects - */ -function createTestObjects() { - const now = new Date().toISOString(); - - const baseStixFields = { - spec_version: '2.1', - created: now, - modified: now, - name: 'Test Object', - description: 'Test description', - x_mitre_attack_spec_version: '3.3.0', - x_mitre_version: '1.0', - x_mitre_domains: ['enterprise-attack'], - x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', - object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], - external_references: [{ - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/techniques/T9999' - }] - }; - - return { - technique: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'T9999' - }, - stix: { - ...baseStixFields, - type: 'attack-pattern', - id: 'attack-pattern--' + uuidv4(), - x_mitre_is_subtechnique: false - } - }, - - tactic: { - workspace: { - workflow: { state: 'awaiting-review' }, - attackId: 'TA9999' - }, - stix: { - ...baseStixFields, - type: 'x-mitre-tactic', - id: 'x-mitre-tactic--' + uuidv4(), - x_mitre_shortname: 'test-tactic', - external_references: [{ - source_name: 'mitre-attack', - external_id: 'TA9999', - url: 'https://attack.mitre.org/tactics/TA9999' - }] - } - }, - - campaign: { - workspace: { - workflow: { state: 'awaiting-review' } - }, - stix: { - ...baseStixFields, - type: 'campaign', - id: 'campaign--' + uuidv4(), - aliases: ['Test Campaign'], - first_seen: now, - last_seen: now, - x_mitre_first_seen_citation: '(Citation: Test)', - x_mitre_last_seen_citation: '(Citation: Test)', - revoked: false, - external_references: [{ - source_name: 'Test', - description: 'Test reference' - }] - } - } - }; -} - -/** - * Test specific field requirements - */ -async function probeFieldRequirements() { - console.log('šŸ” Testing Field Requirements for "awaiting-review" Objects'); - console.log('='*70); - - const schemas = { - technique: techniqueSchema, - tactic: tacticSchema, - campaign: campaignSchema - }; - - const testObjects = createTestObjects(); - - // Fields that are typically set by the backend - const BACKEND_FIELDS = [ - 'x_mitre_attack_spec_version', - 'x_mitre_domains', - 'x_mitre_modified_by_ref', - 'created_by_ref', - 'object_marking_refs' - ]; - - // Core STIX fields that should always be required - const CORE_FIELDS = [ - 'type', - 'spec_version', - 'created', - 'modified' - ]; - - const results = {}; - - for (const [objectType, schema] of Object.entries(schemas)) { - console.log(`\nšŸ” Testing ${objectType}...`); - results[objectType] = { - coreFields: {}, - backendFields: {}, - baselineValid: false - }; - - const baseObject = testObjects[objectType]; - - // Test 1: Baseline - full object should be valid - const baselineResult = await testValidation(schema, baseObject); - results[objectType].baselineValid = baselineResult.success; - console.log(` Baseline (full object): ${baselineResult.success ? 'āœ…' : 'āŒ'}`); - if (!baselineResult.success) { - console.log(` Error: ${JSON.stringify(baselineResult.error, null, 2)}`); - } - - // Test 2: Core STIX fields - console.log('\n Core STIX fields:'); - for (const field of CORE_FIELDS) { - const testObj = JSON.parse(JSON.stringify(baseObject)); - delete testObj.stix[field]; - - const result = await testValidation(schema, testObj); - results[objectType].coreFields[field] = { - required: !result.success, - error: result.error - }; - - console.log(` ${field}: ${result.success ? 'āœ… Optional' : 'āŒ Required'}`); - } - - // Test 3: Backend-managed fields - console.log('\n Backend-managed fields:'); - for (const field of BACKEND_FIELDS) { - const testObj = JSON.parse(JSON.stringify(baseObject)); - delete testObj.stix[field]; - - const result = await testValidation(schema, testObj); - results[objectType].backendFields[field] = { - required: !result.success, - error: result.error - }; - - const status = result.success ? 'āœ… Optional' : 'āŒ Required (PROBLEM!)'; - console.log(` ${field}: ${status}`); - } - } - - return results; -} - -/** - * Generate analysis report - */ -function generateReport(results) { - console.log('\n' + '='*70); - console.log('šŸ“Š FIELD REQUIREMENTS ANALYSIS REPORT'); - console.log('='*70); - - // Identify problematic backend fields - const problematicFields = new Set(); - const coreFieldIssues = new Set(); - - Object.entries(results).forEach(([objectType, result]) => { - // Check core fields - Object.entries(result.coreFields).forEach(([field, fieldResult]) => { - if (!fieldResult.required) { - coreFieldIssues.add(field); - } - }); - - // Check backend fields - Object.entries(result.backendFields).forEach(([field, fieldResult]) => { - if (fieldResult.required) { - problematicFields.add(field); - } - }); - }); - - console.log('\nšŸ”’ CORE STIX FIELD ANALYSIS:'); - if (coreFieldIssues.size === 0) { - console.log(' āœ… All core STIX fields are properly required'); - } else { - console.log(' āš ļø These core fields are unexpectedly optional:'); - coreFieldIssues.forEach(field => console.log(` - ${field}`)); - } - - console.log('\nāš ļø BACKEND FIELD ANALYSIS:'); - if (problematicFields.size === 0) { - console.log(' āœ… No backend-managed fields are required for "awaiting-review" objects'); - console.log(' āœ… Your validation middleware is working correctly!'); - } else { - console.log(' āŒ These backend-managed fields are required for "awaiting-review" objects:'); - problematicFields.forEach(field => { - console.log(` - ${field}`); - - // Provide recommendations - if (field === 'x_mitre_attack_spec_version') { - console.log(` → Frontend should include: "3.3.0"`); - } else if (field === 'x_mitre_domains') { - console.log(` → Frontend should include: ["enterprise-attack"]`); - } else if (field === 'x_mitre_modified_by_ref') { - console.log(` → Frontend should include: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"`); - } else if (field === 'created_by_ref') { - console.log(` → Frontend should include: "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"`); - } else if (field === 'object_marking_refs') { - console.log(` → Frontend should include: ["marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"]`); - } - }); - } - - console.log('\nšŸŽÆ RECOMMENDATIONS:'); - if (problematicFields.size === 0) { - console.log(' āœ… No action required! Your middleware correctly handles "work-in-progress" vs "awaiting-review" validation.'); - } else { - console.log(' šŸ“‹ Update your frontend to include these fields when posting "awaiting-review" objects:'); - console.log(' šŸ“‹ OR modify your validation middleware to handle these fields differently.'); - } - - // Detailed breakdown - console.log('\nšŸ“‹ DETAILED BREAKDOWN:'); - Object.entries(results).forEach(([objectType, result]) => { - console.log(`\n ${objectType.toUpperCase()}:`); - console.log(` Baseline valid: ${result.baselineValid ? 'āœ…' : 'āŒ'}`); - - const requiredCore = Object.entries(result.coreFields) - .filter(([, fr]) => fr.required).length; - const requiredBackend = Object.entries(result.backendFields) - .filter(([, fr]) => fr.required).length; - - console.log(` Required core fields: ${requiredCore}/4`); - console.log(` Required backend fields: ${requiredBackend}/5 ${requiredBackend > 0 ? 'āš ļø' : 'āœ…'}`); - }); - - return { - problematicFields: [...problematicFields], - coreFieldIssues: [...coreFieldIssues], - summary: { - hasProblems: problematicFields.size > 0, - coreFieldsOk: coreFieldIssues.size === 0 - } - }; -} - -/** - * Main execution - */ -async function main() { - try { - const results = await probeFieldRequirements(); - const analysis = generateReport(results); - - console.log('\nāœ… Field requirements probe complete!'); - - if (analysis.summary.hasProblems) { - console.log('\n🚨 Action required: Frontend needs to include additional fields for "awaiting-review" objects.'); - process.exit(1); - } else { - console.log('\nšŸŽ‰ No issues found! Your validation is working correctly.'); - process.exit(0); - } - } catch (error) { - console.error('\nšŸ’„ Probe failed:', error.message); - console.error(error.stack); - process.exit(1); - } -} - -if (require.main === module) { - main(); -} - -module.exports = { - createTestObjects, - testValidation, - probeFieldRequirements, - generateReport -}; \ No newline at end of file diff --git a/.nocommit/test-mongoose-serialization.js b/.nocommit/test-mongoose-serialization.js deleted file mode 100644 index c56b53a1..00000000 --- a/.nocommit/test-mongoose-serialization.js +++ /dev/null @@ -1,136 +0,0 @@ -const request = require('supertest'); -const database = require('./app/lib/database-in-memory'); -const databaseConfiguration = require('./app/lib/database-configuration'); -const config = require('./app/config/config'); -const login = require('./app/tests/shared/login'); -const uuid = require('uuid'); - -async function testMongooseSerialization() { - console.log('=== Testing Mongoose Serialization Behavior ===\n'); - - // Configure validation - config.validateRequests.withAttackDataModel = true; - config.validateRequests.withOpenApi = false; - - // Initialize database and app - await database.initializeConnection(); - await databaseConfiguration.checkSystemConfiguration(); - const app = await require('./app/index').initializeApp(); - const passportCookie = await login.loginAnonymous(app); - - // Create a minimal technique WITHOUT x_mitre_platforms or x_mitre_contributors - const minimalTechnique = { - workspace: { - workflow: { - state: 'work-in-progress', - }, - }, - stix: { - type: 'attack-pattern', - spec_version: '2.1', - id: `attack-pattern--${uuid.v4()}`, - created: new Date().toISOString(), - modified: new Date().toISOString(), - name: 'Test Technique - No Arrays', - x_mitre_is_subtechnique: false, - x_mitre_domains: ['enterprise-attack'], - external_references: [{ external_id: 'T001' }], - // NOTE: We are deliberately NOT including x_mitre_platforms or x_mitre_contributors - }, - }; - - console.log('1. Sending POST request WITHOUT x_mitre_platforms and x_mitre_contributors:'); - console.log(' Request body includes these fields:', Object.keys(minimalTechnique.stix)); - console.log(' x_mitre_platforms in request:', 'x_mitre_platforms' in minimalTechnique.stix); - console.log( - ' x_mitre_contributors in request:', - 'x_mitre_contributors' in minimalTechnique.stix, - ); - console.log(''); - - const postResponse = await request(app) - .post('/api/techniques') - .send(minimalTechnique) - .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); - - console.log('2. POST Response status:', postResponse.status); - console.log(''); - - if (postResponse.status === 201) { - const returnedStix = postResponse.body.stix; - - console.log('3. Server Response Analysis:'); - console.log(' Fields in response:', Object.keys(returnedStix)); - console.log(''); - console.log('4. Critical Fields Check:'); - console.log(' x_mitre_platforms in response:', 'x_mitre_platforms' in returnedStix); - console.log(' x_mitre_platforms value:', JSON.stringify(returnedStix.x_mitre_platforms)); - console.log(' x_mitre_platforms type:', typeof returnedStix.x_mitre_platforms); - console.log( - ' x_mitre_platforms Array.isArray:', - Array.isArray(returnedStix.x_mitre_platforms), - ); - console.log(''); - console.log(' x_mitre_contributors in response:', 'x_mitre_contributors' in returnedStix); - console.log( - ' x_mitre_contributors value:', - JSON.stringify(returnedStix.x_mitre_contributors), - ); - console.log(' x_mitre_contributors type:', typeof returnedStix.x_mitre_contributors); - console.log( - ' x_mitre_contributors Array.isArray:', - Array.isArray(returnedStix.x_mitre_contributors), - ); - console.log(''); - - // Now check what happens when we try to PUT with this data - console.log('5. Testing PUT with the returned object:'); - const putBody = { - workspace: { - workflow: { - state: 'work-in-progress', - }, - }, - stix: { - ...returnedStix, - name: 'Updated Name', - }, - }; - - delete putBody.stix.x_mitre_attack_spec_version; - - console.log( - ' x_mitre_platforms in PUT body:', - JSON.stringify(putBody.stix.x_mitre_platforms), - ); - console.log( - ' x_mitre_contributors in PUT body:', - JSON.stringify(putBody.stix.x_mitre_contributors), - ); - console.log(''); - - const putResponse = await request(app) - .put(`/api/techniques/${returnedStix.id}/modified/${returnedStix.modified}`) - .send(putBody) - .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); - - console.log(' PUT Response status:', putResponse.status); - if (putResponse.status === 400) { - console.log(' PUT FAILED with validation errors:'); - console.log(' Errors:', JSON.stringify(putResponse.body.details, null, 2)); - } else { - console.log(' PUT succeeded (unexpected!)'); - } - } else { - console.log('POST failed:', postResponse.body); - } - - process.exit(0); -} - -testMongooseSerialization().catch((err) => { - console.error('Error:', err); - process.exit(1); -}); From 3f56a81d77a1590b7641ff3b058fafc2aac10be2 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:28:33 -0400 Subject: [PATCH 3/4] docs: remove developer artifacts --- app/lib/default-bypass-rules.json.original | 94 ------ codex-prompt.md | 9 - .../crud-regression-test-taxonomy.md | 276 ------------------ 3 files changed, 379 deletions(-) delete mode 100644 app/lib/default-bypass-rules.json.original delete mode 100644 codex-prompt.md delete mode 100644 docs/developer/crud-regression-test-taxonomy.md diff --git a/app/lib/default-bypass-rules.json.original b/app/lib/default-bypass-rules.json.original deleted file mode 100644 index f4b999ce..00000000 --- a/app/lib/default-bypass-rules.json.original +++ /dev/null @@ -1,94 +0,0 @@ -[ - // { - // "fieldPath": ["x_mitre_attack_spec_version"], - // "errorCode": "invalid_type", - // "stixType": "all", - // "suppressError": true, - // "_comment": "Server always sets x_mitre_attack_spec_version" - // }, - // { - // "fieldPath": ["x_mitre_modified_by_ref"], - // "errorCode": "invalid_value", - // "stixType": "all", - // "suppressError": true, - // "_comment": "Server sets x_mitre_modified_by_ref based on authenticated user" - // }, - { - "fieldPath": ["x_mitre_shortname"], - "errorCode": "invalid_value", - "stixType": "x-mitre-tactic", - "suppressError": false, - "warningMessage": "Tactic shortname does not match predefined ATT&CK tactics. This may prevent compatibility with official ATT&CK data but can be used for custom taxonomies.", - "_comment": "Warn about non-standard tactic shortnames instead of blocking" - }, - { - "fieldPath": ["x_mitre_domains"], - "errorCode": "invalid_type", - "stixType": "intrusion-set", - "suppressError": true, - "_comment": "Server sets x_mitre_domains for intrusion-set (assigned during bundle export)" - }, - { - "fieldPath": ["x_mitre_domains"], - "errorCode": "invalid_type", - "stixType": "campaign", - "suppressError": true, - "_comment": "Server sets x_mitre_domains for campaign (assigned during bundle export)" - }, - { - "fieldPath": ["x_mitre_domains"], - "errorCode": "invalid_type", - "stixType": "x-mitre-matrix", - "suppressError": true, - "_comment": "Server sets x_mitre_domains for x-mitre-matrix (assigned during bundle export)" - }, - { - "fieldPath": ["x_mitre_domains"], - "errorCode": "invalid_type", - "stixType": "x-mitre-detection-strategy", - "suppressError": true, - "_comment": "Server sets x_mitre_domains for x-mitre-detection-strategy (assigned during bundle export)" - }, - // { - // "fieldPath": ["object_marking_refs"], - // "errorCode": "invalid_type", - // "stixType": "campaign", - // "suppressError": true, - // "_comment": "Server sets object_marking_refs for campaign" - // }, - // { - // "fieldPath": ["object_marking_refs"], - // "errorCode": "invalid_type", - // "stixType": "identity", - // "suppressError": true, - // "_comment": "Server sets object_marking_refs for identity" - // }, - // { - // "fieldPath": ["created_by_ref"], - // "errorCode": "invalid_type", - // "stixType": "campaign", - // "suppressError": true, - // "_comment": "Server sets created_by_ref for campaign" - // }, - // { - // "fieldPath": ["created_by_ref"], - // "errorCode": "invalid_type", - // "stixType": "x-mitre-matrix", - // "suppressError": true, - // "_comment": "Server sets created_by_ref for x-mitre-matrix" - // }, - // { - // "fieldPath": ["created_by_ref"], - // "errorCode": "invalid_type", - // "stixType": "x-mitre-asset", - // "suppressError": true, - // "_comment": "Server sets created_by_ref for x-mitre-asset" - // }, - // { - // "fieldPath": ["created_by_ref"], - // "errorCode": "invalid_type", - // "stixType": "course-of-action", - // "suppressError": true, - // "_comment": "Server sets created_by_ref for course-of-action" - // } -] diff --git a/codex-prompt.md b/codex-prompt.md deleted file mode 100644 index 559e12ea..00000000 --- a/codex-prompt.md +++ /dev/null @@ -1,9 +0,0 @@ -You're picking up a mechanical test-migration effort in the `attack-workbench-rest-api` repo. Read `ADM-test-migration-handoff.md` at the repo root first — it has the full context, the per-directory workflow, a playbook of the common ADM validation fixes, and a checklist of remaining directories. - -**Task:** Enable ATT&CK Data Model (ADM) request validation across the remaining `app/tests/api/` regression suites, one directory at a time, alphabetically, starting with `groups`. For each directory: flip each spec's `config.validateRequests.withAttackDataModel` from `false` to `true` (for pagination specs, pass `validateWithAdm: true` in the `PaginationTests` options instead), make the seeded request payloads ADM-compliant per the playbook, get the suite green, lint+format, and make one commit per directory (`test(): run suites with ADM validation enabled`, plain message body listing the fixture fixes, no AI-attribution footer). - -Work on the existing `fix/adm-validation-logging` branch. Run a single spec with `npm run test:file -- ` and a directory with `npm run test:file -- --recursive app/tests/api/`. The server logs full ADM failure detail at `[WARN] Bad request: %s` — read the `details` array to see exactly which field/rule failed. - -**Skip for now** (do last, they behave differently): `attack-objects`, `collection-bundles`, `collection-indexes`. - -Do the directories in order, committing each before moving to the next. Stop and ask if a spec needs a behavior change beyond fixture compliance, or if a directory turns out not to involve STIX-object creation at all. diff --git a/docs/developer/crud-regression-test-taxonomy.md b/docs/developer/crud-regression-test-taxonomy.md deleted file mode 100644 index bd6224bc..00000000 --- a/docs/developer/crud-regression-test-taxonomy.md +++ /dev/null @@ -1,276 +0,0 @@ -# CRUD Regression Test Taxonomy - -## Overview - -Almost every ATT&CK object type (techniques, tactics, groups, software, mitigations, -assets, data sources, data components, campaigns, …) is served by a thin -`*-service.js` that extends [`BaseService`](../../app/services/meta-classes/base.service.js). -The HTTP controllers are equally thin. This means the **business logic exercised by -the regression suites is overwhelmingly shared**: the create/update/delete behavior -lives in `BaseService`, and each per-type suite is, in effect, re-testing the same -pipeline with a different `stix.type`. - -This document does two things: - -1. Maps the POST execution stack so we know exactly what behavior each suite is - (or should be) exercising. -2. Defines a **taxonomy of evaluations** — a checklist of shared behaviors — so that - when we refactor a suite to run with ADM validation on, we converge on a common, - consistent set of test cases instead of re-deriving them per type. - -The taxonomy is the starting contract. Type-specific behavior (e.g. subtechnique -conversion, matrix external IDs) layers on top of it. - -## Execution stack: `POST /api/techniques` - -Techniques are representative — `TechniquesService` does **not** override `create`, -so the whole path is `BaseService` logic shared by nearly every type. - -``` -techniques-controller.js exports.create - └─ techniquesService.create(req.body, options) // options: { import, userAccountId, parentTechniqueId, dryRun } - └─ BaseService.create(data, options) // app/services/meta-classes/base.service.js - 1. ANALYZE REQUEST - 2. COMPOSE OBJECT - 3. SET SERVER-CONTROLLED FIELDS - 4. LIFECYCLE HOOKS (beforeCreate) - 5. VALIDATE WITH ADM (validateComposedObject → getSchema) - 6. PERSIST (repository.save → afterCreate → emit event) - └─ 201 + created object | 200 + composed object (dryRun) | 409 DuplicateId | 400 ADM/validation -``` - -### What each stage does (and which fields it touches) - -| Stage | Behavior | Key fields | -| --- | --- | --- | -| 1. Analyze | Reject if `stix.type !== service.type`. Look up existing versions by `stix.id` to decide **new object vs. new version** and whether to reuse the ATT&CK ID. | `stix.type`, `stix.id` | -| 2. Compose | `stripServerControlledFields` → strip empty strings → normalize dates → **generate or reuse the ATT&CK ID** → build and prepend the ATT&CK external reference. | `workspace.attack_id`, `external_references[0]`, `revoked`, `x_mitre_attack_spec_version` | -| 3. Set server fields | Set spec version; set `stix.id`/`created` (new object) or carry `revoked` forward (new version); default `modified`/`spec_version`; set identity refs; apply default markings; record `created_by_user_account`. | `stix.id`, `created`, `modified`, `created_by_ref`, `x_mitre_modified_by_ref`, `object_marking_refs`, `workspace.workflow` | -| 4. Hooks | `beforeCreate` — per-type transforms (most types: no-op). | (varies) | -| 5. Validate | `validateComposedObject` runs ADM `getSchema(type, status)` on the **composed** `stix`. WIP → partial schema; otherwise full schema. 400 on failure. | all | -| 6. Persist | `repository.save` (409 on duplicate `id`+`modified`), `afterCreate`, emit `created` event. `dryRun` returns the composed object with `200` and skips persistence. | all | - -> Validation runs on the **server-composed** object, not the raw request body. A test -> only needs to send a body that is valid *after* the server fills in `id`, -> `external_references`, `created_by_ref`, etc. This is why work-in-progress payloads -> can omit `stix.id` and the ATT&CK reference and still pass. - -## Field provenance model - -The single most important concept for refactoring tests is knowing **who owns each -field**. Three categories: - -### Server-controlled (client values are stripped / overwritten — do not assert that what you sent comes back) - -| Field | Behavior | Source | -| --- | --- | --- | -| `workspace.attack_id` | Stripped, then generated (new) or reused (new version). | `stripServerControlledFields`, `attackIdGenerator` | -| `external_references[0]` (the `mitre-attack` ref) | Any client-supplied ATT&CK ref is filtered out; server prepends the canonical one. `external_references[0].external_id` always mirrors `workspace.attack_id`. | `stripServerControlledFields`, `createAttackExternalReference` | -| `x_mitre_attack_spec_version` | Always stripped, then set to `config.app.attackSpecVersion`. | `ALWAYS_STRIPPED_STIX_FIELDS` | -| `revoked` | Always stripped, then `false` (new) or carried forward (new version). | `ALWAYS_STRIPPED_STIX_FIELDS` | -| `created_by_ref` | Overwritten with the org identity **on new objects** (preserved on new versions). | step 3 | -| `x_mitre_modified_by_ref` | Overwritten with the org identity (new and new version). | step 3 | -| `workspace.validation` | Stripped on every create/update. | `stripServerControlledFields` | -| `workspace.workflow.created_by_user_account` | Set from the authenticated user. | step 3 | - -### Hybrid (client may supply; server defaults when omitted) - -| Field | Provided | Omitted | -| --- | --- | --- | -| `stix.id` | Honored verbatim (and, if it already exists, makes this a **new version**). | Generated as `${type}--${uuidv4}`. | -| `stix.created` | Honored. | Defaulted to "now" **for new objects only**; on a new version it is taken from the request as-is. | -| `stix.modified` | Honored. | Defaulted to "now" (new objects and new versions). | -| `stix.spec_version` | Honored. | Defaulted to `'2.1'`. | -| `object_marking_refs` | Honored. | Default marking definitions applied. | - -### Client-controlled (round-trip faithfully; safe to assert equality) - -Everything else: `name`, `description`, `kill_chain_phases`, `x_mitre_platforms`, -`x_mitre_domains`, `x_mitre_detection`, user-provided non-ATT&CK `external_references`, -`workspace.workflow.state`, etc. These must be ADM-valid but are stored as sent. - -## The taxonomy - -Each category below is a shared behavior that should be evaluated by (nearly) every -SDO suite. IDs are stable handles for cross-referencing during the refactor. - -### A. Request shape & type - -- **A1 — Empty body rejected.** `POST` with `{}` → 400. -- **A2 — Type mismatch rejected.** `stix.type` not matching the endpoint → 400 (`InvalidTypeError`). -- **A3 — ADM validation enforced.** A body that is ADM-invalid after composition → 400, and the - response/server log carries the per-field `details` (see the validation logging fix). - -### B. STIX identity (`stix.id`) - -- **B1 — Generated when omitted.** Response `stix.id` is defined and matches `${type}--`. -- **B2 — Honored when provided.** A client-supplied `stix.id` is returned unchanged. -- **B3 — Duplicate `(id, modified)` rejected.** Re-POST of the same id+modified → 409. - -### C. Timestamps (`stix.created`, `stix.modified`) - -- **C1 — `modified` generated when omitted.** -- **C2 — `created`/`modified` honored when provided.** -- **C3 — New version bumps `modified`.** Same `stix.id`, different `modified` → second version - created (`versions=all` returns N, default GET returns latest by `modified`). -- **C4 — `created` is stable across versions** (client carries it forward; server does not rewrite it on a new version). - -### D. ATT&CK ID idempotency (`workspace.attack_id` ↔ `external_references[0].external_id`) - -- **D1 — Generated for a new object** in the correct format for the type (`T####`, `TA####`, `G####`, …). -- **D2 — Mirrored into `external_references[0].external_id`** with `source_name: 'mitre-attack'`. -- **D3 — User cannot set it.** A client-supplied `workspace.attack_id` or `mitre-attack` - external reference is stripped; the server value wins. -- **D4 — Reused across revisions.** A new version of an existing `stix.id` keeps the original - `attack_id` regardless of what the client sends (omit / keep / change → same result). -- **D5 — Searchable by ATT&CK ID.** `GET ?search=` returns the object. - -### E. Server-controlled STIX fields - -- **E1 — `x_mitre_attack_spec_version`** equals `config.app.attackSpecVersion` regardless of input. -- **E2 — `revoked`** is `false` on a new object; **carried forward** on a new version; never settable by the client. -- **E3 — `created_by_ref` / `x_mitre_modified_by_ref`** set to the org identity (not the client's value) on create. -- **E4 — Default marking definitions** applied when `object_marking_refs` is omitted. - -### F. Workspace / workflow - -- **F1 — `created_by_user_account`** recorded from the authenticated session. -- **F2 — `workspace.attack_id`/`workspace.validation`** never accepted from the client. -- **F3 — Workflow state drives validation strictness.** `work-in-progress` → partial schema - (drafts may omit fields); `awaiting-review`/`reviewed` → full schema. - -### G. Lifecycle & retrieval (the existing CRUD coverage, restated) - -- **G1 — Create → 201**, returns the composed object. -- **G2 — Retrieve** by collection, by id, by id+modified; latest-version semantics. -- **G3 — Update (PUT)** returns 200; preserves server-controlled fields from the stored doc - (`attack_id`, `revoked`, `x_mitre_is_subtechnique`, spec version). -- **G4 — Delete** by id+modified (one version) and by id (all versions). -- **G5 — `dryRun=true`** returns 200 + composed object and **persists nothing** (subsequent GET is empty). - -### H. Negative / not-found - -- **H1 — GET/DELETE unknown id** → 404. -- **H2 — Malformed parameters** → 400. - -## How `PUT` (update) differs from `POST` - -`updateFull` reuses the same strip/normalize helpers but **composes server-controlled -fields from the stored document** rather than generating them: - -- `attack_id`, `revoked`, `x_mitre_attack_spec_version`, and the ATT&CK - `external_references[0]` are taken from the existing document. -- `x_mitre_is_subtechnique` is preserved from the stored doc (changing it requires the - dedicated conversion endpoints, not the generic update path). -- `workspace.validation` is cleared once validation passes. - -So categories **D, E** apply to PUT as "carried forward from the stored doc" rather than -"generated". - -## Applicability matrix - -| Category | SDOs (technique, tactic, group, software, mitigation, asset, …) | Matrices | Marking definitions / Identities | Relationships | -| --- | --- | --- | --- | --- | -| A, B, C, G, H | āœ… | āœ… | āœ… | āœ… | -| D (ATT&CK ID) | āœ… | āš ļø external_id is the domain name, not an auto ID | āŒ no ATT&CK ID | āŒ | -| E1, E2 | āœ… | āœ… | partial | partial | -| E3 (identity refs) | āœ… | āœ… | āš ļø identities created during bootstrap | āœ… | -| F3 (WIP vs full) | āœ… | āœ… | āœ… (simple schema) | āœ… | - -Types in the āš ļø/āŒ columns get the shared categories that apply plus a small set of -type-specific cases; they should **not** re-implement the categories that don't apply. - -## Test suite organization - -A per-type capability (techniques, tactics, …) is covered by **several spec files split -by behavior**, not one monolith. Using `app/tests/api/techniques/` as the model: - -| Spec | Scope | Taxonomy categories | -| --- | --- | --- | -| `.spec.js` | Core CRUD lifecycle | A, B, C, E, F, G, H | -| `-pagination.spec.js` | Pagination (offset/limit/`includePagination`) | G2 (read) | -| `.query.spec.js` | GET filter parameters (`search`, `state`, `includeRevoked`, domains, …) | G2 (read) | -| `.convert.spec.js` | Type-specific endpoints (e.g. sub/technique conversion) | type-specific | -| `.tactics.spec.js` | Type-specific relationship endpoints | type-specific | -| `.revoke.spec.js` | Revoke workflow | type-specific | - -**This decomposition is correct and should be preserved.** Each file is cohesive, names -its failures clearly, and can be run alone. The decomposition is not the problem; the -inconsistencies within it are. When refactoring, fix these and **do not** collapse files: - -- **One fixture per type.** Today each spec defines its own `initialObjectData` and they - have drifted. Converge on a single shared ADM-compliant baseline per type (see the - refactor steps below) so an ADM rule change is fixed in one place, not six. -- **Consistent naming.** Use `.` separators (`.pagination.spec.js`), not a mix of - `-` and `.`. -- **Put search/filter tests in `.query`**, not in the core CRUD spec. - -### Bootstrapping styles (choose deliberately) - -Three ways a suite gets the database into a known state are in use. All are legitimate; -the choice should be intentional: - -1. **HTTP-driven** (`request(app).post(...)`) — exercises the *full* stack: OpenAPI request - validation → controller → service → ADM validation → persistence. Use this when the - behavior under test **is** the HTTP contract (the core CRUD spec, convert, revoke). -2. **Service-driven** (`service.create(...)`) — seeds state by calling the service directly. - Faster and less verbose, but **bypasses the controller and OpenAPI request-validation - middleware**. ADM validation still runs (it lives in `BaseService.create`), so seeded - fixtures must still be ADM-compliant. Use this to set up *preconditions* for tests whose - real subject is reads/queries (`query`, `pagination`). -3. **Bundle-import** (collection bundle JSON via the import path) — seeds many related - objects at once. Use for relationship/graph-shaped fixtures (`tactics`). - -> Refactor consequence: because service-driven and bundle-import seeding still run ADM -> validation, the `query`, `pagination`, and `tactics` fixtures need the **same** -> compliance fixes as the core spec — they are not exempt just because they skip HTTP. - -## Test independence - -The industry baseline is **F.I.R.S.T.**: Fast, **Independent**, Repeatable, -Self-validating, Timely. "Independent" means a test establishes its own preconditions and -passes regardless of execution order or what ran before it. - -The distinction that matters in practice is **what** a test depends on: - -- Depending on a **`before`/`beforeEach` hook** for shared state — āœ… good. The dependency - is explicit and re-established on every run; tests within the block stay order-independent. -- Depending on a **previous `it` block having executed** — āš ļø fragile. Mocha runs in - definition order and never randomizes, so it works, but you cannot run a single `it` in - isolation (`it.only`), and a failure mid-chain cascades into misleading downstream - failures. - -**The one sanctioned exception** is a deliberately **sequential CRUD/E2E narrative** -(create → read → update → new version → delete) where per-test bootstrapping would be -wasteful and the *flow itself* is the thing under test. This is idiomatic for integration -suites — provided it is **deliberate and contained**: - -- keep it in a single `describe` with its own database lifecycle (`before`/`after`), -- comment it as an intentional sequential flow, -- keep the chain short; pull shared preconditions into `before` rather than relying on a - sibling `it` wherever practical. - -### Policy - -- **Core CRUD spec** (`.spec.js`): a sequential narrative is acceptable — label it as - such. Everything else should be hook-seeded and order-independent. -- **Feature specs** (`query`, `pagination`, `convert`, `tactics`, `revoke`): seed shared - state in `before`/`beforeEach`; individual `it` blocks must not depend on a sibling `it`. -- **Never** rely on cross-*file* state. Each spec owns its database lifecycle. - -## Using this during the refactor - -For each suite we convert to ADM-on: - -1. Build a single ADM-compliant `initialObjectData` for the type (state at rest, fully - composed — see [`techniques.spec.js`](../../app/tests/api/techniques/techniques.spec.js)). -2. Walk the taxonomy and confirm the suite exercises each applicable category, adding the - missing ones (the `stix.id`/`created`/`modified` provided-vs-omitted cases and the - ATT&CK-ID idempotency cases **D3/D4** are the ones most commonly absent today). -3. Do **not** assert that server-controlled fields echo the client's input — assert the - server's rule instead (e.g. "`attack_id` is defined and matches `T####`", not - "`attack_id === 'T9999'`"). - -Once the taxonomy is encoded once (ideally as shared assertion helpers in -`app/tests/shared/`), each per-type suite shrinks to: a valid baseline object + the -type-specific deltas. From 7f375fa57d317fd7fa6cf737b3cbbef7ff9e04d5 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:30:03 -0400 Subject: [PATCH 4/4] docs: remove developer artifacts --- ADM-test-migration-handoff.md | 173 ---------------------------------- 1 file changed, 173 deletions(-) delete mode 100644 ADM-test-migration-handoff.md diff --git a/ADM-test-migration-handoff.md b/ADM-test-migration-handoff.md deleted file mode 100644 index f14b9bdb..00000000 --- a/ADM-test-migration-handoff.md +++ /dev/null @@ -1,173 +0,0 @@ -# Handoff: Enable ADM validation across the regression test suites - -## Goal - -The REST API can validate incoming objects against the ATT&CK Data Model (ADM) -Zod schemas when `config.validateRequests.withAttackDataModel` is `true`. Most -regression specs were written against looser, pre-ADM payloads and still run with -that flag `false`, so the strict validation path is untested. - -Your job: go directory-by-directory through `app/tests/api/`, flip each spec to -run **with ADM validation enabled**, make the seeded request payloads -ADM-compliant, and confirm the suite still passes. One commit per directory. - -This is mechanical, repetitive work. A playbook of the common fixes is below — -most directories need only a handful of them. - -## Branch & continuation - -- Work on the existing branch **`fix/adm-validation-logging`** (forked from `next`). -- A draft PR is already open against `next`. -- Already migrated (do not redo): `techniques`, `analytics`, `assets`, - `campaigns`, `collections`, `data-components`, `data-sources`, - `detection-strategies`, plus a logging fix and a fixture-inlining commit. -- Continue **alphabetically**. Next up is `groups`. - -## The per-directory workflow (repeat for each) - -1. List the specs: `ls app/tests/api//`. -2. For each `*.spec.js` (and any `*-spec.js` — note the inconsistent naming), - find the ADM toggle: `grep -n withAttackDataModel app/tests/api//*`. -3. Flip `config.validateRequests.withAttackDataModel = false` → `true`. Leave - `withOpenApi` as it is. Update the stale `// Disable ADM validation` comment to - `// Enable ADM validation; the request payloads in this spec are ADM-compliant`. - - **Pagination specs are special** — see below. -4. Run the spec: `npm run test:file -- app/tests/api//.spec.js`. -5. Read the failures. The logger prints full ADM detail at `[WARN] Bad request: %s` - with a `details` array naming the exact field + rule. Fix the fixture(s) per the - playbook. Re-run. Iterate until green. -6. When all specs in the directory pass, run the whole directory: - `npm run test:file -- --recursive app/tests/api/`. -7. Lint + format the changed files: - `npx eslint app/tests/api//` and `npx prettier app/tests/api// --check` - (use `--write` if needed). -8. Commit (one per directory): - `git add app/tests/api//...` then - `git commit -m "test(): run suites with ADM validation enabled"` - with a short body describing the fixture fixes. **Match the existing commit - style — plain messages, no AI-attribution footer.** A pre-commit hook runs - prettier/eslint/lockfile-check automatically. - -## Pagination specs (special case) - -Pagination specs delegate to the shared `PaginationTests` class -(`app/tests/shared/pagination.js`) and have no `before()` of their own. Do **not** -add a config toggle. Instead pass the flag through the options object: - -```js -const options = { - prefix: '...', - baseUrl: '/api/...', - label: '...', - // The seeded fixture is ADM-compliant; pin validation on so this suite does - // not inherit the flag from whichever spec ran before it. - validateWithAdm: true, -}; -``` - -`PaginationTests` already honors `options.validateWithAdm` (added during the -techniques migration) and sets the config in its own `before()`. - -## ADM fix playbook (the common failures) - -Validation runs on the **server-composed** object, after the server fills in -`id`, the ATT&CK `external_references[0]`, `created_by_ref`, -`x_mitre_modified_by_ref`, and `x_mitre_attack_spec_version`. So a payload only -needs to be valid *after* composition. Workflow state drives strictness: -`work-in-progress` → **partial** schema (lenient; may omit required fields); -`awaiting-review`/`reviewed`/absent → **full** schema (strict). `status` defaults -to `reviewed` when `workspace.workflow.state` is absent. - -| Symptom (from the `details` array) | Fix | -| --- | --- | -| `x_mitre_platforms.N ... Platform must be one of: ...` | Enum is **case-sensitive**: `'windows'` → `'Windows'`; `'platform-1'` → a real value (`Linux`, `macOS`, `Windows`, `Android`, `Network`, `Office Suite`, …). Domain does **not** constrain platform. | -| `kill_chain_phases.N.kill_chain_name ... expected one of "mitre-attack"\|"mitre-mobile-attack"\|"mitre-ics-attack"` | Use `mitre-attack` (or the mobile/ics variant). | -| `phase_name ...` | Lowercase, hyphenated tactic shortname, e.g. `execution`, `impact`. | -| `x_mitre_impact_type.N ... must be one of: Availability, Integrity` | Use a valid value **and** include the `impact` tactic in `kill_chain_phases` (enterprise-only refinement). If the field isn't asserted by any test, just delete it. | -| `x_mitre_sectors / related_asset_sectors ... Sector must be one of: ...` | Enum: `Electric`, `Water and Wastewater`, `Manufacturing`, `Rail`, `Maritime`, `General`. | -| `x_mitre_collection_layers ...` | Enum: `Cloud Control Plane`, `Host`, `Report`, `Container`, `Device`, `OSINT`, `Network`. | -| `x_mitre_data_sources.N ... pattern ': '` | Deprecated `z.custom` field. Use `'Name: Component'` format, or (if not asserted) delete it. | -| `id ... invalid UUIDv4 format` / `must comply with format 'type--UUIDv4'` | Hardcoded STIX ids must be `type--`. **Valid v4**: 13th hex digit (version) = `4`, 17th (variant) = `8/9/a/b`. Keep ids consistent across every place they're referenced (definition + embedded refs + literal assertions). For "non-existent ref → 404" tests, use a **valid-format-but-absent** UUID so ADM (400) doesn't preempt the intended 404. | -| `x_mitre_version ... expected string, received undefined` | Full schema requires `x_mitre_version` (e.g. `'1.0'`). | -| `x_mitre_domains ... expected array, received undefined` | Full schema requires `x_mitre_domains` (e.g. `['enterprise-attack']`). WIP omits it fine. | -| `... Unrecognized key: "description"` | `x-mitre-detection-strategy` has **no** `description` field — remove it. (Schemas are `.strict()`; analytics/most SDOs *do* allow `description`.) | -| `x_mitre_analytic_refs ... Too small / At least one` | Detection-strategy requires ≄1 analytic ref. Either reference a real created analytic, or for "no refs" omit the field (don't send `[]`). | -| `x_mitre_log_source_references ... Too small` | Omit the field entirely to mean "none"; it must be non-empty when present. | -| `x_mitre_contents ... At least one` + missing `x_mitre_version` (collections) | Collection full schema needs `x_mitre_version` and ≄1 `x_mitre_contents` entry. | - -**Do not** assert that server-controlled fields echo what you sent. The server -overwrites/strips: `workspace.attack_id`, the ATT&CK `external_references[0]`, -`x_mitre_attack_spec_version`, `revoked`, and (on create) `created_by_ref` / -`x_mitre_modified_by_ref`. If an existing assertion expects a specific -server-controlled value, assert the server's *rule* instead (e.g. "`attack_id` -matches `T####`"), not the literal you sent. - -## Where to find schema truth - -- Selector used by the validator: `getSchema()` in - `app/lib/validation-schemas.js`. -- Compiled schemas: `node_modules/@mitre-attack/attack-data-model/dist/index.cjs`. -- Readable source (checked out locally): - `/Users/ssica/Development/attack/attack-data-model/src/schemas/` — the - per-type files under `sdo/` and the cross-field rules in - `refinements/index.ts`. Enum lists and required fields live in the type's - `*.schema.ts`. -- Quick UUID sanity check: - `node -e "const {validate,version}=require('uuid'); const u='...'; console.log(validate(u)&&version(u)===4)"` - -## Gotchas - -- **Flakiness:** the suite has intermittent cross-spec state leakage (shared - in-memory DB). An occasional failure at delete-all / "expect empty array" - assertions usually clears on re-run. Don't chase it; re-run once. -- **Collections route returns 500, not 400, on ADM failure** — its create path - doesn't map `ValidationError` to 400 and the details bypass the `Bad request: %s` - log. If `details` aren't logged, validate the composed object directly against - `getSchema(type, status)` in a scratch script. -- **Non-STIX specs:** some directories operate on non-STIX/system objects - (`teams`, `user-accounts`, `session`, parts of `system-configuration`, - `identities`). For those, `getSchema()` returns `null` and ADM never runs, so - flipping the flag is a harmless no-op — flip it for consistency, confirm green, - move on. If a spec clearly has nothing to do with STIX object creation, it's - fine to leave it unchanged; note it in the commit body. -- **Naming inconsistency:** some files are `-spec.js` instead of - `.spec.js`, and some pagination files use `-` vs `.`. Mocha's - `--recursive` loads all `.js` files so they still run; leave renames out of this - effort. - -## Remaining work (alphabetical; one commit each) - -Vanilla CRUD suites — do these in order: - -- [ ] `groups` — `groups.spec.js`, `groups-input-validation.spec.js`, `groups.query.spec.js`, `groups-pagination.spec.js` (pagination → `validateWithAdm` option) -- [ ] `identities` — `identities.spec.js` (likely non-STIX no-op; verify) -- [ ] `marking-definitions` — `marking-definitions.spec.js` -- [ ] `matrices` — `matrices.spec.js` (matrix ATT&CK external_id is the domain name, not an auto ID) -- [ ] `mitigations` — `mitigations.spec.js`, `mitigations-pagination.spec.js` (pagination → `validateWithAdm` option) -- [ ] `notes` — `notes.spec.js` (workspace object; may be a no-op) -- [ ] `recent-activity` — `recent-activity.spec.js` (bundle-seeded; may be involved) -- [ ] `references` — `references.spec.js` (workspace object; may be a no-op) -- [ ] `relationships` — `relationships.spec.js`, `relationships-pagination.spec.js` (pagination → `validateWithAdm` option) -- [ ] `reports` — `reports.spec.js` -- [ ] `session` — `session.spec.js` (non-STIX; likely no-op) -- [ ] `software` — `software.spec.js`, `software-pagination.spec.js` (`malware`/`tool`; pagination → `validateWithAdm` option) -- [ ] `stix-bundles` — `stix-bundles.spec.js` (bundle-seeded; may be involved) -- [ ] `system-configuration` — `system-configuration.spec.js`, `create-object-identity.spec.js` -- [ ] `tactics` — `tactics.spec.js`, `tactics.techniques.spec.js` (the latter is bundle-import seeded) -- [ ] `teams` — `teams.spec.js`, `teams-invalid.spec.js` (non-STIX; likely no-op) -- [ ] `user-accounts` — `user-accounts.spec.js`, `user-accounts-invalid.spec.js` (non-STIX; likely no-op) - -**Defer to the very end** (non-vanilla; import-path / bundle-validation heavy — -ADM is recorded-not-rejected there, so they behave differently): - -- [ ] `attack-objects` -- [ ] `collection-bundles` -- [ ] `collection-indexes` - -## Definition of done (per directory) - -- Every spec in the directory sets/pins `withAttackDataModel = true` (or - `validateWithAdm: true` for pagination). -- `npm run test:file -- --recursive app/tests/api/` is green. -- eslint + prettier clean. -- One commit, `test(): ...`, with a body listing the fixture fixes.