diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fecf9d1c..3e206c29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: - name: Run regression tests with code coverage run: npm run coverage:cobertura + env: + MONGOSTORE_CRYPTO_SECRET: 'ThisisASecretKeyForTestingPurposesOnly1234567890!@#$' - name: Upload Coverage to CodeCov uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index aa128085..41e16e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# A place to store artifacts during local development (scripts, datasets, dotenv files, etc.) +.nocommit/**/* + # Logs logs *.log diff --git a/.releaserc b/.releaserc index ad14f01e..60f0d3b7 100644 --- a/.releaserc +++ b/.releaserc @@ -11,10 +11,6 @@ { "name": "alpha", "prerelease": true - }, - { - "name": "adm", - "prerelease": true } ], "plugins": [ diff --git a/.vscode/launch.json b/.vscode/launch.json index 2bef47fe..4d01aac9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,11 @@ "skipFiles": ["/**"], "program": "${workspaceFolder}/bin/www", "outputCapture": "std", - "envFile": "${workspaceFolder}/.env" + "envFile": "${workspaceFolder}/.env", + "env": { + "DATABASE_URL": "mongodb://localhost:27017/attack-workspace", + "LOG_LEVEL": "debug" + } }, { "type": "node", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b104267..317e0ec3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,20 +60,20 @@ If your changes are related to or dependent on changes in [attack-workbench-fron The project uses the following branch structure to support semantic-release: -- `main` / `master`: Production-ready code +- `main`: Production-ready code - `next`: Features for the next minor version - `next-major`: Features for the next major version - `beta`: Beta pre-releases - `alpha`: Alpha pre-releases -- `*.*.x` or `*.x`: Maintenance branches for specific version releases +- `*.x`: Maintenance branches for specific version releases -Always target your pull requests to the `develop` branch unless specifically advised otherwise. +Always target your pull requests to the `main` branch unless specifically advised otherwise. ## Commit Message Guidelines This project uses [conventional commits](https://www.conventionalcommits.org/) to automatically determine semantic versioning through semantic-release. Your commit messages should follow this format: -``` +```text (): [optional body] @@ -101,7 +101,7 @@ Adding `BREAKING CHANGE:` in the commit message footer will trigger a MAJOR vers The project uses GitHub Actions for continuous integration with the following workflow: 1. **Commit Linting**: Ensures all commits follow the conventional commit format -2. **Static Checks**: +2. **Static Checks**: - Runs linting checks - Performs security scanning with Snyk - Generates code coverage reports @@ -140,11 +140,12 @@ Pre-release branches (alpha, beta) will generate pre-release versions with appro The project publishes Docker images to the GitHub Container Registry (ghcr.io) with these tags: -- `latest`: Points to the most recent release from the main branch -- `v{major}.{minor}.{patch}`: Specific version tags (e.g., `v1.2.3`) -- `{major}.{minor}.{patch}`: Version tags without the 'v' prefix -- `sha-{short-commit-sha}`: Specific commit reference +- `latest`: Points to the most recent release from the `main` branch +- `next`: Points to the most recent release from the `next` branch +- `beta`: Points to the most recent release from the `beta` branch +- `alpha`: Points to the most recent release from the `alpha` branch +- `{major}.{minor}.{patch}`: Specific version tags (e.g., `v1.2.3`) Docker images include metadata such as version, build time, and commit reference, which are accessible via both environment variables and image labels. -The image contains the Express.js REST API service and is designed to work with a MongoDB database. \ No newline at end of file +The image contains the Express.js REST API service and is designed to work with a MongoDB database. diff --git a/README.md b/README.md index 086b16e9..ed9176db 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ For a full ATT&CK Workbench deployment, including the frontend application, see - [Usage Guide](USAGE.md): Comprehensive instructions for installing, configuring, and administering the REST API - [Contributing Guide](CONTRIBUTING.md): Information for developers about contributing to the project -- [Data Model](docs/data-model.md): Technical details about the data models used in the application +- [Data Model](docs/developer/data-model.md): Technical details about the data models used in the application ## Technical Information @@ -57,10 +57,10 @@ Copyright 2020-2025 MITRE Engenuity. Approved for public release. Document numbe Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. This project makes use of ATT&CK® -[ATT&CK Terms of Use](https://attack.mitre.org/resources/terms-of-use/) \ No newline at end of file +[ATT&CK Terms of Use](https://attack.mitre.org/resources/terms-of-use/) diff --git a/USAGE.md b/USAGE.md index 9f04e4f5..14db2443 100644 --- a/USAGE.md +++ b/USAGE.md @@ -18,9 +18,6 @@ This guide provides comprehensive instructions for installing, configuring, and - [Environment Variables](#environment-variables) - [Configuration File](#configuration-file) - [Authentication](#authentication) - - [Authentication Mechanisms](#authentication-mechanisms) - - [OpenID Connect (OIDC) Configuration](#openid-connect-oidc-configuration) - - [Service Authentication](#service-authentication) - [User Management](#user-management) - [User Roles and Permissions](#user-roles-and-permissions) - [User Account Status](#user-account-status) @@ -37,6 +34,7 @@ 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) @@ -48,18 +46,21 @@ The recommended deployment method is using Docker. The REST API is published as #### Using Docker Compose (Recommended) -The simplest way to deploy the entire ATT&CK Workbench application is using Docker Compose. Instructions are available in the [Workbench Deployment Guide](https://github.com/mitre-attack/attack-workbench-deployment). +The simplest way to deploy the entire ATT&CK Workbench application is using Docker Compose. +Instructions are available in the [Workbench Deployment Guide](https://github.com/mitre-attack/attack-workbench-deployment). #### Standalone Docker Deployment To run only the REST API in a Docker container: 1. **Create a Docker network** (if not already created): + ```shell docker network create attack-workbench-network ``` 2. **Run MongoDB container**: + ```shell docker run --name attack-workbench-mongodb -d \ --network attack-workbench-network \ @@ -67,6 +68,7 @@ To run only the REST API in a Docker container: ``` 3. **Run REST API container**: + ```shell docker run -p 3000:3000 -d \ --name attack-workbench-rest-api \ @@ -86,6 +88,8 @@ docker run -p 3000:3000 -d \ ghcr.io/center-for-threat-informed-defense/attack-workbench-rest-api:latest ``` +More infomation about configuration options is in the [configuration file documentation](./docs/admin/configuration.md). + ### Manual Installation #### Requirements @@ -96,12 +100,14 @@ docker run -p 3000:3000 -d \ #### Installation Steps 1. **Clone the repository**: + ```shell git clone https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api.git cd attack-workbench-rest-api ``` 2. **Install dependencies**: + ```shell npm install ``` @@ -109,6 +115,7 @@ docker run -p 3000:3000 -d \ 3. **Configure the application** using environment variables or a configuration file (see [Configuration](#configuration)). 4. **Start the application**: + ```shell node ./bin/www ``` @@ -165,42 +172,8 @@ Example configuration file: ## Authentication -The REST API supports different authentication mechanisms for both user and service authentication. - -### Authentication Mechanisms - -The application supports these user authentication mechanisms: - -- **Anonymous**: Default mechanism with no actual authentication (primarily for local development) -- **OpenID Connect (OIDC)**: Integration with organizational identity providers - -### OpenID Connect (OIDC) Configuration - -To enable OIDC authentication: - -1. **Register with your OIDC Identity Provider** with these details: - - Authentication flow: Authorization Code Flow - - Required claims: `email` (required), `preferred_username` (optional), `name` (optional) - - Grant Types: Client Credentials, Authorization Code, and Refresh Token - - Redirect URL: `/api/authn/oidc/callback` - -2. **Configure the REST API** with these environment variables: - -| Environment Variable | Required | Description | Configuration Property | -|---------------------|----------|-------------|------------------------| -| **AUTHN_MECHANISM** | Yes | Must be set to `oidc` | userAuthn.mechanism | -| **AUTHN_OIDC_CLIENT_ID** | Yes | Client ID from your OIDC provider | userAuthn.oidc.clientId | -| **AUTHN_OIDC_CLIENT_SECRET** | Yes | Client secret from your OIDC provider | userAuthn.oidc.clientSecret | -| **AUTHN_OIDC_ISSUER_URL** | Yes | Issuer URL for the Identity Server | userAuthn.oidc.issuerUrl | -| **AUTHN_OIDC_REDIRECT_ORIGIN** | Yes | URL for the Workbench host | userAuthn.oidc.redirectOrigin | - -### Service Authentication - -For service-to-service communication, the REST API supports three methods: - -1. **API Key Challenge Authentication**: Services obtain a JWT using a challenge-response protocol -2. **API Key Basic Authentication**: Services authenticate using HTTP Basic Authentication -3. **OIDC Client Credentials Flow**: Services obtain a JWT from an OIDC provider +The REST API has several authentication options. +Read all about them in the [authentication docs](./docs/admin/authentication/README.md). ## User Management @@ -210,29 +183,29 @@ The REST API includes a user management system when using OIDC authentication. The system supports these roles: -| Role | Description | -|------|-------------| -| `none` | No access to the system (for pending/inactive users) | -| `visitor` | Read-only access to ATT&CK objects | -| `editor` | Read and write access to ATT&CK objects | -| `admin` | Full access to all system capabilities, including user management | +| Role | Description | +|-----------|-------------------------------------------------------------------| +| `none` | No access to the system (for pending/inactive users) | +| `visitor` | Read-only access to ATT&CK objects | +| `editor` | Read and write access to ATT&CK objects | +| `admin` | Full access to all system capabilities, including user management | ### User Account Status -| Status | Description | -|--------|-------------| -| `pending` | User has registered but awaits approval | -| `active` | User is registered and approved | -| `inactive` | User is no longer active | +| Status | Description | +|------------|-----------------------------------------| +| `pending` | User has registered but awaits approval | +| `active` | User is registered and approved | +| `inactive` | User is no longer active | ### User Management Endpoints -| Endpoint | Method | Description | Authorization | -|----------|--------|-------------|--------------| -| `/api/user-accounts` | GET | List all users | Admin only | -| `/api/user-accounts/:id` | GET | Get user by ID | Admin or self | -| `/api/user-accounts/register` | POST | Register new user | Logged in, unregistered users | -| `/api/user-accounts/:id` | PUT | Update user | Admin only | +| Endpoint | Method | Description | Authorization | +|-------------------------------|--------|-------------------|-------------------------------| +| `/api/user-accounts` | GET | List all users | Admin only | +| `/api/user-accounts/:id` | GET | Get user by ID | Admin or self | +| `/api/user-accounts/register` | POST | Register new user | Logged in, unregistered users | +| `/api/user-accounts/:id` | PUT | Update user | Admin only | ## API Documentation @@ -284,4 +257,4 @@ Common issues and their solutions: 4. **Permission denied errors**: - Check the user's role and status - - Ensure the user account has the necessary permissions for the operation \ No newline at end of file + - Ensure the user account has the necessary permissions for the operation diff --git a/app/api/definitions/components/campaigns.yml b/app/api/definitions/components/campaigns.yml index fb6f3d63..bc4bf904 100644 --- a/app/api/definitions/components/campaigns.yml +++ b/app/api/definitions/components/campaigns.yml @@ -17,10 +17,6 @@ components: - type: object required: - name - - first_seen - - last_seen - - x_mitre_first_seen_citation - - x_mitre_last_seen_citation properties: # campaign specific properties name: diff --git a/app/api/definitions/components/query-parameters.yml b/app/api/definitions/components/query-parameters.yml new file mode 100644 index 00000000..c0bc2972 --- /dev/null +++ b/app/api/definitions/components/query-parameters.yml @@ -0,0 +1,14 @@ +components: + parameters: + dryRun: + name: dryRun + in: query + description: | + When set to `true`, the request runs through the full composition and validation + pipeline but does not persist changes. Returns the composed object that would have + been created or updated. + + Use this to validate data before committing it. Replaces the deprecated `POST /api/validate` endpoint. + schema: + type: boolean + default: false diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml new file mode 100644 index 00000000..214d28ef --- /dev/null +++ b/app/api/definitions/components/release-tracks.yml @@ -0,0 +1,339 @@ +components: + schemas: + release-track-snapshot: + type: object + description: 'A snapshot document for a release track, containing versioned member objects and workflow tiers' + properties: + id: + type: string + description: 'The release track ID (STIX identifier format)' + example: 'release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890' + type: + type: string + enum: + - standard + - virtual + description: 'Track type: standard (direct object management) or virtual (aggregation)' + modified: + type: string + format: date-time + description: 'When this snapshot was created' + version: + type: string + nullable: true + description: 'Semantic version (e.g., "1.0", "2.1") if tagged, null for draft snapshots' + example: '1.0' + name: + type: string + description: 'Human-readable track name' + example: 'Enterprise ATT&CK' + description: + type: string + description: 'Track description' + created: + type: string + format: date-time + description: 'When the track was initially created' + created_by_ref: + type: string + description: 'STIX ID of the user who created the track' + example: 'identity--12345678-1234-1234-1234-123456789012' + object_marking_refs: + type: array + items: + type: string + description: 'STIX marking definition references' + members: + type: array + description: 'Released objects (promoted from staged during tagging)' + items: + $ref: '#/components/schemas/tier-entry' + staged: + type: array + nullable: true + description: 'Reviewed objects ready for next release (standard tracks only)' + items: + $ref: '#/components/schemas/staged-entry' + candidates: + type: array + nullable: true + description: 'Objects in workflow (standard tracks only)' + items: + $ref: '#/components/schemas/candidate-entry' + quarantine: + type: array + nullable: true + description: 'Conflicting objects needing resolution (virtual tracks only)' + items: + $ref: '#/components/schemas/tier-entry' + composition: + nullable: true + description: 'Component track references (virtual tracks only)' + $ref: '#/components/schemas/composition' + config: + $ref: '#/components/schemas/track-config' + version_history: + type: array + description: 'History of tagged releases' + items: + $ref: '#/components/schemas/version-history-entry' + + tier-entry: + type: object + description: 'A reference to a specific version of a STIX object' + properties: + object_ref: + type: string + description: 'STIX ID of the object' + example: 'attack-pattern--12345678-1234-1234-1234-123456789012' + object_modified: + type: string + format: date-time + description: 'Version pin: the modified timestamp of this object version' + + candidate-entry: + allOf: + - $ref: '#/components/schemas/tier-entry' + - type: object + properties: + object_status: + type: string + enum: + - work-in-progress + - awaiting-review + - reviewed + description: 'Workflow status (scoped to this track)' + object_added_at: + type: string + format: date-time + description: 'When added to candidates' + object_added_by: + type: string + description: 'User who added this candidate' + + staged-entry: + allOf: + - $ref: '#/components/schemas/tier-entry' + - type: object + properties: + object_status: + type: string + enum: + - work-in-progress + - awaiting-review + - reviewed + description: 'Workflow status (preserved from candidates)' + object_staged_at: + type: string + format: date-time + description: 'When promoted to staged' + object_staged_by: + type: string + description: 'User who staged this object' + + track-config: + type: object + description: 'Release track configuration' + properties: + auto_promote: + type: boolean + description: 'Whether to automatically promote candidates that meet the threshold' + default: false + candidacy_threshold: + type: string + enum: + - work-in-progress + - awaiting-review + - reviewed + description: 'Minimum workflow status required for auto-promotion to staged' + default: 'reviewed' + promotion_conflicts: + type: object + description: 'Conflict resolution policies for tier promotions' + properties: + candidates_to_staged: + type: string + enum: + - always_overwrite + - always_reject + - prefer_latest + - abort + description: 'How to handle conflicts when promoting candidates to staged' + default: 'prefer_latest' + staged_to_members: + type: string + enum: + - always_overwrite + - always_reject + - prefer_latest + - abort + description: 'How to handle conflicts when tagging (promoting staged to members)' + default: 'abort' + + composition: + type: object + description: 'Virtual track composition (references to component standard tracks)' + properties: + component_tracks: + type: array + items: + $ref: '#/components/schemas/component-track' + deduplication_strategy: + type: string + enum: + - prioritize_latest_object + - prioritize_latest_snapshot + - prioritize_higher_priority + - quarantine + description: 'How to resolve duplicate objects across components' + default: 'prioritize_latest_object' + + component-track: + type: object + description: 'Reference to a component track in a virtual track composition' + properties: + track_id: + type: string + description: 'The release track ID of the component' + example: 'release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890' + priority: + type: number + description: 'Priority for deduplication (higher wins)' + resolution_strategy: + type: string + enum: + - latest_tagged + - specific_version + - specific_snapshot + description: 'How to resolve which snapshot to use from this component' + default: 'latest_tagged' + version: + type: string + nullable: true + description: 'Specific version to pin to (when resolution_strategy is specific_version)' + snapshot_modified: + type: string + format: date-time + nullable: true + description: 'Specific snapshot to pin to (when resolution_strategy is specific_snapshot)' + filters: + type: object + description: 'Optional filters to apply to component members' + properties: + object_types: + type: array + items: + type: string + description: 'Only include these STIX types' + domains: + type: array + items: + type: string + description: 'Only include objects from these ATT&CK domains' + + version-history-entry: + type: object + description: 'Record of a tagged release' + properties: + version: + type: string + description: 'The semantic version number' + example: '1.0' + tagged_at: + type: string + format: date-time + description: 'When this version was tagged' + tagged_by: + type: string + description: 'User who tagged this version' + snapshot_id: + type: string + format: date-time + description: 'The snapshot modified timestamp that was tagged' + summary: + type: object + description: 'Statistics about this release' + properties: + members_count: + type: number + description: 'Total objects in members tier after release' + promoted_count: + type: number + description: 'Objects promoted from staged in this release' + staged_count: + type: number + description: 'Objects remaining in staged after release' + candidate_count: + type: number + description: 'Objects in candidates at time of release' + + release-track-registry: + type: object + description: 'Registry entry tracking metadata about a release track' + properties: + track_id: + type: string + description: 'The release track ID' + example: 'release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890' + type: + type: string + enum: + - standard + - virtual + description: 'Track type' + name: + type: string + description: 'Track name' + description: + type: string + description: 'Track description' + latest_snapshot_modified: + type: string + format: date-time + description: 'Timestamp of the most recent snapshot' + latest_tagged_version: + type: string + nullable: true + description: 'Most recent tagged version number' + example: '2.1' + snapshot_count: + type: number + description: 'Total number of snapshots for this track' + tagged_release_count: + type: number + description: 'Number of tagged releases' + created_at: + type: string + format: date-time + description: 'When the track was created' + updated_at: + type: string + format: date-time + description: 'When the track metadata was last updated' + snapshot_schedule: + nullable: true + description: 'Automated snapshot schedule (virtual tracks only)' + $ref: '#/components/schemas/snapshot-schedule' + + snapshot-schedule: + type: object + description: 'Schedule for automated virtual track snapshot creation' + properties: + mode: + type: string + enum: + - interval + - dates + - disabled + description: 'Scheduling mode' + interval_days: + type: number + nullable: true + description: 'Days between snapshots (when mode is interval)' + dates: + type: array + nullable: true + items: + type: string + format: date-time + description: 'Specific dates for snapshots (when mode is dates)' diff --git a/app/api/definitions/components/stix-common.yml b/app/api/definitions/components/stix-common.yml index 96710d19..72b8e24f 100644 --- a/app/api/definitions/components/stix-common.yml +++ b/app/api/definitions/components/stix-common.yml @@ -4,9 +4,6 @@ components: type: object required: - type - - spec_version - - created - - modified properties: # STIX common properties type: diff --git a/app/api/definitions/components/workflow-response.yml b/app/api/definitions/components/workflow-response.yml new file mode 100644 index 00000000..094578c7 --- /dev/null +++ b/app/api/definitions/components/workflow-response.yml @@ -0,0 +1,102 @@ +components: + schemas: + workflow-response: + type: object + description: | + Universal response envelope for all workflow endpoints (revoke, convert-to-subtechnique, + convert-to-technique). Provides full visibility into every object that was created, + modified, deprecated, or deleted as a consequence of the request. + required: + - workflow + - primary + - sideEffects + - warnings + properties: + workflow: + type: string + description: 'Discriminator identifying which workflow produced this response.' + enum: + - revoke + - convert-to-subtechnique + - convert-to-technique + example: 'revoke' + primary: + type: object + description: 'The primary object that the user acted on, in its post-workflow state.' + properties: + workspace: + $ref: 'workspace.yml#/components/schemas/workspace' + stix: + $ref: 'stix-common.yml#/components/schemas/stix-common' + sideEffects: + $ref: '#/components/schemas/side-effects' + warnings: + type: array + description: 'Non-fatal warnings generated during the workflow. Each warning is a structured object with a message field and additional context fields.' + items: + type: object + required: + - message + properties: + message: + type: string + description: 'Human-readable summary of the warning.' + additionalProperties: true + example: [] + + side-effects: + type: object + description: 'Objects created, modified, deprecated, or deleted as a consequence of the workflow.' + required: + - created + - modified + - deprecated + - deleted + properties: + created: + type: array + description: 'Objects created during the workflow (e.g., revoked-by relationships, subtechnique-of relationships, transferred relationships).' + items: + type: object + properties: + workspace: + $ref: 'workspace.yml#/components/schemas/workspace' + stix: + $ref: 'stix-common.yml#/components/schemas/stix-common' + modified: + type: array + description: 'Objects modified (new version saved) during the workflow.' + items: + type: object + properties: + workspace: + $ref: 'workspace.yml#/components/schemas/workspace' + stix: + $ref: 'stix-common.yml#/components/schemas/stix-common' + deprecated: + type: array + description: 'Objects deprecated (new version saved with x_mitre_deprecated=true) during the workflow.' + items: + type: object + properties: + workspace: + $ref: 'workspace.yml#/components/schemas/workspace' + stix: + $ref: 'stix-common.yml#/components/schemas/stix-common' + deleted: + type: object + description: 'Summary of hard-deleted objects (if any).' + required: + - count + - stixIds + properties: + count: + type: integer + description: 'Number of hard-deleted objects.' + example: 0 + stixIds: + type: array + description: 'STIX IDs of hard-deleted objects.' + items: + type: string + example: [] diff --git a/app/api/definitions/components/workspace.yml b/app/api/definitions/components/workspace.yml index afe3a51a..32051cbf 100644 --- a/app/api/definitions/components/workspace.yml +++ b/app/api/definitions/components/workspace.yml @@ -9,15 +9,14 @@ components: properties: state: type: string - enum: ['work-in-progress', 'awaiting-review', 'reviewed', 'static'] - attackId: - type: string - example: 'T9999' + enum: ['work-in-progress', 'awaiting-review', 'reviewed', 'static', 'draft'] collections: type: array items: $ref: '#/components/schemas/collection_reference' - + attack_id: + type: string + description: 'ATT&CK ID (e.g., T1234, G0001). When creating a new version of an existing object, this must match the existing attack_id. When creating a new object, this field is generated by the backend and cannot be set.' collection_reference: type: object properties: diff --git a/app/api/definitions/openapi.yml b/app/api/definitions/openapi.yml index 7d693c76..e66e7b04 100644 --- a/app/api/definitions/openapi.yml +++ b/app/api/definitions/openapi.yml @@ -66,12 +66,25 @@ tags: description: 'Operations on user accounts' - name: 'Health Check' description: 'Operations on system status' + - name: 'Reports' + description: 'Operations for generating analytical reports on ATT&CK data' + - name: 'Release Tracks' + description: 'Operations on release tracks (versioned STIX object releases with workflow management)' + +components: + parameters: + dryRun: + $ref: 'components/query-parameters.yml#/components/parameters/dryRun' paths: # ATT&CK Objects /api/attack-objects: $ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects' + # Generate next available ATT&CK ID + /api/attack-objects/attack-id/next: + $ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects~1attack-id~1next' + # Techniques /api/techniques: $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques' @@ -82,6 +95,15 @@ paths: /api/techniques/{stixId}/modified/{modified}: $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques~1{stixId}~1modified~1{modified}' + /api/techniques/{stixId}/convert-to-subtechnique: + $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques~1{stixId}~1convert-to-subtechnique' + + /api/techniques/{stixId}/convert-to-technique: + $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques~1{stixId}~1convert-to-technique' + + /api/techniques/{stixId}/revoke: + $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques~1{stixId}~1revoke' + /api/techniques/{stixId}/modified/{modified}/tactics: $ref: 'paths/techniques-paths.yml#/paths/~1api~1techniques~1{stixId}~1modified~1{modified}~1tactics' @@ -98,6 +120,9 @@ paths: /api/tactics/{stixId}/modified/{modified}/techniques: $ref: 'paths/tactics-paths.yml#/paths/~1api~1tactics~1{stixId}~1modified~1{modified}~1techniques' + /api/tactics/{stixId}/revoke: + $ref: 'paths/tactics-paths.yml#/paths/~1api~1tactics~1{stixId}~1revoke' + # Groups /api/groups: $ref: 'paths/groups-paths.yml#/paths/~1api~1groups' @@ -108,6 +133,9 @@ paths: /api/groups/{stixId}/modified/{modified}: $ref: 'paths/groups-paths.yml#/paths/~1api~1groups~1{stixId}~1modified~1{modified}' + /api/groups/{stixId}/revoke: + $ref: 'paths/groups-paths.yml#/paths/~1api~1groups~1{stixId}~1revoke' + # Campaigns /api/campaigns: $ref: 'paths/campaigns-paths.yml#/paths/~1api~1campaigns' @@ -118,6 +146,9 @@ paths: /api/campaigns/{stixId}/modified/{modified}: $ref: 'paths/campaigns-paths.yml#/paths/~1api~1campaigns~1{stixId}~1modified~1{modified}' + /api/campaigns/{stixId}/revoke: + $ref: 'paths/campaigns-paths.yml#/paths/~1api~1campaigns~1{stixId}~1revoke' + # Matrices /api/matrices: $ref: 'paths/matrices-paths.yml#/paths/~1api~1matrices' @@ -131,6 +162,9 @@ paths: /api/matrices/{stixId}/modified/{modified}/techniques: $ref: 'paths/matrices-paths.yml#/paths/~1api~1matrices~1{stixId}~1modified~1{modified}~1techniques' + /api/matrices/{stixId}/revoke: + $ref: 'paths/matrices-paths.yml#/paths/~1api~1matrices~1{stixId}~1revoke' + # Identities /api/identities: $ref: 'paths/identities-paths.yml#/paths/~1api~1identities' @@ -158,6 +192,9 @@ paths: /api/software/{stixId}/modified/{modified}: $ref: 'paths/software-paths.yml#/paths/~1api~1software~1{stixId}~1modified~1{modified}' + /api/software/{stixId}/revoke: + $ref: 'paths/software-paths.yml#/paths/~1api~1software~1{stixId}~1revoke' + # Mitigations /api/mitigations: $ref: 'paths/mitigations-paths.yml#/paths/~1api~1mitigations' @@ -168,6 +205,9 @@ paths: /api/mitigations/{stixId}/modified/{modified}: $ref: 'paths/mitigations-paths.yml#/paths/~1api~1mitigations~1{stixId}~1modified~1{modified}' + /api/mitigations/{stixId}/revoke: + $ref: 'paths/mitigations-paths.yml#/paths/~1api~1mitigations~1{stixId}~1revoke' + # Data Sources /api/data-sources: $ref: 'paths/data-sources-paths.yml#/paths/~1api~1data-sources' @@ -178,6 +218,9 @@ paths: /api/data-sources/{stixId}/modified/{modified}: $ref: 'paths/data-sources-paths.yml#/paths/~1api~1data-sources~1{stixId}~1modified~1{modified}' + /api/data-sources/{stixId}/revoke: + $ref: 'paths/data-sources-paths.yml#/paths/~1api~1data-sources~1{stixId}~1revoke' + # Analytics /api/analytics: $ref: 'paths/analytics-paths.yml#/paths/~1api~1analytics' @@ -214,6 +257,9 @@ paths: /api/data-components/{stixId}/modified/{modified}: $ref: 'paths/data-components-paths.yml#/paths/~1api~1data-components~1{stixId}~1modified~1{modified}' + /api/data-components/{stixId}/revoke: + $ref: 'paths/data-components-paths.yml#/paths/~1api~1data-components~1{stixId}~1revoke' + # Assets /api/assets: $ref: 'paths/assets-paths.yml#/paths/~1api~1assets' @@ -224,6 +270,9 @@ paths: /api/assets/{stixId}/modified/{modified}: $ref: 'paths/assets-paths.yml#/paths/~1api~1assets~1{stixId}~1modified~1{modified}' + /api/assets/{stixId}/revoke: + $ref: 'paths/assets-paths.yml#/paths/~1api~1assets~1{stixId}~1revoke' + # Relationships /api/relationships: $ref: 'paths/relationships-paths.yml#/paths/~1api~1relationships' @@ -276,6 +325,91 @@ paths: /api/stix-bundles: $ref: 'paths/stix-bundles-paths.yml#/paths/~1api~1stix-bundles' + # Release Tracks + /api/release-tracks/ephemeral/{domain}: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1ephemeral~1{domain}' + + /api/release-tracks: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks' + + /api/release-tracks/new: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1new' + + /api/release-tracks/new-from-bundle: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1new-from-bundle' + + /api/release-tracks/import: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1import' + + /api/release-tracks/{id}: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}' + + /api/release-tracks/{id}/meta: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1meta' + + /api/release-tracks/{id}/contents: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1contents' + + /api/release-tracks/{id}/clone: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1clone' + + /api/release-tracks/{id}/bump: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1bump' + + /api/release-tracks/{id}/bump/preview: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1bump~1preview' + + /api/release-tracks/{id}/candidates: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1candidates' + + /api/release-tracks/{id}/candidates/review: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1candidates~1review' + + /api/release-tracks/{id}/candidates/promote: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1candidates~1promote' + + /api/release-tracks/{id}/candidates/{objectRef}: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1candidates~1{objectRef}' + + /api/release-tracks/{id}/candidates/{objectRef}/update-version: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1candidates~1{objectRef}~1update-version' + + /api/release-tracks/{id}/staged: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1staged' + + /api/release-tracks/{id}/staged/demote: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1staged~1demote' + + /api/release-tracks/{id}/config: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1config' + + /api/release-tracks/{id}/objects/{objectRef}/versions: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1objects~1{objectRef}~1versions' + + /api/release-tracks/{id}/composition: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1composition' + + /api/release-tracks/{id}/snapshots/create: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1create' + + /api/release-tracks/{id}/snapshots/preview: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1preview' + + /api/release-tracks/{id}/snapshots/{modified}: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1{modified}' + + /api/release-tracks/{id}/snapshots/{modified}/meta: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1{modified}~1meta' + + /api/release-tracks/{id}/snapshots/{modified}/contents: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1{modified}~1contents' + + /api/release-tracks/{id}/snapshots/{modified}/clone: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1{modified}~1clone' + + /api/release-tracks/{id}/snapshots/{modified}/bump: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1snapshots~1{modified}~1bump' + # System Configuration /api/config/system-version: $ref: 'paths/system-configuration-paths.yml#/paths/~1api~1config~1system-version' @@ -348,6 +482,13 @@ paths: /api/recent-activity: $ref: 'paths/recent-activity-paths.yml#/paths/~1api~1recent-activity' + # Reports + /api/reports/link-by-id/missing: + $ref: 'paths/reports-paths.yml#/paths/~1api~1reports~1link-by-id~1missing' + + /api/reports/parallel-relationships: + $ref: 'paths/reports-paths.yml#/paths/~1api~1reports~1parallel-relationships' + # Health Checks /api/health/ping: $ref: 'paths/health-paths.yml#/paths/~1api~1health~1ping' diff --git a/app/api/definitions/paths/analytics-paths.yml b/app/api/definitions/paths/analytics-paths.yml index f1f22919..0e5e9b2c 100644 --- a/app/api/definitions/paths/analytics-paths.yml +++ b/app/api/definitions/paths/analytics-paths.yml @@ -119,12 +119,15 @@ paths: If the `stix.id` property is not set, it creates a new analytic, generating a STIX id for it. tags: - 'Analytics' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/analytics.yml#/components/schemas/analytic' + # type: object + $ref: '../components/analytics.yml#/components/schemas/analytic' # TODO delete after ADM integration complete responses: '201': description: 'The analytic has been successfully created.' @@ -253,12 +256,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/analytics.yml#/components/schemas/analytic' + # type: object + $ref: '../components/analytics.yml#/components/schemas/analytic' # TODO delete after ADM integration complete responses: '200': description: 'The analytic was updated.' diff --git a/app/api/definitions/paths/assets-paths.yml b/app/api/definitions/paths/assets-paths.yml index 5cec7e1f..5f8d1585 100644 --- a/app/api/definitions/paths/assets-paths.yml +++ b/app/api/definitions/paths/assets-paths.yml @@ -123,12 +123,15 @@ paths: If the `stix.id` property is not set, it creates a new asset, generating a STIX id for it. tags: - 'Assets' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/assets.yml#/components/schemas/asset' + # type: object + $ref: '../components/assets.yml#/components/schemas/asset' # TODO delete after ADM integration complete responses: '201': description: 'The asset has been successfully created.' @@ -247,12 +250,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/assets.yml#/components/schemas/asset' + # type: object + $ref: '../components/assets.yml#/components/schemas/asset' # TODO delete after ADM integration complete responses: '200': description: 'The asset was updated.' @@ -290,3 +295,56 @@ paths: description: 'The asset was successfully deleted.' '404': description: 'An asset with the requested STIX id and modified date was not found.' + + /api/assets/{stixId}/revoke: + post: + summary: 'Revoke a asset' + operationId: 'asset-revoke' + description: | + Revokes the asset identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Assets' + parameters: + - name: stixId + in: path + description: 'STIX id of the asset to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The asset was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The asset or revoking object was not found.' + '409': + description: 'The asset is already revoked.' diff --git a/app/api/definitions/paths/attack-objects-paths.yml b/app/api/definitions/paths/attack-objects-paths.yml index 13051727..ffb1e0e0 100644 --- a/app/api/definitions/paths/attack-objects-paths.yml +++ b/app/api/definitions/paths/attack-objects-paths.yml @@ -119,7 +119,80 @@ paths: - $ref: '../components/marking-definitions.yml#/components/schemas/marking-definition' - $ref: '../components/matrices.yml#/components/schemas/matrix' - $ref: '../components/mitigations.yml#/components/schemas/mitigation' - - $ref: '../components/relationships.yml#/components/schemas/relationship' - $ref: '../components/software.yml#/components/schemas/software' - $ref: '../components/tactics.yml#/components/schemas/tactic' - $ref: '../components/techniques.yml#/components/schemas/technique' + - $ref: '../components/analytics.yml#/components/schemas/analytic' + - $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' + - $ref: '../components/assets.yml#/components/schemas/asset' + - $ref: '../components/campaigns.yml#/components/schemas/campaign' + - $ref: '../components/data-components.yml#/components/schemas/data-component' + - $ref: '../components/data-sources.yml#/components/schemas/data-source' + + /api/attack-objects/attack-id/next: + get: + summary: 'Get the next available ATT&CK ID for a STIX type' + operationId: 'attack-object-get-next-attack-id' + description: | + This endpoint generates the next available ATT&CK ID for a given STIX type. + This is useful for previewing what ID will be assigned before creating a new object. + tags: + - 'ATT&CK Objects' + parameters: + - name: type + in: query + required: true + description: | + The STIX type for which to generate an ATT&CK ID. + Must be a type that supports ATT&CK IDs. + schema: + type: string + enum: + - x-mitre-tactic + - attack-pattern + - intrusion-set + - malware + - tool + - course-of-action + - x-mitre-data-source + - x-mitre-data-component + - x-mitre-asset + - campaign + - x-mitre-detection-strategy + - x-mitre-analytic + example: 'x-mitre-tactic' + - name: parentRef + in: query + required: false + description: | + The STIX ID of the parent technique (required for generating subtechnique IDs). + Only applicable when type is 'attack-pattern' and you want a subtechnique ID. + schema: + type: string + example: 'attack-pattern--12345678-1234-1234-1234-123456789abc' + responses: + '200': + description: 'The next available ATT&CK ID for the specified type.' + content: + application/json: + schema: + type: object + properties: + attack_id: + type: string + description: 'The next available ATT&CK ID' + example: 'TA0042' + '400': + description: 'Bad request - missing or invalid type parameter, or type does not support ATT&CK IDs' + content: + text/plain: + schema: + type: string + example: 'Missing required query parameter: type' + '500': + description: 'Server error - unable to generate ATT&CK ID' + content: + text/plain: + schema: + type: string + example: 'Unable to generate next ATT&CK ID. Server error.' diff --git a/app/api/definitions/paths/campaigns-paths.yml b/app/api/definitions/paths/campaigns-paths.yml index c1dca1e4..e643c3aa 100644 --- a/app/api/definitions/paths/campaigns-paths.yml +++ b/app/api/definitions/paths/campaigns-paths.yml @@ -99,12 +99,15 @@ paths: If the `stix.id` property is not set, it creates a new campaign, generating a STIX id for it. tags: - 'Campaigns' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/campaigns.yml#/components/schemas/campaign' + # type: object + $ref: '../components/campaigns.yml#/components/schemas/campaign' # TODO delete after ADM integration complete responses: '201': description: 'The campaign has been successfully created.' @@ -225,12 +228,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/campaigns.yml#/components/schemas/campaign' + # type: object + $ref: '../components/campaigns.yml#/components/schemas/campaign' # TODO delete after ADM integration complete responses: '200': description: 'The campaign was updated.' @@ -268,3 +273,56 @@ paths: description: 'The campaign was successfully deleted.' '404': description: 'A campaign with the requested STIX id and modified date was not found.' + + /api/campaigns/{stixId}/revoke: + post: + summary: 'Revoke a campaign' + operationId: 'campaign-revoke' + description: | + Revokes the campaign identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Campaigns' + parameters: + - name: stixId + in: path + description: 'STIX id of the campaign to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The campaign was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The campaign or revoking object was not found.' + '409': + description: 'The campaign is already revoked.' diff --git a/app/api/definitions/paths/collection-bundles-paths.yml b/app/api/definitions/paths/collection-bundles-paths.yml index fc774451..00c194d1 100644 --- a/app/api/definitions/paths/collection-bundles-paths.yml +++ b/app/api/definitions/paths/collection-bundles-paths.yml @@ -70,6 +70,14 @@ paths: schema: type: boolean default: false + - name: stream + in: query + description: | + Stream import progress updates using Server-Sent Events (SSE). + When enabled, the endpoint will stream progress events and the final result. + schema: + type: boolean + default: false - name: forceImport in: query description: | @@ -93,6 +101,14 @@ paths: - duplicate-collection - all example: 'attack-spec-version-violations' + - name: validateContents + in: query + description: | + If false, bypasses ATT&CK Data Model validation. Note that this may result in unexpected issues. + If true, validates the contents of the given STIX bundle using the ATT&CK Data Model. + schema: + type: boolean + default: true requestBody: required: true content: diff --git a/app/api/definitions/paths/collections-paths.yml b/app/api/definitions/paths/collections-paths.yml index 6b67e0cf..f2bcb30f 100644 --- a/app/api/definitions/paths/collections-paths.yml +++ b/app/api/definitions/paths/collections-paths.yml @@ -107,7 +107,8 @@ paths: content: application/json: schema: - $ref: '../components/collections.yml#/components/schemas/collection' + # type: object + $ref: '../components/collections.yml#/components/schemas/collection' # TODO delete after ADM integration complete responses: '201': description: 'The collection has been successfully created.' diff --git a/app/api/definitions/paths/data-components-paths.yml b/app/api/definitions/paths/data-components-paths.yml index 0932c453..74dbeb04 100644 --- a/app/api/definitions/paths/data-components-paths.yml +++ b/app/api/definitions/paths/data-components-paths.yml @@ -61,6 +61,18 @@ paths: type: string allowReserved: true example: 'windows' + - name: domain + in: query + description: | + Only return data components in the given domain. + This parameter may be set multiple times to retrieve data components in any of the provided domains. + schema: + oneOf: + - type: string + - type: array + items: + type: string + example: 'enterprise-attack' - name: lastUpdatedBy in: query description: | @@ -99,12 +111,15 @@ paths: If the `stix.id` property is not set, it creates a new data component, generating a STIX id for it. tags: - 'Data Components' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/data-components.yml#/components/schemas/data-component' + # type: object + $ref: '../components/data-components.yml#/components/schemas/data-component' # TODO delete after ADM integration complete responses: '201': description: 'The data component has been successfully created.' @@ -279,12 +294,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/data-components.yml#/components/schemas/data-component' + # type: object + $ref: '../components/data-components.yml#/components/schemas/data-component' # TODO delete after ADM integration complete responses: '200': description: 'The data component was updated.' @@ -322,3 +339,56 @@ paths: description: 'The data component was successfully deleted.' '404': description: 'A data component with the requested STIX id and modified date was not found.' + + /api/data-components/{stixId}/revoke: + post: + summary: 'Revoke a data component' + operationId: 'data-component-revoke' + description: | + Revokes the data component identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Data Components' + parameters: + - name: stixId + in: path + description: 'STIX id of the data component to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The data component was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The data component or revoking object was not found.' + '409': + description: 'The data component is already revoked.' diff --git a/app/api/definitions/paths/data-sources-paths.yml b/app/api/definitions/paths/data-sources-paths.yml index 1d9b5133..2b609476 100644 --- a/app/api/definitions/paths/data-sources-paths.yml +++ b/app/api/definitions/paths/data-sources-paths.yml @@ -123,12 +123,15 @@ paths: If the `stix.id` property is not set, it creates a new data source, generating a STIX id for it. tags: - 'Data Sources' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/data-sources.yml#/components/schemas/data-source' + # type: object + $ref: '../components/data-sources.yml#/components/schemas/data-source' # TODO delete after ADM integration complete responses: '201': description: 'The data source has been successfully created.' @@ -263,12 +266,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/data-sources.yml#/components/schemas/data-source' + # type: object + $ref: '../components/data-sources.yml#/components/schemas/data-source' # TODO delete after ADM integration complete responses: '200': description: 'The data source was updated.' @@ -306,3 +311,56 @@ paths: description: 'The data source was successfully deleted.' '404': description: 'A data source with the requested STIX id and modified date was not found.' + + /api/data-sources/{stixId}/revoke: + post: + summary: 'Revoke a data source' + operationId: 'data-source-revoke' + description: | + Revokes the data source identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Data Sources' + parameters: + - name: stixId + in: path + description: 'STIX id of the data source to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The data source was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The data source or revoking object was not found.' + '409': + description: 'The data source is already revoked.' diff --git a/app/api/definitions/paths/detection-strategies-paths.yml b/app/api/definitions/paths/detection-strategies-paths.yml index bfc81004..9ff84498 100644 --- a/app/api/definitions/paths/detection-strategies-paths.yml +++ b/app/api/definitions/paths/detection-strategies-paths.yml @@ -111,12 +111,15 @@ paths: If the `stix.id` property is not set, it creates a new detection strategy, generating a STIX id for it. tags: - 'Detection Strategies' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' + # type: object + $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' # TODO delete after ADM integration complete responses: '201': description: 'The detection strategy has been successfully created.' @@ -237,12 +240,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' + # type: object + $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' # TODO delete after ADM integration complete responses: '200': description: 'The detection strategy was updated.' diff --git a/app/api/definitions/paths/groups-paths.yml b/app/api/definitions/paths/groups-paths.yml index b42c007b..4b736e37 100644 --- a/app/api/definitions/paths/groups-paths.yml +++ b/app/api/definitions/paths/groups-paths.yml @@ -99,12 +99,15 @@ paths: If the `stix.id` property is not set, it creates a new group, generating a STIX id for it. tags: - 'Groups' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/groups.yml#/components/schemas/group' + # type: object + $ref: '../components/groups.yml#/components/schemas/group' # TODO delete after ADM integration complete responses: '201': description: 'The group has been successfully created.' @@ -225,12 +228,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/groups.yml#/components/schemas/group' + # type: object + $ref: '../components/groups.yml#/components/schemas/group' # TODO delete after ADM integration complete responses: '200': description: 'The group was updated.' @@ -268,3 +273,56 @@ paths: description: 'The group was successfully deleted.' '404': description: 'A group with the requested STIX id and modified date was not found.' + + /api/groups/{stixId}/revoke: + post: + summary: 'Revoke a group' + operationId: 'group-revoke' + description: | + Revokes the group identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Groups' + parameters: + - name: stixId + in: path + description: 'STIX id of the group to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The group was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The group or revoking object was not found.' + '409': + description: 'The group is already revoked.' diff --git a/app/api/definitions/paths/identities-paths.yml b/app/api/definitions/paths/identities-paths.yml index 3e14729e..edca01cf 100644 --- a/app/api/definitions/paths/identities-paths.yml +++ b/app/api/definitions/paths/identities-paths.yml @@ -80,12 +80,15 @@ paths: If the `stix.id` property is not set, it creates a new identity, generating a STIX id for it. tags: - 'Identities' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/identities.yml#/components/schemas/identity' + # type: object + $ref: '../components/identities.yml#/components/schemas/identity' # TODO delete after ADM integration complete responses: '201': description: 'The identity has been successfully created.' @@ -204,12 +207,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/identities.yml#/components/schemas/identity' + # type: object + $ref: '../components/identities.yml#/components/schemas/identity' # TODO delete after ADM integration complete responses: '200': description: 'The identity was updated.' diff --git a/app/api/definitions/paths/marking-definitions-paths.yml b/app/api/definitions/paths/marking-definitions-paths.yml index fdae62ea..a12814cb 100644 --- a/app/api/definitions/paths/marking-definitions-paths.yml +++ b/app/api/definitions/paths/marking-definitions-paths.yml @@ -72,6 +72,8 @@ paths: The `stix.id` property should not be set; this endpoint will create a new marking definition, generating a STIX id for it. tags: - 'Marking Definitions' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: @@ -129,6 +131,7 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: diff --git a/app/api/definitions/paths/matrices-paths.yml b/app/api/definitions/paths/matrices-paths.yml index 237b3219..f7f84956 100644 --- a/app/api/definitions/paths/matrices-paths.yml +++ b/app/api/definitions/paths/matrices-paths.yml @@ -99,12 +99,15 @@ paths: If the `stix.id` property is not set, it creates a new matrix, generating a STIX id for it. tags: - 'Matrices' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/matrices.yml#/components/schemas/matrix' + # type: object + $ref: '../components/matrices.yml#/components/schemas/matrix' # TODO delete after ADM integration complete responses: '201': description: 'The matrix has been successfully created.' @@ -225,12 +228,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/matrices.yml#/components/schemas/matrix' + # type: object + $ref: '../components/matrices.yml#/components/schemas/matrix' # TODO delete after ADM integration complete responses: '200': description: 'The matrix was updated.' @@ -295,3 +300,56 @@ paths: description: 'The techniques and subtechniques of a matrix matching the STIX id and modified date.' '404': description: 'A matrix with the requested STIX id and modified date was not found.' + + /api/matrices/{stixId}/revoke: + post: + summary: 'Revoke a matrix' + operationId: 'matrix-revoke' + description: | + Revokes the matrix identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Matrices' + parameters: + - name: stixId + in: path + description: 'STIX id of the matrix to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The matrix was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The matrix or revoking object was not found.' + '409': + description: 'The matrix is already revoked.' diff --git a/app/api/definitions/paths/mitigations-paths.yml b/app/api/definitions/paths/mitigations-paths.yml index de7e83c1..4189babd 100644 --- a/app/api/definitions/paths/mitigations-paths.yml +++ b/app/api/definitions/paths/mitigations-paths.yml @@ -111,12 +111,15 @@ paths: If the `stix.id` property is not set, it creates a new mitigation, generating a STIX id for it. tags: - 'Mitigations' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/mitigations.yml#/components/schemas/mitigation' + # type: object + $ref: '../components/mitigations.yml#/components/schemas/mitigation' # TODO delete after ADM integration complete responses: '201': description: 'The mitigation has been successfully created.' @@ -237,12 +240,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/mitigations.yml#/components/schemas/mitigation' + # type: object + $ref: '../components/mitigations.yml#/components/schemas/mitigation' # TODO delete after ADM integration complete responses: '200': description: 'The mitigation was updated.' @@ -280,3 +285,56 @@ paths: description: 'The mitigation was successfully deleted.' '404': description: 'A mitigation with the requested STIX id and modified date was not found.' + + /api/mitigations/{stixId}/revoke: + post: + summary: 'Revoke a mitigation' + operationId: 'mitigation-revoke' + description: | + Revokes the mitigation identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Mitigations' + parameters: + - name: stixId + in: path + description: 'STIX id of the mitigation to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The mitigation was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The mitigation or revoking object was not found.' + '409': + description: 'The mitigation is already revoked.' diff --git a/app/api/definitions/paths/notes-paths.yml b/app/api/definitions/paths/notes-paths.yml index 496ac818..c246d8de 100644 --- a/app/api/definitions/paths/notes-paths.yml +++ b/app/api/definitions/paths/notes-paths.yml @@ -98,6 +98,8 @@ paths: If the `stix.id` property is not set, it creates a new note, generating a STIX id for it. tags: - 'Notes' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: @@ -224,6 +226,7 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: diff --git a/app/api/definitions/paths/recent-activity-paths.yml b/app/api/definitions/paths/recent-activity-paths.yml index c4c98466..069baa29 100644 --- a/app/api/definitions/paths/recent-activity-paths.yml +++ b/app/api/definitions/paths/recent-activity-paths.yml @@ -76,3 +76,9 @@ paths: - $ref: '../components/software.yml#/components/schemas/software' - $ref: '../components/tactics.yml#/components/schemas/tactic' - $ref: '../components/techniques.yml#/components/schemas/technique' + - $ref: '../components/analytics.yml#/components/schemas/analytic' + - $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' + - $ref: '../components/assets.yml#/components/schemas/asset' + - $ref: '../components/campaigns.yml#/components/schemas/campaign' + - $ref: '../components/data-components.yml#/components/schemas/data-component' + - $ref: '../components/data-sources.yml#/components/schemas/data-source' diff --git a/app/api/definitions/paths/relationships-paths.yml b/app/api/definitions/paths/relationships-paths.yml index 85476bce..e25940fc 100644 --- a/app/api/definitions/paths/relationships-paths.yml +++ b/app/api/definitions/paths/relationships-paths.yml @@ -169,12 +169,15 @@ paths: If the `stix.id` property is not set, it creates a new relationship, generating a STIX id for it. tags: - 'Relationships' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/relationships.yml#/components/schemas/relationship' + # type: object + $ref: '../components/relationships.yml#/components/schemas/relationship' # TODO delete after ADM integration complete responses: '201': description: 'The relationship has been successfully created.' @@ -295,12 +298,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/relationships.yml#/components/schemas/relationship' + # type: object + $ref: '../components/relationships.yml#/components/schemas/relationship' # TODO delete after ADM integration complete responses: '200': description: 'The relationship was updated.' diff --git a/app/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml new file mode 100644 index 00000000..dd67ea65 --- /dev/null +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -0,0 +1,871 @@ +paths: + # ============================================================================= + # Ephemeral bundles + # ============================================================================= + /api/release-tracks/ephemeral/{domain}: + get: + summary: 'Generate an ephemeral bundle for a domain' + operationId: 'release-tracks-ephemeral-get' + description: | + Generate a stateless bundle containing all objects from a given ATT&CK domain. + This endpoint queries all STIX repositories by domain without persisting a release track. + tags: + - 'Release Tracks' + parameters: + - name: domain + in: path + required: true + description: 'ATT&CK domain (e.g., enterprise-attack, mobile-attack, ics-attack)' + schema: + type: string + example: 'enterprise-attack' + - name: format + in: query + description: 'Output format (bundle, workbench, or filesystem-store)' + schema: + type: string + enum: + - bundle + - workbench + - filesystem-store + default: bundle + responses: + '200': + description: 'Ephemeral bundle generated successfully' + '501': + description: 'Not yet implemented' + + # ============================================================================= + # Track management + # ============================================================================= + /api/release-tracks: + get: + summary: 'List all release tracks' + operationId: 'release-tracks-list' + description: | + Retrieve a paginated list of release track registry entries. + Returns metadata about each track (not full snapshots). + tags: + - 'Release Tracks' + parameters: + - name: type + in: query + description: 'Filter by track type' + schema: + type: string + enum: + - standard + - virtual + - name: search + in: query + description: 'Search by name or description (case-insensitive)' + schema: + type: string + - name: limit + in: query + description: 'Number of tracks to return (0 for all)' + schema: + type: number + default: 0 + - name: offset + in: query + description: 'Number of tracks to skip' + schema: + type: number + default: 0 + responses: + '200': + description: 'List of release tracks' + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-registry' + pagination: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + + /api/release-tracks/new: + post: + summary: 'Create a new release track' + operationId: 'release-tracks-create' + description: | + Create a new standard or virtual release track with an initial empty draft snapshot. + Request body is validated via Zod (not OpenAPI). See controller for schema. + tags: + - 'Release Tracks' + # Request body validation moved to Zod in controller + responses: + '201': + description: 'Release track created successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + '400': + description: 'Invalid request parameters' + + /api/release-tracks/new-from-bundle: + post: + summary: 'Create a release track from a STIX bundle' + operationId: 'release-tracks-create-from-bundle' + description: | + Parse a STIX bundle and create a new release track with member objects extracted from x_mitre_contents. + Request body validation moved to Zod in controller. + tags: + - 'Release Tracks' + responses: + '201': + description: 'Release track created from bundle' + '501': + description: 'Not yet implemented' + + /api/release-tracks/import: + post: + summary: 'Import a release track' + operationId: 'release-tracks-import' + description: | + Import a release track with all snapshots and version history. + Request body validation moved to Zod in controller. + tags: + - 'Release Tracks' + responses: + '201': + description: 'Release track imported successfully' + '501': + description: 'Not yet implemented' + + # ============================================================================= + # Track retrieval and deletion + # ============================================================================= + /api/release-tracks/{id}: + get: + summary: 'Get the latest snapshot of a release track' + operationId: 'release-tracks-get-latest' + description: | + Retrieve the most recent snapshot for a release track. + By default returns only members; use include query param for other tiers. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + description: 'Release track ID' + schema: + type: string + example: 'release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890' + - name: include + in: query + description: 'Which tiers to include in response' + schema: + type: string + enum: + - members + - staged + - candidates + - all + default: members + - name: format + in: query + description: 'Output format: snapshot (raw), bundle (STIX 2.1), workbench (with metadata), filesystemstore (not implemented)' + schema: + type: string + enum: + - snapshot + - bundle + - workbench + - filesystemstore + default: snapshot + responses: + '200': + description: 'Latest snapshot retrieved successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + '404': + description: 'Release track not found' + + delete: + summary: 'Delete a release track' + operationId: 'release-tracks-delete' + description: | + Delete an entire release track including all snapshots and version history. + This operation cannot be undone. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + description: 'Release track ID' + schema: + type: string + responses: + '204': + description: 'Release track deleted successfully' + '404': + description: 'Release track not found' + + # ============================================================================= + # Latest snapshot operations + # ============================================================================= + /api/release-tracks/{id}/meta: + post: + summary: 'Update metadata on the latest snapshot' + operationId: 'release-tracks-update-meta-latest' + description: | + Update name, description, or object_marking_refs on the latest snapshot. + Creates a new snapshot clone with updated metadata. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Metadata updated successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + + /api/release-tracks/{id}/contents: + post: + summary: 'Update member contents on the latest snapshot' + operationId: 'release-tracks-update-contents-latest' + description: | + Replace the members tier with new contents (x_mitre_contents format). + Creates a new snapshot clone. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Contents updated successfully' + + /api/release-tracks/{id}/clone: + post: + summary: 'Clone the latest snapshot into a new release track' + operationId: 'release-tracks-clone-latest' + description: | + Create a new release track by cloning the latest snapshot. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '201': + description: 'Release track cloned successfully' + + /api/release-tracks/{id}/bump: + post: + summary: 'Tag the latest snapshot (create a release)' + operationId: 'release-tracks-bump-latest' + description: | + Tag the latest snapshot with a version number. + For standard tracks: promotes staged → members. + For virtual tracks: N/A (already resolved). + Request body validated via Zod in controller: { type: 'major'|'minor', version?: string, dry_run?: boolean } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Snapshot tagged successfully' + '409': + description: 'Snapshot already tagged or conflict during promotion' + '501': + description: 'Not yet implemented' + + /api/release-tracks/{id}/bump/preview: + get: + summary: 'Preview the next release' + operationId: 'release-tracks-bump-preview' + description: | + Compute what the next tagged release will contain without persisting changes. + Shows which objects will be promoted from staged → members. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: format + in: query + schema: + type: string + enum: + - summary + - detailed + default: summary + responses: + '200': + description: 'Release preview generated' + '501': + description: 'Not yet implemented' + + # ============================================================================= + # Candidate management + # ============================================================================= + /api/release-tracks/{id}/candidates: + get: + summary: 'List candidates in the latest snapshot' + operationId: 'release-tracks-candidates-list' + description: | + Retrieve the candidates tier from the latest snapshot. + Optionally filter by workflow status. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: status + in: query + description: 'Filter by workflow status' + schema: + type: string + enum: + - work-in-progress + - awaiting-review + - reviewed + responses: + '200': + description: 'Candidates retrieved successfully' + content: + application/json: + schema: + type: object + properties: + candidates: + type: array + items: + $ref: '../components/release-tracks.yml#/components/schemas/candidate-entry' + + post: + summary: 'Add objects as candidates' + operationId: 'release-tracks-candidates-add' + description: | + Add one or more objects to the candidates tier. + If modified is omitted or 'latest', resolves to the latest version of the object. + If auto_promote is enabled and candidates meet the threshold, they are auto-promoted to staged. + Request body validated via Zod: { object_refs: Array } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Candidates added successfully' + + /api/release-tracks/{id}/candidates/review: + post: + summary: 'Bulk transition candidate workflow status' + operationId: 'release-tracks-candidates-review' + description: | + Transition candidates from one workflow status to another (forward-only). + If auto_promote is enabled and candidates meet the threshold after transition, they are auto-promoted to staged. + Request body validated via Zod: { from, to, object_refs? } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Candidates reviewed successfully' + + /api/release-tracks/{id}/candidates/promote: + post: + summary: 'Manually promote candidates to staged' + operationId: 'release-tracks-candidates-promote' + description: | + Manually promote specific candidates to the staged tier, bypassing auto-promotion logic. + Applies conflict resolution policy. + Request body validated via Zod: { object_refs: string[] } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Candidates promoted successfully' + + /api/release-tracks/{id}/candidates/{objectRef}: + delete: + summary: 'Remove a candidate' + operationId: 'release-tracks-candidates-remove' + description: | + Remove all entries for a given object_ref from the candidates tier. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: objectRef + in: path + required: true + description: 'STIX ID of the object to remove' + schema: + type: string + responses: + '200': + description: 'Candidate removed successfully' + '404': + description: 'Candidate not found' + + /api/release-tracks/{id}/candidates/{objectRef}/update-version: + post: + summary: 'Update the version pin of a candidate' + operationId: 'release-tracks-candidates-update-version' + description: | + Change which version of an object is being tracked in the candidates tier. + Request body validated via Zod: { old_modified, new_modified } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: objectRef + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Candidate version pin updated successfully' + + # ============================================================================= + # Staged objects + # ============================================================================= + /api/release-tracks/{id}/staged: + get: + summary: 'List staged objects in the latest snapshot' + operationId: 'release-tracks-staged-list' + description: | + Retrieve the staged tier from the latest snapshot. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Staged objects retrieved successfully' + content: + application/json: + schema: + type: object + properties: + staged: + type: array + items: + $ref: '../components/release-tracks.yml#/components/schemas/staged-entry' + + /api/release-tracks/{id}/staged/demote: + post: + summary: 'Demote staged objects back to candidates' + operationId: 'release-tracks-staged-demote' + description: | + Move objects from staged tier back to candidates tier. + Request body validated via Zod: { object_refs: Array<{id, modified}> } + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Staged objects demoted successfully' + + # ============================================================================= + # Configuration + # ============================================================================= + /api/release-tracks/{id}/config: + get: + summary: 'Get release track configuration' + operationId: 'release-tracks-config-get' + description: | + Retrieve the config sub-document from the latest snapshot. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Configuration retrieved successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/track-config' + + put: + summary: 'Update release track configuration' + operationId: 'release-tracks-config-update' + description: | + Merge configuration changes into the latest snapshot (creates new snapshot clone). + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Configuration updated successfully' + + # ============================================================================= + # Object version history + # ============================================================================= + /api/release-tracks/{id}/objects/{objectRef}/versions: + get: + summary: 'List all versions of an object across tiers' + operationId: 'release-tracks-object-versions' + description: | + Find all occurrences of a given object_ref across members, staged, and candidates tiers. + Shows which versions are tracked in which tiers. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: objectRef + in: path + required: true + description: 'STIX ID of the object' + schema: + type: string + responses: + '200': + description: 'Object versions retrieved successfully' + content: + application/json: + schema: + type: object + properties: + versions: + type: array + items: + type: object + properties: + tier: + type: string + enum: + - members + - staged + - candidates + object_ref: + type: string + object_modified: + type: string + format: date-time + object_status: + type: string + nullable: true + + # ============================================================================= + # Virtual track operations + # ============================================================================= + /api/release-tracks/{id}/composition: + put: + summary: 'Update virtual track composition' + operationId: 'release-tracks-composition-update' + description: | + Update which component tracks a virtual track aggregates. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Composition updated successfully' + '501': + description: 'Not yet implemented' + + /api/release-tracks/{id}/snapshots/create: + post: + summary: 'Create a virtual track snapshot' + operationId: 'release-tracks-virtual-snapshot-create' + description: | + Resolve component tracks and create a new virtual snapshot. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '201': + description: 'Virtual snapshot created successfully' + '501': + description: 'Not yet implemented' + + /api/release-tracks/{id}/snapshots/preview: + get: + summary: 'Preview a virtual track snapshot' + operationId: 'release-tracks-virtual-snapshot-preview' + description: | + Compute what a virtual snapshot would contain without persisting it. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Virtual snapshot preview generated' + '501': + description: 'Not yet implemented' + + # ============================================================================= + # Snapshot-specific operations + # ============================================================================= + /api/release-tracks/{id}/snapshots/{modified}: + get: + summary: 'Get a specific snapshot by modified timestamp' + operationId: 'release-tracks-snapshot-get' + description: | + Retrieve a historical snapshot identified by its modified timestamp. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + description: 'ISO 8601 timestamp' + schema: + type: string + - name: include + in: query + schema: + type: string + enum: + - members + - staged + - candidates + - all + default: members + - name: format + in: query + schema: + type: string + enum: + - snapshot + - bundle + - workbench + default: snapshot + responses: + '200': + description: 'Snapshot retrieved successfully' + '404': + description: 'Snapshot not found' + + delete: + summary: 'Delete a specific snapshot' + operationId: 'release-tracks-snapshot-delete' + description: | + Delete a snapshot by its modified timestamp. + Cannot delete tagged snapshots. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + responses: + '204': + description: 'Snapshot deleted successfully' + '400': + description: 'Cannot delete tagged snapshot' + '404': + description: 'Snapshot not found' + + /api/release-tracks/{id}/snapshots/{modified}/meta: + post: + summary: 'Update metadata on a specific snapshot' + operationId: 'release-tracks-update-meta-by-modified' + description: | + Update metadata on a historical snapshot. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Metadata updated successfully' + + /api/release-tracks/{id}/snapshots/{modified}/contents: + post: + summary: 'Update contents on a specific snapshot' + operationId: 'release-tracks-update-contents-by-modified' + description: | + Update member contents on a historical snapshot. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Contents updated successfully' + + /api/release-tracks/{id}/snapshots/{modified}/clone: + post: + summary: 'Clone a specific snapshot into a new release track' + operationId: 'release-tracks-clone-by-modified' + description: | + Create a new release track by cloning a historical snapshot. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + responses: + '201': + description: 'Release track cloned successfully' + + /api/release-tracks/{id}/snapshots/{modified}/bump: + post: + summary: 'Tag a specific snapshot' + operationId: 'release-tracks-bump-by-modified' + description: | + Tag a historical snapshot with a version number. + Request body validated via Zod in controller. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Snapshot tagged successfully' + '501': + description: 'Not yet implemented' diff --git a/app/api/definitions/paths/reports-paths.yml b/app/api/definitions/paths/reports-paths.yml new file mode 100644 index 00000000..bff1f68d --- /dev/null +++ b/app/api/definitions/paths/reports-paths.yml @@ -0,0 +1,97 @@ +paths: + /api/reports/link-by-id/missing: + get: + summary: 'Get objects with missing LinkById references' + operationId: 'reports-get-missing-linkbyid' + description: | + This endpoint generates a list of ATT&CK objects and/or relationships that directly + mention attack.mitre.org in their descriptions, indicating that they are likely missing + a reference using the (LinkById: X####) pattern. + tags: + - 'Reports' + parameters: + - name: type + in: query + description: | + Filter results by STIX type. If not specified, returns all types including relationships. + Use 'relationship' to get only relationships, or use any ATT&CK object type + (e.g., 'attack-pattern', 'x-mitre-tactic', 'intrusion-set', etc.) to filter by that type. + schema: + type: string + enum: + - relationship + - attack-pattern + - x-mitre-tactic + - intrusion-set + - malware + - tool + - course-of-action + - x-mitre-data-source + - x-mitre-data-component + - x-mitre-asset + - campaign + - x-mitre-matrix + - x-mitre-detection-strategy + - x-mitre-analytic + example: 'attack-pattern' + responses: + '200': + description: 'A list of objects with missing LinkById references.' + content: + application/json: + schema: + type: array + items: + anyOf: + - $ref: '../components/relationships.yml#/components/schemas/relationship' + - $ref: '../components/collections.yml#/components/schemas/collection' + - $ref: '../components/groups.yml#/components/schemas/group' + - $ref: '../components/identities.yml#/components/schemas/identity' + - $ref: '../components/marking-definitions.yml#/components/schemas/marking-definition' + - $ref: '../components/matrices.yml#/components/schemas/matrix' + - $ref: '../components/mitigations.yml#/components/schemas/mitigation' + - $ref: '../components/software.yml#/components/schemas/software' + - $ref: '../components/tactics.yml#/components/schemas/tactic' + - $ref: '../components/techniques.yml#/components/schemas/technique' + - $ref: '../components/analytics.yml#/components/schemas/analytic' + - $ref: '../components/detection-strategies.yml#/components/schemas/detection-strategy' + - $ref: '../components/assets.yml#/components/schemas/asset' + - $ref: '../components/campaigns.yml#/components/schemas/campaign' + - $ref: '../components/data-components.yml#/components/schemas/data-component' + - $ref: '../components/data-sources.yml#/components/schemas/data-source' + '500': + description: 'Server error' + content: + text/plain: + schema: + type: string + example: 'Unable to get objects with missing LinkById. Server error.' + + /api/reports/parallel-relationships: + get: + summary: 'Get parallel relationships' + operationId: 'reports-get-parallel-relationships' + description: | + This endpoint generates a map of lists of parallel relationships. Within each list, + the members share the same source_ref, target_ref, and relationship_type values. + This is useful for identifying potentially duplicate relationships. + tags: + - 'Reports' + responses: + '200': + description: 'A map of relationship keys to arrays of parallel relationships.' + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: '../components/relationships.yml#/components/schemas/relationship' + '500': + description: 'Server error' + content: + text/plain: + schema: + type: string + example: 'Unable to get parallel relationships. Server error.' diff --git a/app/api/definitions/paths/software-paths.yml b/app/api/definitions/paths/software-paths.yml index 922b869b..e8d1fe25 100644 --- a/app/api/definitions/paths/software-paths.yml +++ b/app/api/definitions/paths/software-paths.yml @@ -123,6 +123,8 @@ paths: If the `stix.id` property is not set, it creates a new software object, generating a STIX id for it. tags: - 'Software' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: @@ -249,6 +251,7 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: @@ -292,3 +295,56 @@ paths: description: 'The software object was successfully deleted.' '404': description: 'A software object with the requested STIX id and modified date was not found.' + + /api/software/{stixId}/revoke: + post: + summary: 'Revoke a software' + operationId: 'software-revoke' + description: | + Revokes the software identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Software' + parameters: + - name: stixId + in: path + description: 'STIX id of the software to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The software was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The software or revoking object was not found.' + '409': + description: 'The software is already revoked.' diff --git a/app/api/definitions/paths/tactics-paths.yml b/app/api/definitions/paths/tactics-paths.yml index 8ada2627..f402dfb9 100644 --- a/app/api/definitions/paths/tactics-paths.yml +++ b/app/api/definitions/paths/tactics-paths.yml @@ -111,12 +111,15 @@ paths: If the `stix.id` property is not set, it creates a new tactic, generating a STIX id for it. tags: - 'Tactics' + parameters: + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/tactics.yml#/components/schemas/tactic' + # type: object + $ref: '../components/tactics.yml#/components/schemas/tactic' # TODO delete after ADM integration complete responses: '201': description: 'The tactic has been successfully created.' @@ -237,12 +240,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/tactics.yml#/components/schemas/tactic' + # type: object + $ref: '../components/tactics.yml#/components/schemas/tactic' # TODO delete after ADM integration complete responses: '200': description: 'The tactic was updated.' @@ -337,3 +342,56 @@ paths: $ref: '../components/techniques.yml#/components/schemas/technique' '404': description: 'A tactic with the requested STIX id and modified date was not found.' + + /api/tactics/{stixId}/revoke: + post: + summary: 'Revoke a tactic' + operationId: 'tactic-revoke' + description: | + Revokes the tactic identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + tags: + - 'Tactics' + parameters: + - name: stixId + in: path + description: 'STIX id of the tactic to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The tactic was successfully revoked.' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The tactic or revoking object was not found.' + '409': + description: 'The tactic is already revoked.' diff --git a/app/api/definitions/paths/techniques-paths.yml b/app/api/definitions/paths/techniques-paths.yml index effaf3fb..918372ee 100644 --- a/app/api/definitions/paths/techniques-paths.yml +++ b/app/api/definitions/paths/techniques-paths.yml @@ -122,14 +122,28 @@ paths: This endpoint creates a new technique in the workspace. If the `stix.id` property is set, it creates a new version of an existing technique. If the `stix.id` property is not set, it creates a new technique, generating a STIX id for it. + + For subtechniques (when `x_mitre_is_subtechnique` is true), you must provide the `parentTechniqueId` query parameter + to specify the parent technique's ATT&CK ID. tags: - 'Techniques' + parameters: + - name: parentTechniqueId + in: query + description: | + Parent technique ATT&CK ID (e.g., T1234). Required when creating subtechniques (`x_mitre_is_subtechnique: true`). + schema: + type: string + pattern: '^T\d{4}$' + example: 'T1234' + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/techniques.yml#/components/schemas/technique' + # type: object + $ref: '../components/techniques.yml#/components/schemas/technique' # TODO delete after ADM integration complete responses: '201': description: 'The technique has been successfully created.' @@ -250,12 +264,14 @@ paths: required: true schema: type: string + - $ref: '../components/query-parameters.yml#/components/parameters/dryRun' requestBody: required: true content: application/json: schema: - $ref: '../components/techniques.yml#/components/schemas/technique' + # type: object + $ref: '../components/techniques.yml#/components/schemas/technique' # TODO delete after ADM integration complete responses: '200': description: 'The technique was updated.' @@ -350,3 +366,144 @@ paths: $ref: '../components/tactics.yml#/components/schemas/tactic' '404': description: 'A technique with the requested STIX id and modified date was not found.' + + /api/techniques/{stixId}/convert-to-subtechnique: + post: + summary: 'Convert a technique to a subtechnique' + operationId: 'technique-convert-to-subtechnique' + description: | + Converts the technique identified by the stixId path parameter into a subtechnique + of the specified parent technique. This operation: + - Generates a new subtechnique-format ATT&CK ID (e.g., T1234.001) + - Sets `x_mitre_is_subtechnique` to `true` + - Rebuilds the ATT&CK external reference with the new ID and URL + - Persists the result as a new version of the object + + The technique must not already be a subtechnique or revoked. + tags: + - 'Techniques' + parameters: + - name: stixId + in: path + description: 'STIX id of the technique to convert' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - parentTechniqueAttackId + properties: + parentTechniqueAttackId: + type: string + pattern: '^T\d{4}$' + description: 'ATT&CK ID of the parent technique (e.g., T1234)' + example: 'T1234' + responses: + '200': + description: 'The technique was successfully converted to a subtechnique.' + content: + application/json: + schema: + $ref: '../components/workflow-response.yml#/components/schemas/workflow-response' + '400': + description: 'Invalid request. The technique is already a subtechnique, is revoked, or the parent technique does not exist.' + '404': + description: 'The technique was not found.' + + /api/techniques/{stixId}/convert-to-technique: + post: + summary: 'Convert a subtechnique to a technique' + operationId: 'technique-convert-to-technique' + description: | + Converts the subtechnique identified by the stixId path parameter into a top-level technique. + This operation: + - Generates a new technique-format ATT&CK ID (e.g., T1235) + - Sets `x_mitre_is_subtechnique` to `false` + - Rebuilds the ATT&CK external reference with the new ID and URL + - Deprecates any existing subtechnique-of relationships + - Persists the result as a new version of the object + + The technique must currently be a subtechnique and must not be revoked. + tags: + - 'Techniques' + parameters: + - name: stixId + in: path + description: 'STIX id of the subtechnique to convert' + required: true + schema: + type: string + responses: + '200': + description: 'The subtechnique was successfully converted to a technique.' + content: + application/json: + schema: + $ref: '../components/workflow-response.yml#/components/schemas/workflow-response' + '400': + description: 'Invalid request. The object is not a subtechnique or is revoked.' + '404': + description: 'The technique was not found.' + + /api/techniques/{stixId}/revoke: + post: + summary: 'Revoke a technique' + operationId: 'technique-revoke' + description: | + Revokes the technique identified by the stixId path parameter in favor of the revoking object specified in the request body. + Optionally transfers relationships from the revoked object to the revoking object. + Deprecates all relationships referencing the revoked object. + tags: + - 'Techniques' + parameters: + - name: stixId + in: path + description: 'STIX id of the technique to revoke' + required: true + schema: + type: string + - name: preserveRelationships + in: query + description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deprecation.' + schema: + type: boolean + default: false + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - revoking + properties: + revoking: + type: object + required: + - stixId + - modified + properties: + stixId: + type: string + description: 'STIX id of the revoking (replacement) object' + modified: + type: string + description: 'Modified timestamp of the specific version of the revoking object' + responses: + '200': + description: 'The technique was successfully revoked.' + content: + application/json: + schema: + $ref: '../components/workflow-response.yml#/components/schemas/workflow-response' + '400': + description: 'Missing or invalid parameters.' + '404': + description: 'The technique or revoking object was not found.' + '409': + description: 'The technique is already revoked.' diff --git a/app/config/allowed-values.json b/app/config/allowed-values.json index a83a1720..22bb412c 100644 --- a/app/config/allowed-values.json +++ b/app/config/allowed-values.json @@ -30,7 +30,6 @@ "allowedValues": [ "Field Controller/RTU/PLC/IED", "Safety Instrumented System/Protection Relay", - "Device Configuration/Parameters", "Control Server", "Input/Output Server", "Data Historian", @@ -75,7 +74,6 @@ "allowedValues": [ "Field Controller/RTU/PLC/IED", "Safety Instrumented System/Protection Relay", - "Device Configuration/Parameters", "Control Server", "Input/Output Server", "Data Historian", @@ -305,7 +303,7 @@ "domains": [ { "domainName": "ics-attack", - "allowedValues": ["Windows", "Linux", "Network", "Embedded", "Cloud"] + "allowedValues": ["Windows", "Linux", "Network", "Embedded", "IaaS", "SaaS"] } ] } diff --git a/app/config/config.js b/app/config/config.js index 0345d229..74383761 100644 --- a/app/config/config.js +++ b/app/config/config.js @@ -10,6 +10,7 @@ const packageJson = require('../../package.json'); // - Restarting the server will force the users to login again // - Sessions cannot be shared across server instances // Setting the SESSION_SECRET environment variable will override this generated value + function generateSecret() { const stringBase = 'base64'; const byteLength = 48; @@ -21,6 +22,7 @@ function generateSecret() { const defaultSessionSecret = generateSecret(); const defaultTokenSigningSecret = generateSecret(); +const defaultMongoStoreCryptoSecret = generateSecret(); const userAuthnMechanismValues = ['anonymous', 'oidc']; convict.addFormat(enumFormat('user-authn-mechanism', userAuthnMechanismValues, true)); @@ -199,6 +201,20 @@ function loadConfig() { default: './app/api/definitions/openapi.yml', }, }, + validateRequests: { + withAttackDataModel: { + doc: 'Enable validation of POST and PUT request bodies using the ATT&CK Data Model', + format: Boolean, + default: true, + env: 'VALIDATE_WITH_ADM_SCHEMAS', + }, + withOpenApi: { + doc: 'Enable validation of POST and PUT request bodies using the legacy OpenAPI YAML-based validation schemas', + format: Boolean, + default: true, + env: 'VALIDATE_WITH_LEGACY_SCHEMAS', + }, + }, collectionIndex: { defaultInterval: { doc: 'How often collection indexes should check for updates (in seconds). Only applies to new indexes added to the REST API, does not affect existing collection indexes', @@ -222,12 +238,27 @@ function loadConfig() { default: './app/lib/default-static-marking-definitions/', env: 'WB_REST_STATIC_MARKING_DEFS_PATH', }, + staticBypassRulesPath: { + doc: 'Location of a JSON file containing default validation bypass rules to load at startup', + default: './app/lib/default-bypass-rules.json', + env: 'WB_REST_STATIC_BYPASS_RULES_PATH', + }, }, scheduler: { - checkWorkbenchInterval: { + syncCollectionIndexesCron: { doc: 'Sets the interval in seconds for starting the scheduler.', - default: 10, - env: 'CHECK_WORKBENCH_INTERVAL', + default: '* * * * *', // every minute + env: 'SYNC_COLLECTION_INDEXES_CRON', + }, + checkWipAttackIdsCron: { + doc: 'Cron pattern for checking WIP objects with ATT&CK IDs (e.g., "0 * * * *" for hourly).', + default: '0 * * * *', // every hour + env: 'CHECK_WIP_ATTACK_IDS_CRON', + }, + validateObjectsCron: { + doc: 'Cron pattern for re-validating all STIX objects against the ADM (e.g., "0 3 * * *" for daily at 3 AM).', + default: '0 3 * * *', // daily at 3 AM + env: 'VALIDATE_OBJECTS_CRON', }, enableScheduler: { format: Boolean, @@ -241,6 +272,11 @@ function loadConfig() { default: defaultSessionSecret, env: 'SESSION_SECRET', }, + mongoStoreCryptoSecret: { + doc: 'Secret used to encrypt session data in MongoDB', + default: defaultMongoStoreCryptoSecret, + env: 'MONGOSTORE_CRYPTO_SECRET', + }, }, userAuthn: { mechanism: { diff --git a/app/controllers/analytics-controller.js b/app/controllers/analytics-controller.js index feb72ad7..961a1a20 100644 --- a/app/controllers/analytics-controller.js +++ b/app/controllers/analytics-controller.js @@ -1,12 +1,8 @@ 'use strict'; -const analyticsService = require('../services/analytics-service'); +const analyticsService = require('../services/stix/analytics-service'); const logger = require('../lib/logger'); -const { - DuplicateIdError, - BadlyFormattedParameterError, - InvalidQueryStringParameterError, -} = require('../exceptions'); +const { BadlyFormattedParameterError, InvalidQueryStringParameterError } = require('../exceptions'); exports.retrieveAll = async function (req, res) { const options = { @@ -19,7 +15,8 @@ exports.retrieveAll = async function (req, res) { search: req.query.search, lastUpdatedBy: req.query.lastUpdatedBy, includePagination: req.query.includePagination, - includeRefs: req.query.includeRefs === 'true' || req.query.includeRefs === true, + includeEmbeddedRelationships: + req.query.includeRefs === 'true' || req.query.includeRefs === true, }; try { @@ -41,7 +38,8 @@ exports.retrieveAll = async function (req, res) { exports.retrieveById = async function (req, res) { const options = { versions: req.query.versions || 'latest', - includeRefs: req.query.includeRefs === 'true' || req.query.includeRefs === true, + includeEmbeddedRelationships: + req.query.includeRefs === 'true' || req.query.includeRefs === true, }; try { @@ -91,52 +89,50 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { +exports.create = async function (req, res, next) { // Get the data from the request const analyticData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the analytic try { const analytic = await analyticsService.create(analyticData, options); + if (options.dryRun) { + return res.status(200).send(analytic); + } logger.debug('Success: Created analytic with id ' + analytic.stix.id); return res.status(201).send(analytic); } catch (err) { - if (err instanceof DuplicateIdError) { - logger.warn('Duplicate stix.id and stix.modified'); - return res - .status(409) - .send('Unable to create analytic. Duplicate stix.id and stix.modified properties.'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create analytic. Server error.'); - } + // Pass the error to the service exception middleware + return next(err); } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const analyticData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the analytic try { const analytic = await analyticsService.updateFull( req.params.stixId, req.params.modified, analyticData, + options, ); if (!analytic) { return res.status(404).send('Analytic not found.'); - } else { - logger.debug('Success: Updated analytic with id ' + analytic.stix.id); + } + if (options.dryRun) { return res.status(200).send(analytic); } + logger.debug('Success: Updated analytic with id ' + analytic.stix.id); + return res.status(200).send(analytic); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update analytic. Server error.'); + // Pass the error to the service exception middleware + return next(err); } }; diff --git a/app/controllers/assets-controller.js b/app/controllers/assets-controller.js index f122c183..1de1f880 100644 --- a/app/controllers/assets-controller.js +++ b/app/controllers/assets-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const assetsService = require('../services/assets-service'); +const assetsService = require('../services/stix/assets-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -92,16 +92,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const assetData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const asset = await assetsService.create(assetData, options); + if (options.dryRun) { + return res.status(200).send(asset); + } logger.debug('Success: Created asset with id ' + asset.stix.id); return res.status(201).send(asset); } catch (err) { @@ -111,27 +114,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create asset. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create asset. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const assetData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { - const asset = await assetsService.updateFull(req.params.stixId, req.params.modified, assetData); + const asset = await assetsService.updateFull( + req.params.stixId, + req.params.modified, + assetData, + options, + ); if (!asset) { return res.status(404).send('Asset not found.'); - } else { - logger.debug('Success: Updated asset with id ' + asset.stix.id); + } + if (options.dryRun) { return res.status(200).send(asset); } + logger.debug('Success: Updated asset with id ' + asset.stix.id); + return res.status(200).send(asset); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update asset. Server error.'); + return next(err); } }; @@ -165,3 +173,17 @@ exports.deleteVersionById = async function (req, res) { return res.status(500).send('Unable to delete asset. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await assetsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/attack-objects-controller.js b/app/controllers/attack-objects-controller.js index 58d99d50..d86bf5dc 100644 --- a/app/controllers/attack-objects-controller.js +++ b/app/controllers/attack-objects-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const attackObjectsService = require('../services/attack-objects-service'); +const attackObjectsService = require('../services/stix/attack-objects-service'); const logger = require('../lib/logger'); exports.retrieveAll = async function (req, res) { diff --git a/app/controllers/campaigns-controller.js b/app/controllers/campaigns-controller.js index e0381655..ebcfd8b3 100644 --- a/app/controllers/campaigns-controller.js +++ b/app/controllers/campaigns-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const campaignsService = require('../services/campaigns-service'); +const campaignsService = require('../services/stix/campaigns-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -88,18 +88,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const campaignData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the campaign try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; - const campaign = await campaignsService.create(campaignData, options); + if (options.dryRun) { + return res.status(200).send(campaign); + } logger.debug('Success: Created campaign with id ' + campaign.stix.id); return res.status(201).send(campaign); } catch (err) { @@ -112,31 +113,32 @@ exports.create = async function (req, res) { logger.warn('Invalid stix.type'); return res.status(400).send('Unable to create campaign. stix.type must be campaign'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create campaign. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const campaignData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; + try { const campaign = await campaignsService.updateFull( req.params.stixId, req.params.modified, campaignData, + options, ); if (!campaign) { return res.status(404).send('Campaign not found.'); - } else { - logger.debug('Success: Updated campaign with id ' + campaign.stix.id); + } + if (options.dryRun) { return res.status(200).send(campaign); } + logger.debug('Success: Updated campaign with id ' + campaign.stix.id); + return res.status(200).send(campaign); } catch (err) { - // Create the campaign - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update campaign. Server error.'); + return next(err); } }; @@ -172,3 +174,17 @@ exports.deleteById = async function (req, res) { return res.status(500).send('Unable to delete campaign. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await campaignsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/collection-bundles-controller.js b/app/controllers/collection-bundles-controller.js index d6c60af5..665fdedc 100644 --- a/app/controllers/collection-bundles-controller.js +++ b/app/controllers/collection-bundles-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const collectionBundlesService = require('../services/collection-bundles-service'); +const collectionBundlesService = require('../services/stix/collection-bundles-service'); const logger = require('../lib/logger'); const availableForceImportParameters = [ @@ -25,13 +25,8 @@ function extractForceImportParameters(req) { return params; } -exports.importBundle = async function (req, res) { - // Get the data from the request - const collectionBundleData = req.body; - - const forceImportParameters = extractForceImportParameters(req); - - const errorResult = { +function createErrorResult() { + return { bundleErrors: { noCollection: false, moreThanOneCollection: false, @@ -46,6 +41,15 @@ exports.importBundle = async function (req, res) { errors: [], }, }; +} + +/** + * Validates the structure of a collection bundle + * @param {Object} collectionBundleData - The bundle data to validate + * @returns {Object} Validation result with { errorResult, errorFound, collections } + */ +function validateCollectionBundle(collectionBundleData) { + const errorResult = createErrorResult(); let errorFound = false; // Find the x-mitre-collection objects @@ -64,13 +68,14 @@ exports.importBundle = async function (req, res) { errorFound = true; } - // The collection must have an id. + // The collection must have an id if (collections.length > 0 && !collections[0].id) { logger.warn('Badly formatted collection in bundle, x-mitre-collection missing id.'); errorResult.bundleErrors.badlyFormattedCollection = true; errorFound = true; } + // Validate bundle content const validationResult = collectionBundlesService.validateBundle(collectionBundleData); if (validationResult.errors.length > 0) { errorFound = true; @@ -81,46 +86,165 @@ exports.importBundle = async function (req, res) { errorResult.objectErrors.summary.duplicateObjectInBundleCount = validationResult.duplicateObjectInBundleCount; } - if (validationResult.invalidAttackSpecVersionCount > 0) { logger.warn( - `Collection bundle has ${validationResult.invalidAttackSpecVersionCount} objects with invalid ATT&CK Spec Versions.`, + `Collection bundle has ${validationResult.invalidAttackSpecVersionCount} objects with invalid ATT&CK Spec version.`, ); errorResult.objectErrors.summary.invalidAttackSpecVersionCount = validationResult.invalidAttackSpecVersionCount; } - errorResult.objectErrors.errors.push(...validationResult.errors); } - if (errorFound) { - // Determine if any of the errors are overridden by the forceImport flag + return { errorResult, errorFound, collections }; +} + +/** + * Checks if validation errors should prevent import + * @param {Object} errorResult - The error result from validation + * @param {boolean} errorFound - Whether any errors were found + * @param {Array} forceImportParameters - Parameters to override validation errors + * @returns {boolean} True if import should be blocked + */ +function shouldBlockImport(errorResult, errorFound, forceImportParameters) { + if (!errorFound) { + return false; + } + + // These errors do not have forceImport flags yet + if ( + errorResult.bundleErrors.noCollection || + errorResult.bundleErrors.moreThanOneCollection || + errorResult.bundleErrors.badlyFormattedCollection || + errorResult.objectErrors.summary.duplicateObjectInBundleCount > 0 + ) { + return true; + } + + // Check the forceImport flag for overriding ATT&CK Spec version violations + if ( + errorResult.objectErrors.summary.invalidAttackSpecVersionCount > 0 && + !forceImportParameters.find( + (e) => e === collectionBundlesService.forceImportParameters.attackSpecVersionViolations, + ) + ) { + return true; + } + + return false; +} + +/** + * Stream import progress using Server-Sent Events (SSE) + */ +exports.streamImportBundle = async function (req, res) { + let heartbeatInterval; + + try { + const collectionBundleData = req.body; + const forceImportParameters = extractForceImportParameters(req); + + // Set up SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // Heartbeat to prevent connection timeout + heartbeatInterval = setInterval(() => { + if (!res.destroyed) { + res.write(': heartbeat\n\n'); + } + }, 30000); + + // Handle client disconnect + req.on('close', () => { + if (heartbeatInterval) clearInterval(heartbeatInterval); + }); + + // Validate bundle using shared validation logic + const { errorResult, errorFound, collections } = validateCollectionBundle(collectionBundleData); - // These errors do not have forceImport flags yet - if ( - errorResult.bundleErrors.noCollection || - errorResult.bundleErrors.moreThanOneCollection || - errorResult.bundleErrors.badlyFormattedCollection || - errorResult.objectErrors.summary.duplicateObjectInBundleCount > 0 - ) { + // Check if import should be blocked + if (shouldBlockImport(errorResult, errorFound, forceImportParameters)) { logger.error('Unable to import collection bundle due to an error in the bundle.'); - return res.status(400).send(errorResult); + const event = `event: error\ndata: ${JSON.stringify(errorResult)}\n\n`; + res.write(event); + res.end(); + return; } - // Check the forceImport flag for overriding ATT&CK Spec version violations - if ( - errorResult.objectErrors.summary.invalidAttackSpecVersionCount > 0 && - !forceImportParameters.find( - (e) => e === collectionBundlesService.forceImportParameters.attackSpecVersionViolations, - ) - ) { - logger.error('Unable to import collection bundle due to an error in the bundle.'); - return res.status(400).send(errorResult); + // Progress callback to send SSE events + const onProgress = (progress) => { + if (!res.destroyed) { + const event = `event: progress\ndata: ${JSON.stringify(progress)}\n\n`; + res.write(event); + // Flush immediately to ensure event is sent to client + if (res.flush && typeof res.flush === 'function') { + res.flush(); + } + } + }; + + const options = { + previewOnly: req.query.previewOnly || req.query.checkOnly, + forceImportParameters, + onProgress, + }; + + // Import the collection bundle + const importedCollection = await collectionBundlesService.importBundle( + collections[0], + collectionBundleData, + options, + ); + + // Send final result + if (!res.destroyed) { + const event = `event: complete\ndata: ${JSON.stringify(importedCollection)}\n\n`; + res.write(event); + res.end(); } + + logger.debug('Success: Imported collection with id ' + importedCollection.stix.id); + } catch (err) { + logger.error('Import failed with error: ' + err); + + if (heartbeatInterval) clearInterval(heartbeatInterval); + + if (!res.destroyed) { + const errorData = { + message: err.message || 'Unknown error', + error: err.toString(), + }; + const event = `event: error\ndata: ${JSON.stringify(errorData)}\n\n`; + res.write(event); + res.end(); + } + } finally { + if (heartbeatInterval) clearInterval(heartbeatInterval); + } +}; + +exports.importBundle = async function (req, res) { + // Get the data from the request + const collectionBundleData = req.body; + const forceImportParameters = extractForceImportParameters(req); + + // Validate bundle using shared validation logic + const { errorResult, errorFound, collections } = validateCollectionBundle(collectionBundleData); + + // Check if import should be blocked + if (shouldBlockImport(errorResult, errorFound, forceImportParameters)) { + logger.error('Unable to import collection bundle due to an error in the bundle.'); + return res.status(400).send(errorResult); } const options = { previewOnly: req.query.previewOnly || req.query.checkOnly, + validateContents: req.query.validateContents === 'true', forceImportParameters, }; diff --git a/app/controllers/collection-indexes-controller.js b/app/controllers/collection-indexes-controller.js index 151edc78..b9534946 100644 --- a/app/controllers/collection-indexes-controller.js +++ b/app/controllers/collection-indexes-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const collectionIndexService = require('../services/collection-indexes-service'); +const collectionIndexService = require('../services/stix/collection-indexes-service'); const logger = require('../lib/logger'); const { DuplicateIdError, BadlyFormattedParameterError } = require('../exceptions'); diff --git a/app/controllers/collections-controller.js b/app/controllers/collections-controller.js index 7acf4ef0..538a51a1 100644 --- a/app/controllers/collections-controller.js +++ b/app/controllers/collections-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const collectionsService = require('../services/collections-service'); +const collectionsService = require('../services/stix/collections-service'); const logger = require('../lib/logger'); const { BadlyFormattedParameterError, @@ -68,11 +68,6 @@ exports.retrieveVersionById = async function (req, res, next) { const { stixId, modified } = req.params; const { retrieveContents, stream } = req.query; - // Debug logging - console.log('[CONTROLLER] retrieveVersionById called'); - console.log('[CONTROLLER] stream param:', stream, typeof stream); - console.log('[CONTROLLER] retrieveContents param:', retrieveContents, typeof retrieveContents); - const options = { retrieveContents: retrieveContents === true || retrieveContents === 'true', }; @@ -80,12 +75,10 @@ exports.retrieveVersionById = async function (req, res, next) { // Use streaming if requested and contents are being retrieved // Fix: Check for string 'true' since query params are strings if ((stream === true || stream === 'true') && options.retrieveContents) { - console.log('[CONTROLLER] Delegating to streamVersionById'); return exports.streamVersionById(req, res, next); } // Otherwise use regular response - console.log('[CONTROLLER] Using regular response'); const collection = await collectionsService.retrieveVersionById(stixId, modified, options); if (!collection) { diff --git a/app/controllers/data-components-controller.js b/app/controllers/data-components-controller.js index 122476d3..f2401aa3 100644 --- a/app/controllers/data-components-controller.js +++ b/app/controllers/data-components-controller.js @@ -1,10 +1,9 @@ 'use strict'; -const dataComponentsService = require('../services/data-components-service'); +const dataComponentsService = require('../services/stix/data-components-service'); const logger = require('../lib/logger'); -const { DuplicateIdError } = require('../exceptions'); -exports.retrieveAll = async function (req, res) { +exports.retrieveAll = async function (req, res, next) { const options = { offset: req.query.offset || 0, limit: req.query.limit || 0, @@ -12,6 +11,7 @@ exports.retrieveAll = async function (req, res) { includeRevoked: req.query.includeRevoked, includeDeprecated: req.query.includeDeprecated, search: req.query.search, + domain: req.query.domain, lastUpdatedBy: req.query.lastUpdatedBy, includePagination: req.query.includePagination, }; @@ -27,12 +27,11 @@ exports.retrieveAll = async function (req, res) { } return res.status(200).send(results); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get data components. Server error.'); + next(err); } }; -exports.retrieveById = async function (req, res) { +exports.retrieveById = async function (req, res, next) { const options = { versions: req.query.versions || 'latest', }; @@ -48,12 +47,11 @@ exports.retrieveById = async function (req, res) { return res.status(200).send(dataComponents); } } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get data component. Server error.'); + next(err); } }; -exports.retrieveChannelsById = async function (req, res) { +exports.retrieveChannelsById = async function (req, res, next) { try { const dataComponents = await dataComponentsService.retrieveById(req.params.stixId, { versions: 'latest', @@ -68,12 +66,11 @@ exports.retrieveChannelsById = async function (req, res) { return res.status(200).send(channels); } } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get data components. Server error.'); + next(err); } }; -exports.retrieveLogSourcesById = async function (req, res) { +exports.retrieveLogSourcesById = async function (req, res, next) { try { const dataComponents = await dataComponentsService.retrieveById(req.params.stixId, { versions: 'latest', @@ -85,12 +82,11 @@ exports.retrieveLogSourcesById = async function (req, res) { return res.status(200).send(dataComponents[0].stix.x_mitre_log_sources); } } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get data components. Server error.'); + next(err); } }; -exports.retrieveVersionById = async function (req, res) { +exports.retrieveVersionById = async function (req, res, next) { try { const dataComponent = await dataComponentsService.retrieveVersionById( req.params.stixId, @@ -103,62 +99,56 @@ exports.retrieveVersionById = async function (req, res) { return res.status(200).send(dataComponent); } } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get data component. Server error.'); + next(err); } }; -exports.create = async function (req, res) { +exports.create = async function (req, res, next) { // Get the data from the request const dataComponentData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the data component try { const dataComponent = await dataComponentsService.create(dataComponentData, options); + if (options.dryRun) { + return res.status(200).send(dataComponent); + } logger.debug('Success: Created data component with id ' + dataComponent.stix.id); return res.status(201).send(dataComponent); } catch (err) { - if (err instanceof DuplicateIdError) { - logger.warn('Duplicate stix.id and stix.modified'); - return res - .status(409) - .send('Unable to create data component. Duplicate stix.id and stix.modified properties.'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create data component. Server error.'); - } + next(err); } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const dataComponentData = req.body; - - // Create the data component + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { const dataComponent = await dataComponentsService.updateFull( req.params.stixId, req.params.modified, dataComponentData, + options, ); if (!dataComponent) { return res.status(404).send('Data component not found.'); - } else { - logger.debug('Success: Updated data component with id ' + dataComponent.stix.id); + } + if (options.dryRun) { return res.status(200).send(dataComponent); } + logger.debug('Success: Updated data component with id ' + dataComponent.stix.id); + return res.status(200).send(dataComponent); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update data component. Server error.'); + next(err); } }; -exports.deleteVersionById = async function (req, res) { +exports.deleteVersionById = async function (req, res, next) { try { const dataComponent = await dataComponentsService.deleteVersionById( req.params.stixId, @@ -171,12 +161,11 @@ exports.deleteVersionById = async function (req, res) { return res.status(204).end(); } } catch (err) { - logger.error('Delete data component failed. ' + err); - return res.status(500).send('Unable to delete data component. Server error.'); + next(err); } }; -exports.deleteById = async function (req, res) { +exports.deleteById = async function (req, res, next) { try { const dataComponents = await dataComponentsService.deleteById(req.params.stixId); if (dataComponents.deletedCount === 0) { @@ -186,7 +175,20 @@ exports.deleteById = async function (req, res) { return res.status(204).end(); } } catch (err) { - logger.error('Delete data component failed. ' + err); - return res.status(500).send('Unable to delete data component. Server error.'); + next(err); + } +}; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await dataComponentsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); } }; diff --git a/app/controllers/data-sources-controller.js b/app/controllers/data-sources-controller.js index 4e2190f1..d7933d55 100644 --- a/app/controllers/data-sources-controller.js +++ b/app/controllers/data-sources-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const dataSourcesService = require('../services/data-sources-service'); +const dataSourcesService = require('../services/stix/data-sources-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -94,17 +94,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const dataSourceData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the data source try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const dataSource = await dataSourcesService.create(dataSourceData, options); + if (options.dryRun) { + return res.status(200).send(dataSource); + } logger.debug('Success: Created data source with id ' + dataSource.stix.id); return res.status(201).send(dataSource); } catch (err) { @@ -114,32 +116,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create data source. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create data source. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const dataSourceData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the data source try { const dataSource = await dataSourcesService.updateFull( req.params.stixId, req.params.modified, dataSourceData, + options, ); if (!dataSource) { return res.status(404).send('Data source not found.'); - } else { - logger.debug('Success: Updated data source with id ' + dataSource.stix.id); + } + if (options.dryRun) { return res.status(200).send(dataSource); } + logger.debug('Success: Updated data source with id ' + dataSource.stix.id); + return res.status(200).send(dataSource); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update data source. Server error.'); + return next(err); } }; @@ -175,3 +177,17 @@ exports.deleteById = async function (req, res) { return res.status(500).send('Unable to delete data source. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await dataSourcesService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/detection-strategies-controller.js b/app/controllers/detection-strategies-controller.js index 570a9ccd..6f62a273 100644 --- a/app/controllers/detection-strategies-controller.js +++ b/app/controllers/detection-strategies-controller.js @@ -1,14 +1,9 @@ 'use strict'; -const detectionStrategiesService = require('../services/detection-strategies-service'); +const detectionStrategiesService = require('../services/stix/detection-strategies-service'); const logger = require('../lib/logger'); -const { - DuplicateIdError, - BadlyFormattedParameterError, - InvalidQueryStringParameterError, -} = require('../exceptions'); -exports.retrieveAll = async function (req, res) { +exports.retrieveAll = async function (req, res, next) { const options = { offset: req.query.offset || 0, limit: req.query.limit || 0, @@ -32,12 +27,11 @@ exports.retrieveAll = async function (req, res) { } return res.status(200).send(results); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get detection strategies. Server error.'); + next(err); } }; -exports.retrieveById = async function (req, res) { +exports.retrieveById = async function (req, res, next) { const options = { versions: req.query.versions || 'latest', }; @@ -56,20 +50,11 @@ exports.retrieveById = async function (req, res) { return res.status(200).send(detectionStrategies); } } catch (err) { - if (err instanceof BadlyFormattedParameterError) { - logger.warn('Badly formatted stix id: ' + req.params.stixId); - return res.status(400).send('Stix id is badly formatted.'); - } else if (err instanceof InvalidQueryStringParameterError) { - logger.warn('Invalid query string: versions=' + req.query.versions); - return res.status(400).send('Query string parameter versions is invalid.'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get detection strategies. Server error.'); - } + next(err); } }; -exports.retrieveVersionById = async function (req, res) { +exports.retrieveVersionById = async function (req, res, next) { try { const detectionStrategy = await detectionStrategiesService.retrieveVersionById( req.params.stixId, @@ -82,71 +67,59 @@ exports.retrieveVersionById = async function (req, res) { return res.status(200).send(detectionStrategy); } } catch (err) { - if (err instanceof BadlyFormattedParameterError) { - logger.warn('Badly formatted stix id: ' + req.params.stixId); - return res.status(400).send('Stix id is badly formatted.'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get detection strategy. Server error.'); - } + next(err); } }; -exports.create = async function (req, res) { +exports.create = async function (req, res, next) { // Get the data from the request const detectionStrategyData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the detection strategy try { const detectionStrategy = await detectionStrategiesService.create( detectionStrategyData, options, ); + if (options.dryRun) { + return res.status(200).send(detectionStrategy); + } logger.debug('Success: Created detection strategy with id ' + detectionStrategy.stix.id); return res.status(201).send(detectionStrategy); } catch (err) { - if (err instanceof DuplicateIdError) { - logger.warn('Duplicate stix.id and stix.modified'); - return res - .status(409) - .send( - 'Unable to create detection strategy. Duplicate stix.id and stix.modified properties.', - ); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create detection strategy. Server error.'); - } + next(err); } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const detectionStrategyData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the detection strategy try { const detectionStrategy = await detectionStrategiesService.updateFull( req.params.stixId, req.params.modified, detectionStrategyData, + options, ); if (!detectionStrategy) { return res.status(404).send('Detection strategy not found.'); - } else { - logger.debug('Success: Updated detection strategy with id ' + detectionStrategy.stix.id); + } + if (options.dryRun) { return res.status(200).send(detectionStrategy); } + logger.debug('Success: Updated detection strategy with id ' + detectionStrategy.stix.id); + return res.status(200).send(detectionStrategy); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update detection strategy. Server error.'); + next(err); } }; -exports.deleteVersionById = async function (req, res) { +exports.deleteVersionById = async function (req, res, next) { try { const detectionStrategy = await detectionStrategiesService.deleteVersionById( req.params.stixId, @@ -159,12 +132,11 @@ exports.deleteVersionById = async function (req, res) { return res.status(204).end(); } } catch (err) { - logger.error('Delete detection strategy failed. ' + err); - return res.status(500).send('Unable to delete detection strategy. Server error.'); + next(err); } }; -exports.deleteById = async function (req, res) { +exports.deleteById = async function (req, res, next) { try { const detectionStrategies = await detectionStrategiesService.deleteById(req.params.stixId); if (detectionStrategies.deletedCount === 0) { @@ -174,7 +146,6 @@ exports.deleteById = async function (req, res) { return res.status(204).end(); } } catch (err) { - logger.error('Delete detection strategy failed. ' + err); - return res.status(500).send('Unable to delete detection strategy. Server error.'); + next(err); } }; diff --git a/app/controllers/groups-controller.js b/app/controllers/groups-controller.js index d0282f57..eeff37e3 100644 --- a/app/controllers/groups-controller.js +++ b/app/controllers/groups-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const groupsService = require('../services/groups-service'); +const groupsService = require('../services/stix/groups-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -84,17 +84,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const groupData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the group try { const group = await groupsService.create(groupData, options); + if (options.dryRun) { + return res.status(200).send(group); + } logger.debug('Success: Created group with id ' + group.stix.id); return res.status(201).send(group); } catch (err) { @@ -107,28 +109,32 @@ exports.create = async function (req, res) { logger.warn('Invalid stix.type'); return res.status(400).send('Unable to create group. stix.type must be intrusion-set'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create group. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const groupData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { - // Create the group - const group = await groupsService.updateFull(req.params.stixId, req.params.modified, groupData); + const group = await groupsService.updateFull( + req.params.stixId, + req.params.modified, + groupData, + options, + ); if (!group) { return res.status(404).send('Group not found.'); - } else { - logger.debug('Success: Updated group with id ' + group.stix.id); + } + if (options.dryRun) { return res.status(200).send(group); } + logger.debug('Success: Updated group with id ' + group.stix.id); + return res.status(200).send(group); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update group. Server error.'); + return next(err); } }; @@ -161,3 +167,17 @@ exports.deleteById = async function (req, res) { return res.status(500).send('Unable to delete group. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await groupsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/identities-controller.js b/app/controllers/identities-controller.js index 46d75a10..ce9be48f 100644 --- a/app/controllers/identities-controller.js +++ b/app/controllers/identities-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const identitiesService = require('../services/identities-service'); +const identitiesService = require('../services/stix/identities-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -86,17 +86,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const identityData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the identity try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const identity = await identitiesService.create(identityData, options); + if (options.dryRun) { + return res.status(200).send(identity); + } logger.debug('Success: Created identity with id ' + identity.stix.id); return res.status(201).send(identity); } catch (err) { @@ -106,32 +108,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create identity. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create identity. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const identityData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the identity try { const identity = await identitiesService.updateFull( req.params.stixId, req.params.modified, identityData, + options, ); if (!identity) { return res.status(404).send('Identity not found.'); - } else { - logger.debug('Success: Updated identity with id ' + identity.stix.id); + } + if (options.dryRun) { return res.status(200).send(identity); } + logger.debug('Success: Updated identity with id ' + identity.stix.id); + return res.status(200).send(identity); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update identity. Server error.'); + return next(err); } }; diff --git a/app/controllers/marking-definitions-controller.js b/app/controllers/marking-definitions-controller.js index d45c4124..715183e9 100644 --- a/app/controllers/marking-definitions-controller.js +++ b/app/controllers/marking-definitions-controller.js @@ -1,12 +1,8 @@ 'use strict'; -const markingDefinitionsService = require('../services/marking-definitions-service'); +const markingDefinitionsService = require('../services/stix/marking-definitions-service'); const logger = require('../lib/logger'); -const { - BadlyFormattedParameterError, - CannotUpdateStaticObjectError, - InvalidQueryStringParameterError, -} = require('../exceptions'); +const { BadlyFormattedParameterError, InvalidQueryStringParameterError } = require('../exceptions'); // NOTE: A marking definition does not support the modified or revoked properties!! @@ -63,20 +59,22 @@ exports.retrieveById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const markingDefinitionData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the marking definition try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const markingDefinition = await markingDefinitionsService.create( markingDefinitionData, options, ); + if (options.dryRun) { + return res.status(200).send(markingDefinition); + } logger.debug('Success: Created marking definition with id ' + markingDefinition.stix.id); return res.status(201).send(markingDefinition); } catch (err) { @@ -84,36 +82,31 @@ exports.create = async function (req, res) { logger.warn('Unable to create marking definition: Stix id not allowed'); return res.status(400).send('Stix id not allowed.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create marking definition. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const markingDefinitionData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the marking definition try { const markingDefinition = await markingDefinitionsService.updateFull( req.params.stixId, markingDefinitionData, + options, ); if (!markingDefinition) { return res.status(404).send('Marking definition not found.'); - } else { - logger.debug('Success: Updated marking definition with id ' + markingDefinition.stix.id); + } + if (options.dryRun) { return res.status(200).send(markingDefinition); } + logger.debug('Success: Updated marking definition with id ' + markingDefinition.stix.id); + return res.status(200).send(markingDefinition); } catch (err) { - if (err instanceof CannotUpdateStaticObjectError) { - logger.warn('Unable to update marking definition, cannot update static object'); - return res.status(400).send('Cannot update static object'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update marking definition. Server error.'); - } + return next(err); } }; diff --git a/app/controllers/matrices-controller.js b/app/controllers/matrices-controller.js index 1f50155c..7e0e7738 100644 --- a/app/controllers/matrices-controller.js +++ b/app/controllers/matrices-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const matricesService = require('../services/matrices-service'); +const matricesService = require('../services/stix/matrices-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -87,17 +87,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const matrixData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the matrix try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const matrix = await matricesService.create(matrixData, options); + if (options.dryRun) { + return res.status(200).send(matrix); + } logger.debug('Success: Created matrix with id ' + matrix.stix.id); return res.status(201).send(matrix); } catch (err) { @@ -107,32 +109,31 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create matrix. Duplicate stix.id and stix.modified properties.'); } - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create matrix. Server error.'); + return next(err); } }; -exports.updateFull = async function (req, res) { - try { - // Get the data from the request - const matrixData = req.body; +exports.updateFull = async function (req, res, next) { + const matrixData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Update the matrix + try { const matrix = await matricesService.updateFull( req.params.stixId, req.params.modified, matrixData, + options, ); - if (!matrix) { return res.status(404).send('Matrix not found.'); } - + if (options.dryRun) { + return res.status(200).send(matrix); + } logger.debug('Success: Updated matrix with id ' + matrix.stix.id); return res.status(200).send(matrix); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update matrix. Server error.'); + return next(err); } }; @@ -167,6 +168,20 @@ exports.deleteById = async function (req, res) { } }; +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await matricesService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; + exports.retrieveTechniquesForMatrix = async function (req, res) { try { const techniquesByTactic = await matricesService.retrieveTechniquesForMatrix( diff --git a/app/controllers/mitigations-controller.js b/app/controllers/mitigations-controller.js index 9c0855e7..ddaec0cf 100644 --- a/app/controllers/mitigations-controller.js +++ b/app/controllers/mitigations-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const mitigationsService = require('../services/mitigations-service'); +const mitigationsService = require('../services/stix/mitigations-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -89,17 +89,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const mitigationData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the mitigation try { const mitigation = await mitigationsService.create(mitigationData, options); + if (options.dryRun) { + return res.status(200).send(mitigation); + } logger.debug('Success: Created mitigation with id ' + mitigation.stix.id); return res.status(201).send(mitigation); } catch (err) { @@ -109,33 +111,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create mitigation. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create mitigation. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const mitigationData = req.body; - - // Create the mitigation + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { const mitigation = await mitigationsService.updateFull( req.params.stixId, req.params.modified, mitigationData, + options, ); if (!mitigation) { return res.status(404).send('Mitigation not found.'); - } else { - logger.debug('Success: Updated mitigation with id ' + mitigation.stix.id); + } + if (options.dryRun) { return res.status(200).send(mitigation); } + logger.debug('Success: Updated mitigation with id ' + mitigation.stix.id); + return res.status(200).send(mitigation); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update mitigation. Server error.'); + return next(err); } }; @@ -171,3 +172,17 @@ exports.deleteById = async function (req, res) { return res.status(500).send('Unable to delete mitigation. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await mitigationsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/notes-controller.js b/app/controllers/notes-controller.js index 4c5b3cee..669e022a 100644 --- a/app/controllers/notes-controller.js +++ b/app/controllers/notes-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const notesService = require('../services/notes-service'); +const notesService = require('../services/system/notes-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -83,17 +83,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const noteData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the note try { const note = await notesService.create(noteData, options); + if (options.dryRun) { + return res.status(200).send(note); + } logger.debug('Success: Created note with id ' + note.stix.id); return res.status(201).send(note); } catch (err) { @@ -103,28 +105,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create note. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create note. Server error.'); + return next(err); } } }; -exports.updateVersion = async function (req, res) { - // Get the data from the request +exports.updateVersion = async function (req, res, next) { const noteData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the note try { - const note = await notesService.updateVersion(req.params.stixId, req.params.modified, noteData); + const note = await notesService.updateVersion( + req.params.stixId, + req.params.modified, + noteData, + options, + ); if (!note) { return res.status(404).send('Note not found.'); - } else { - logger.debug('Success: Updated note with id ' + note.stix.id); + } + if (options.dryRun) { return res.status(200).send(note); } + logger.debug('Success: Updated note with id ' + note.stix.id); + return res.status(200).send(note); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update note. Server error.'); + return next(err); } }; diff --git a/app/controllers/recent-activity-controller.js b/app/controllers/recent-activity-controller.js index 6d57e233..a25438e7 100644 --- a/app/controllers/recent-activity-controller.js +++ b/app/controllers/recent-activity-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const recentActivityService = require('../services/recent-activity-service'); +const recentActivityService = require('../services/system/recent-activity-service'); const logger = require('../lib/logger'); exports.retrieveAll = async function (req, res) { diff --git a/app/controllers/references-controller.js b/app/controllers/references-controller.js index 97fe574d..ca8cc99e 100644 --- a/app/controllers/references-controller.js +++ b/app/controllers/references-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const referencesService = require('../services/references-service'); +const referencesService = require('../services/system/references-service'); const logger = require('../lib/logger'); const { DuplicateIdError } = require('../exceptions'); diff --git a/app/controllers/relationships-controller.js b/app/controllers/relationships-controller.js index ac4fb53d..972d75ee 100644 --- a/app/controllers/relationships-controller.js +++ b/app/controllers/relationships-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const relationshipsService = require('../services/relationships-service'); +const relationshipsService = require('../services/stix/relationships-service'); const logger = require('../lib/logger'); const { BadlyFormattedParameterError, @@ -96,17 +96,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const relationshipData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, + }; - // Create the relationship try { - const options = { - import: false, - userAccountId: req.user?.userAccountId, - }; const relationship = await relationshipsService.create(relationshipData, options); + if (options.dryRun) { + return res.status(200).send(relationship); + } logger.debug('Success: Created relationship with id ' + relationship.stix.id); return res.status(201).send(relationship); } catch (err) { @@ -116,32 +118,32 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create relationship. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create relationship. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const relationshipData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; - // Create the relationship try { const relationship = await relationshipsService.updateFull( req.params.stixId, req.params.modified, relationshipData, + options, ); if (!relationship) { return res.status(404).send('Relationship not found.'); - } else { - logger.debug('Success: Updated relationship with id ' + relationship.stix.id); + } + if (options.dryRun) { return res.status(200).send(relationship); } + logger.debug('Success: Updated relationship with id ' + relationship.stix.id); + return res.status(200).send(relationship); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update relationship. Server error.'); + return next(err); } }; diff --git a/app/controllers/release-tracks-controller.js b/app/controllers/release-tracks-controller.js new file mode 100644 index 00000000..239a9c8d --- /dev/null +++ b/app/controllers/release-tracks-controller.js @@ -0,0 +1,784 @@ +'use strict'; + +// ============================================================================= +// Release Tracks Controller +// +// Request parsing, Zod validation, and delegation to the service facade. +// Each handler follows the pattern established in collections-controller-v2.js: +// 1. Validate path params / query params / body with Zod safeParse +// 2. On failure, forward a typed error via next() +// 3. Build options, delegate to service facade +// 4. Return appropriate HTTP status +// 5. Forward unexpected errors to centralized error handler via next() +// ============================================================================= + +const releaseTracksService = require('../services/release-tracks/release-tracks-service'); +const logger = require('../lib/logger'); +const { + InvalidQueryStringParameterError, + BadRequestError, + NotImplementedError, +} = require('../exceptions'); +const { + domainParamSchema, + formatQuerySchema, + includeQuerySchema, + trackTypeQuerySchema, + workflowStatusSchema, + createTrackBodySchema, + createFromBundleBodySchema, + updateMetadataBodySchema, + updateContentsBodySchema, + bumpBodySchema, + cloneBodySchema, + addCandidatesBodySchema, + reviewCandidatesBodySchema, + promoteCandidatesBodySchema, + demoteStagedBodySchema, + updateCandidateVersionBodySchema, + updateConfigBodySchema, + updateCompositionBodySchema, + createVirtualSnapshotBodySchema, + xMitreVersionSchema, +} = require('../lib/release-tracks/release-track-schemas'); + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Parse an optional query parameter with a Zod schema, returning the parsed + * value on success or a default value on failure/absence. + */ +function parseOptionalQuery(value, schema, defaultValue) { + if (value === undefined || value === null) return defaultValue; + const result = schema.safeParse(value); + return result.success ? result.data : defaultValue; +} + +/** + * Parse common query parameters shared across GET snapshot endpoints. + */ +function parseSnapshotQueryParams(query) { + return { + format: parseOptionalQuery(query.format, formatQuerySchema, 'snapshot'), + include: parseOptionalQuery(query.include, includeQuerySchema, undefined), + releases: query.releases === 'only' ? 'only' : undefined, + version: parseOptionalQuery(query.version, xMitreVersionSchema, undefined), + versions: query.versions === 'all' ? 'all' : undefined, + limit: query.limit ? parseInt(query.limit, 10) : undefined, + offset: query.offset ? parseInt(query.offset, 10) : undefined, + }; +} + +// ============================================================================= +// Ephemeral +// ============================================================================= + +/** GET /api/release-tracks/ephemeral/:domain */ +exports.retrieveEphemeralByDomain = async function retrieveEphemeralByDomain(req, res, next) { + try { + const domainResult = domainParamSchema.safeParse(req.params.domain); + if (!domainResult.success) { + return next( + new InvalidQueryStringParameterError({ + parameterName: 'domain', + message: 'Invalid domain parameter. Must be one of: enterprise, ics, mobile', + }), + ); + } + + const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'bundle'); + const result = await releaseTracksService.getEphemeralBundle(domainResult.data, format); + logger.debug(`Success: Retrieved ephemeral ${domainResult.data} bundle`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to retrieve ephemeral bundle: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Track management +// ============================================================================= + +/** GET /api/release-tracks */ +exports.listReleaseTracks = async function listReleaseTracks(req, res, next) { + try { + const options = { + type: parseOptionalQuery(req.query.type, trackTypeQuerySchema, undefined), + releases: req.query.releases === 'only' ? 'only' : undefined, + limit: req.query.limit ? parseInt(req.query.limit, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset, 10) : undefined, + search: req.query.search || undefined, + }; + + const result = await releaseTracksService.listTracks(options); + logger.debug('Success: Retrieved release tracks list'); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to list release tracks: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/new */ +exports.createReleaseTrack = async function createReleaseTrack(req, res, next) { + try { + const bodyResult = createTrackBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid request body', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.createTrack({ + ...bodyResult.data, + userAccountId: req.user?.userAccountId, + }); + logger.debug(`Success: Created release track "${bodyResult.data.name}"`); + return res.status(201).send(result); + } catch (err) { + logger.error('Failed to create release track: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/new-from-bundle */ +exports.createReleaseTrackFromBundle = async function createReleaseTrackFromBundle(req, res, next) { + try { + const bodyResult = createFromBundleBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid STIX bundle', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.createTrackFromBundle(bodyResult.data); + logger.debug('Success: Created release track from bundle'); + return res.status(201).send(result); + } catch (err) { + logger.error('Failed to create release track from bundle: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/import */ +exports.importReleaseTrack = async function importReleaseTrack(_req, _res, next) { + return next( + new NotImplementedError('release-tracks-controller', 'importReleaseTrack', { + message: 'Release track import is not yet implemented', + }), + ); +}; + +/** GET /api/release-tracks/:id */ +exports.retrieveLatestSnapshot = async function retrieveLatestSnapshot(req, res, next) { + try { + const queryOptions = parseSnapshotQueryParams(req.query); + + // filesystemstore format is not yet implemented + if (queryOptions.format === 'filesystemstore') { + return next( + new NotImplementedError('release-tracks-controller', 'retrieveLatestSnapshot', { + message: 'The filesystemstore format is not yet implemented', + }), + ); + } + + const result = await releaseTracksService.getLatestSnapshot(req.params.id, queryOptions); + logger.debug(`Success: Retrieved latest snapshot for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to retrieve latest snapshot: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/meta */ +exports.updateMetadataByLatest = async function updateMetadataByLatest(req, res, next) { + try { + const bodyResult = updateMetadataBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid metadata update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateMetadata( + req.params.id, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated metadata for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update track metadata: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/contents */ +exports.updateContentsByLatest = async function updateContentsByLatest(req, res, next) { + try { + const bodyResult = updateContentsBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid contents update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateContents( + req.params.id, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated contents for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update track contents: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/bump */ +exports.bumpByLatest = async function bumpByLatest(req, res, next) { + try { + const bodyResult = bumpBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid bump request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.bumpLatest(req.params.id, { + ...bodyResult.data, + userAccountId: req.user?.userAccountId, + }); + logger.debug(`Success: Bumped version for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to bump track version: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/clone */ +exports.cloneByLatest = async function cloneByLatest(req, res, next) { + try { + const bodyResult = cloneBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid clone request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.cloneTrack(req.params.id, { + ...(bodyResult.data || {}), + userAccountId: req.user?.userAccountId, + }); + logger.debug(`Success: Cloned track ${req.params.id}`); + return res.status(201).send(result); + } catch (err) { + logger.error('Failed to clone track: ' + err); + return next(err); + } +}; + +/** DELETE /api/release-tracks/:id */ +exports.deleteReleaseTrack = async function deleteReleaseTrack(req, res, next) { + try { + await releaseTracksService.deleteTrack(req.params.id); + logger.debug(`Success: Deleted track ${req.params.id}`); + return res.status(204).end(); + } catch (err) { + logger.error('Failed to delete track: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Snapshot-specific operations +// ============================================================================= + +/** GET /api/release-tracks/:id/snapshots/:modified */ +exports.retrieveSnapshotByModified = async function retrieveSnapshotByModified(req, res, next) { + try { + const queryOptions = parseSnapshotQueryParams(req.query); + + const result = await releaseTracksService.getSnapshotByModified( + req.params.id, + req.params.modified, + queryOptions, + ); + logger.debug(`Success: Retrieved snapshot ${req.params.modified} for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to retrieve snapshot by modified: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/snapshots/:modified/meta */ +exports.updateMetadataByModified = async function updateMetadataByModified(req, res, next) { + try { + const bodyResult = updateMetadataBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid metadata update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateMetadataByModified( + req.params.id, + req.params.modified, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated metadata for snapshot ${req.params.modified}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update snapshot metadata: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/snapshots/:modified/contents */ +exports.updateContentsByModified = async function updateContentsByModified(req, res, next) { + try { + const bodyResult = updateContentsBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid contents update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateContentsByModified( + req.params.id, + req.params.modified, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated contents for snapshot ${req.params.modified}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update snapshot contents: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/snapshots/:modified/bump */ +exports.bumpByModified = async function bumpByModified(req, res, next) { + try { + const bodyResult = bumpBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid bump request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.bumpByModified(req.params.id, req.params.modified, { + ...bodyResult.data, + userAccountId: req.user?.userAccountId, + }); + logger.debug(`Success: Bumped version for snapshot ${req.params.modified}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to bump snapshot version: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/snapshots/:modified/clone */ +exports.cloneByModified = async function cloneByModified(req, res, next) { + try { + const bodyResult = cloneBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid clone request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.cloneFromSnapshot( + req.params.id, + req.params.modified, + { + ...(bodyResult.data || {}), + userAccountId: req.user?.userAccountId, + }, + ); + logger.debug(`Success: Cloned from snapshot ${req.params.modified}`); + return res.status(201).send(result); + } catch (err) { + logger.error('Failed to clone from snapshot: ' + err); + return next(err); + } +}; + +/** DELETE /api/release-tracks/:id/snapshots/:modified */ +exports.deleteSnapshotByModified = async function deleteSnapshotByModified(req, res, next) { + try { + await releaseTracksService.deleteSnapshot(req.params.id, req.params.modified); + logger.debug(`Success: Deleted snapshot ${req.params.modified} from track ${req.params.id}`); + return res.status(204).end(); + } catch (err) { + logger.error('Failed to delete snapshot: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Candidate management +// ============================================================================= + +/** POST /api/release-tracks/:id/candidates */ +exports.addCandidates = async function addCandidates(req, res, next) { + try { + const bodyResult = addCandidatesBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid candidates request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.addCandidates( + req.params.id, + bodyResult.data.object_refs, + req.user?.userAccountId, + ); + logger.debug(`Success: Added candidates to track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to add candidates: ' + err); + return next(err); + } +}; + +/** GET /api/release-tracks/:id/candidates */ +exports.listCandidates = async function listCandidates(req, res, next) { + try { + const options = { + status: parseOptionalQuery(req.query.status, workflowStatusSchema, undefined), + limit: req.query.limit ? parseInt(req.query.limit, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset, 10) : undefined, + }; + + const result = await releaseTracksService.listCandidates(req.params.id, options); + logger.debug(`Success: Listed candidates for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to list candidates: ' + err); + return next(err); + } +}; + +/** DELETE /api/release-tracks/:id/candidates/:objectRef */ +exports.removeCandidate = async function removeCandidate(req, res, next) { + try { + await releaseTracksService.removeCandidate(req.params.id, req.params.objectRef); + logger.debug(`Success: Removed candidate ${req.params.objectRef} from track ${req.params.id}`); + return res.status(204).end(); + } catch (err) { + logger.error('Failed to remove candidate: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/candidates/review */ +exports.reviewCandidates = async function reviewCandidates(req, res, next) { + try { + const bodyResult = reviewCandidatesBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid review request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.reviewCandidates( + req.params.id, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Reviewed candidates for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to review candidates: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/candidates/promote */ +exports.promoteCandidates = async function promoteCandidates(req, res, next) { + try { + const bodyResult = promoteCandidatesBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid promote request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.promoteCandidates( + req.params.id, + bodyResult.data.object_refs, + req.user?.userAccountId, + ); + logger.debug(`Success: Promoted candidates for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to promote candidates: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/candidates/:objectRef/update-version */ +exports.updateCandidateVersion = async function updateCandidateVersion(req, res, next) { + try { + const bodyResult = updateCandidateVersionBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid version update request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateCandidateVersion( + req.params.id, + req.params.objectRef, + bodyResult.data, + ); + logger.debug(`Success: Updated version for candidate ${req.params.objectRef}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update candidate version: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Staged objects +// ============================================================================= + +/** GET /api/release-tracks/:id/staged */ +exports.listStaged = async function listStaged(req, res, next) { + try { + const result = await releaseTracksService.listStaged(req.params.id); + logger.debug(`Success: Listed staged objects for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to list staged objects: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/staged/demote */ +exports.demoteStaged = async function demoteStaged(req, res, next) { + try { + const bodyResult = demoteStagedBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid demote request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.demoteStaged( + req.params.id, + bodyResult.data.object_refs, + req.user?.userAccountId, + ); + logger.debug(`Success: Demoted staged objects for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to demote staged objects: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** GET /api/release-tracks/:id/config */ +exports.getConfig = async function getConfig(req, res, next) { + try { + const result = await releaseTracksService.getConfig(req.params.id); + logger.debug(`Success: Retrieved config for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to get track config: ' + err); + return next(err); + } +}; + +/** PUT /api/release-tracks/:id/config */ +exports.updateConfig = async function updateConfig(req, res, next) { + try { + const bodyResult = updateConfigBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid config update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateConfig( + req.params.id, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated config for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update track config: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Preview & dry run +// ============================================================================= + +/** GET /api/release-tracks/:id/bump/preview */ +exports.previewBump = async function previewBump(req, res, next) { + try { + const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'workbench'); + + const result = await releaseTracksService.previewBump(req.params.id, format); + logger.debug(`Success: Generated bump preview for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to preview bump: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Object versions +// ============================================================================= + +/** GET /api/release-tracks/:id/objects/:objectRef/versions */ +exports.listObjectVersions = async function listObjectVersions(req, res, next) { + try { + const result = await releaseTracksService.listObjectVersions( + req.params.id, + req.params.objectRef, + ); + logger.debug(`Success: Listed versions for object ${req.params.objectRef}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to list object versions: ' + err); + return next(err); + } +}; + +// ============================================================================= +// Virtual track operations +// ============================================================================= + +/** PUT /api/release-tracks/:id/composition */ +exports.updateComposition = async function updateComposition(req, res, next) { + try { + const bodyResult = updateCompositionBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid composition update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateComposition( + req.params.id, + bodyResult.data, + req.user?.userAccountId, + ); + logger.debug(`Success: Updated composition for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update composition: ' + err); + return next(err); + } +}; + +/** POST /api/release-tracks/:id/snapshots/create */ +exports.createVirtualSnapshot = async function createVirtualSnapshot(req, res, next) { + try { + const bodyResult = createVirtualSnapshotBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid virtual snapshot request', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.createVirtualSnapshot(req.params.id, { + ...(bodyResult.data || {}), + userAccountId: req.user?.userAccountId, + }); + logger.debug(`Success: Created virtual snapshot for track ${req.params.id}`); + return res.status(201).send(result); + } catch (err) { + logger.error('Failed to create virtual snapshot: ' + err); + return next(err); + } +}; + +/** GET /api/release-tracks/:id/snapshots/preview */ +exports.previewVirtualSnapshot = async function previewVirtualSnapshot(req, res, next) { + try { + const result = await releaseTracksService.previewVirtualSnapshot(req.params.id); + logger.debug(`Success: Generated virtual snapshot preview for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to preview virtual snapshot: ' + err); + return next(err); + } +}; diff --git a/app/controllers/reports-controller.js b/app/controllers/reports-controller.js new file mode 100644 index 00000000..b930dae9 --- /dev/null +++ b/app/controllers/reports-controller.js @@ -0,0 +1,43 @@ +'use strict'; + +const reportsService = require('../services/reports-service'); +const logger = require('../lib/logger'); + +/** + * Handler for GET /api/reports/link-by-id/missing + * Retrieves objects that contain "attack.mitre.org" in their description. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +exports.getMissingLinkById = async function (req, res) { + const options = { + type: req.query.type, + }; + + try { + const results = await reportsService.getMissingLinkById(options); + logger.debug(`Success: Retrieved ${results.length} object(s) with missing LinkById`); + return res.status(200).send(results); + } catch (err) { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to get objects with missing LinkById. Server error.'); + } +}; + +/** + * Handler for GET /api/reports/parallel-relationships + * Retrieves parallel relationships (same source_ref, target_ref, and relationship_type). + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +exports.getParallelRelationships = async function (req, res) { + try { + const results = await reportsService.getParallelRelationships(); + logger.debug(`Success: Retrieved ${results.size} set(s) of parallel relationship(s)`); + // Convert Map to object for JSON serialization + return res.status(200).send(Object.fromEntries(results)); + } catch (err) { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to get parallel relationships. Server error.'); + } +}; diff --git a/app/controllers/session-controller.js b/app/controllers/session-controller.js index 92421c35..6848e3a6 100644 --- a/app/controllers/session-controller.js +++ b/app/controllers/session-controller.js @@ -1,6 +1,5 @@ 'use strict'; -//const sessionService = require('../services/session-service'); const logger = require('../lib/logger'); exports.retrieveCurrentSession = function (req, res) { diff --git a/app/controllers/software-controller.js b/app/controllers/software-controller.js index 72a6d027..4f1f7717 100644 --- a/app/controllers/software-controller.js +++ b/app/controllers/software-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const softwareService = require('../services/software-service'); +const softwareService = require('../services/stix/software-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -94,18 +94,19 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const softwareData = req.body; - const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the software try { - const software = await await softwareService.create(softwareData, options); + const software = await softwareService.create(softwareData, options); + if (options.dryRun) { + return res.status(200).send(software); + } logger.debug('Success: Created software with id ' + software.stix.id); return res.status(201).send(software); } catch (err) { @@ -120,37 +121,32 @@ exports.create = async function (req, res) { .status(400) .send(`Unable to create software, property ${err.propertyName} is not allowed`); } else { - console.log('create error'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create software. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const softwareData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { - // Create the software const software = await softwareService.updateFull( req.params.stixId, req.params.modified, softwareData, + options, ); - if (!software) { return res.status(404).send('Software not found.'); - } else { - logger.debug('Success: Updated software with id ' + software.stix.id); + } + if (options.dryRun) { return res.status(200).send(software); } + logger.debug('Success: Updated software with id ' + software.stix.id); + return res.status(200).send(software); } catch (err) { - console.log('update full error'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update software. Server error.'); + return next(err); } }; @@ -192,3 +188,17 @@ exports.deleteById = async function (req, res) { return res.status(500).send('Unable to delete software. Server error.'); } }; + +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await softwareService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; diff --git a/app/controllers/stix-bundles-controller.js b/app/controllers/stix-bundles-controller.js index dd5dfeac..d131e28f 100644 --- a/app/controllers/stix-bundles-controller.js +++ b/app/controllers/stix-bundles-controller.js @@ -1,7 +1,7 @@ 'use strict'; -const stixBundlesService = require('../services/stix-bundles-service'); -const stixBundlesServiceOld = require('../services/stix-bundles-service-old'); +const stixBundlesService = require('../services/stix/stix-bundles-service'); +const stixBundlesServiceOld = require('../services/stix/stix-bundles-service-old'); const logger = require('../lib/logger'); const validStixVersions = ['2.0', '2.1']; diff --git a/app/controllers/system-configuration-controller.js b/app/controllers/system-configuration-controller.js index 5902ac09..2b171dcd 100644 --- a/app/controllers/system-configuration-controller.js +++ b/app/controllers/system-configuration-controller.js @@ -1,10 +1,10 @@ 'use strict'; -const systemConfigurationService = require('../services/system-configuration-service'); -const { SystemConfigurationService } = require('../services/system-configuration-service'); +const systemConfigurationService = require('../services/system/system-configuration-service'); +const { SystemConfigurationService } = require('../services/system/system-configuration-service'); const logger = require('../lib/logger'); -exports.retrieveSystemVersion = function (req, res) { +exports.retrieveSystemVersion = function (req, res, next) { try { const systemVersionInfo = SystemConfigurationService.retrieveSystemVersion(); logger.debug( @@ -12,34 +12,31 @@ exports.retrieveSystemVersion = function (req, res) { ); return res.status(200).send(systemVersionInfo); } catch (err) { - logger.error('Unable to retrieve system version, failed with error: ' + err); - return res.status(500).send('Unable to retrieve system version. Server error.'); + return next(err); } }; -exports.retrieveAllowedValues = async function (req, res) { +exports.retrieveAllowedValues = async function (req, res, next) { try { const allowedValues = await systemConfigurationService.retrieveAllowedValues(); logger.debug('Success: Retrieved allowed values.'); return res.status(200).send(allowedValues); } catch (err) { - logger.error('Unable to retrieve allowed values, failed with error: ' + err); - return res.status(500).send('Unable to retrieve allowed values. Server error.'); + return next(err); } }; -exports.retrieveOrganizationIdentity = async function (req, res) { +exports.retrieveOrganizationIdentity = async function (req, res, next) { try { const identity = await systemConfigurationService.retrieveOrganizationIdentity(); logger.debug('Success: Retrieved organization identity.'); return res.status(200).send(identity); } catch (err) { - logger.error('Unable to retrieve organization identity, failed with error: ' + err); - return res.status(500).send('Unable to retrieve organization identity. Server error.'); + return next(err); } }; -exports.setOrganizationIdentity = async function (req, res) { +exports.setOrganizationIdentity = async function (req, res, next) { const organizationIdentity = req.body; if (!organizationIdentity.id) { logger.warn('Missing organization identity id'); @@ -51,23 +48,21 @@ exports.setOrganizationIdentity = async function (req, res) { logger.debug(`Success: Set organization identity to: ${organizationIdentity.id}`); return res.status(204).send(); } catch (err) { - logger.error('Unable to set organization identity, failed with error: ' + err); - return res.status(500).send('Unable to set organization identity. Server error.'); + return next(err); } }; -exports.retrieveAuthenticationConfig = function (req, res) { +exports.retrieveAuthenticationConfig = function (req, res, next) { try { const authenticationConfig = SystemConfigurationService.retrieveAuthenticationConfig(); logger.debug('Success: Retrieved authentication configuration.'); return res.status(200).send(authenticationConfig); } catch (err) { - logger.error('Unable to retrieve authentication configuration, failed with error: ' + err); - return res.status(500).send('Unable to retrieve authentication configuration. Server error.'); + return next(err); } }; -exports.retrieveDefaultMarkingDefinitions = async function (req, res) { +exports.retrieveDefaultMarkingDefinitions = async function (req, res, next) { try { const options = { refOnly: req.query.refOnly }; const defaultMarkingDefinitions = @@ -75,14 +70,11 @@ exports.retrieveDefaultMarkingDefinitions = async function (req, res) { logger.debug('Success: Retrieved default marking definitions.'); return res.status(200).send(defaultMarkingDefinitions); } catch (err) { - logger.error( - `Unable to retrieve default marking definitions, failed with error: ${err} (${err.markingDefinitionRef})`, - ); - return res.status(500).send('Unable to retrieve default marking definitions. Server error.'); + return next(err); } }; -exports.setDefaultMarkingDefinitions = async function (req, res) { +exports.setDefaultMarkingDefinitions = async function (req, res, next) { const defaultMarkingDefinitionIds = req.body; if (!defaultMarkingDefinitionIds) { logger.warn('Missing default marking definition ids'); @@ -97,23 +89,21 @@ exports.setDefaultMarkingDefinitions = async function (req, res) { logger.debug(`Success: Set default marking definitions`); return res.status(204).send(); } catch (err) { - logger.error('Unable to set default marking definitions, failed with error: ' + err); - return res.status(500).send('Unable to default marking definitions. Server error.'); + return next(err); } }; -exports.retrieveOrganizationNamespace = async function (req, res) { +exports.retrieveOrganizationNamespace = async function (req, res, next) { try { const namespace = await systemConfigurationService.retrieveOrganizationNamespace(); logger.debug('Success: Retrieved organization namespace.'); return res.status(200).send(namespace); } catch (err) { - logger.error('Unable to retrieve organization namespace, failed with error: ' + err); - return res.status(500).send('Unable to retrieve organization namespace. Server error.'); + return next(err); } }; -exports.setOrganizationNamespace = async function (req, res) { +exports.setOrganizationNamespace = async function (req, res, next) { const organizationNamespace = req.body; try { @@ -121,7 +111,6 @@ exports.setOrganizationNamespace = async function (req, res) { logger.debug(`Success: Set organization namespace to: ${organizationNamespace.prefix}`); return res.status(204).send(); } catch (err) { - logger.error('Unable to set organization namespace, failed with error: ' + err); - return res.status(500).send('Unable to set organization namespace. Server error.'); + return next(err); } }; diff --git a/app/controllers/tactics-controller.js b/app/controllers/tactics-controller.js index 64c476a4..4c7cdfd2 100644 --- a/app/controllers/tactics-controller.js +++ b/app/controllers/tactics-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const tacticsService = require('../services/tactics-service'); +const tacticsService = require('../services/stix/tactics-service'); const logger = require('../lib/logger'); const { DuplicateIdError, @@ -86,19 +86,23 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request +exports.create = async function (req, res, next) { const tacticData = req.body; const options = { import: false, userAccountId: req.user?.userAccountId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; // Create the tactic try { const tactic = await tacticsService.create(tacticData, options); + if (options.dryRun) { + return res.status(200).send(tactic); + } + logger.debug('Success: Created tactic with id ' + tactic.stix.id); return res.status(201).send(tactic); } catch (err) { @@ -108,32 +112,35 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create tactic. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create tactic. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request +exports.updateFull = async function (req, res, next) { const tacticData = req.body; + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { const tactic = await tacticsService.updateFull( req.params.stixId, req.params.modified, tacticData, + options, ); if (!tactic) { return res.status(404).send('tactic not found.'); - } else { - logger.debug('Success: Updated tactic with id ' + tactic.stix.id); + } + + if (options.dryRun) { return res.status(200).send(tactic); } + + logger.debug('Success: Updated tactic with id ' + tactic.stix.id); + return res.status(200).send(tactic); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update tactic. Server error.'); + return next(err); } }; @@ -169,6 +176,20 @@ exports.deleteById = async function (req, res) { } }; +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await tacticsService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; + exports.retrieveTechniquesForTactic = async function (req, res) { try { const options = { diff --git a/app/controllers/teams-controller.js b/app/controllers/teams-controller.js index 8dd61578..e72a369c 100644 --- a/app/controllers/teams-controller.js +++ b/app/controllers/teams-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const teamsService = require('../services/teams-service'); +const teamsService = require('../services/system/teams-service'); const logger = require('../lib/logger'); const { NotFoundError, BadlyFormattedParameterError, DuplicateIdError } = require('../exceptions'); diff --git a/app/controllers/techniques-controller.js b/app/controllers/techniques-controller.js index c0133537..68394bde 100644 --- a/app/controllers/techniques-controller.js +++ b/app/controllers/techniques-controller.js @@ -1,6 +1,6 @@ 'use strict'; -const techniquesService = require('../services/techniques-service'); +const techniquesService = require('../services/stix/techniques-service'); const logger = require('../lib/logger'); const { BadlyFormattedParameterError, @@ -89,20 +89,23 @@ exports.retrieveVersionById = async function (req, res) { } }; -exports.create = async function (req, res) { - // Get the data from the request - const techniqueData = req.body; +exports.create = async function (req, res, next) { const options = { import: false, userAccountId: req.user?.userAccountId, + parentTechniqueId: req.query.parentTechniqueId, + dryRun: req.query.dryRun === 'true' || req.query.dryRun === true, }; - // Create the technique try { - const technique = await techniquesService.create(techniqueData, options); + const result = await techniquesService.create(req.body, options); - logger.debug('Success: Created technique with id ' + technique.stix.id); - return res.status(201).send(technique); + if (options.dryRun) { + return res.status(200).send(result); + } + + logger.debug('Success: Created technique with id ' + result.stix.id); + return res.status(201).send(result); } catch (err) { if (err instanceof DuplicateIdError) { logger.warn('Duplicate stix.id and stix.modified'); @@ -110,32 +113,33 @@ exports.create = async function (req, res) { .status(409) .send('Unable to create technique. Duplicate stix.id and stix.modified properties.'); } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create technique. Server error.'); + return next(err); } } }; -exports.updateFull = async function (req, res) { - // Get the data from the request - const techniqueData = req.body; +exports.updateFull = async function (req, res, next) { + const options = { dryRun: req.query.dryRun === 'true' || req.query.dryRun === true }; try { - // Create the technique - const technique = await techniquesService.updateFull( + const result = await techniquesService.updateFull( req.params.stixId, req.params.modified, - techniqueData, + req.body, + options, ); - if (!technique) { + if (!result) { return res.status(404).send('Technique not found.'); - } else { - logger.debug('Success: Updated technique with id ' + technique.stix.id); - return res.status(200).send(technique); } + + if (options.dryRun) { + return res.status(200).send(result); + } + + logger.debug('Success: Updated technique with id ' + result.stix.id); + return res.status(200).send(result); } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update technique. Server error.'); + return next(err); } }; @@ -172,6 +176,50 @@ exports.deleteById = async function (req, res) { } }; +exports.revoke = async function (req, res, next) { + try { + const options = { + preserveRelationships: + req.query.preserveRelationships === 'true' || req.query.preserveRelationships === true, + userAccountId: req.user?.userAccountId, + }; + const result = await techniquesService.revoke(req.params.stixId, req.body, options); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; + +exports.convertToSubtechnique = async function (req, res, next) { + try { + const options = { + userAccountId: req.user?.userAccountId, + }; + const result = await techniquesService.convertToSubtechnique( + req.params.stixId, + req.body, + options, + ); + logger.debug('Success: Converted technique to subtechnique ' + result.primary?.stix?.id); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; + +exports.convertToTechnique = async function (req, res, next) { + try { + const options = { + userAccountId: req.user?.userAccountId, + }; + const result = await techniquesService.convertToTechnique(req.params.stixId, options); + logger.debug('Success: Converted subtechnique to technique ' + result.primary?.stix?.id); + return res.status(200).send(result); + } catch (err) { + return next(err); + } +}; + exports.retrieveTacticsForTechnique = async function (req, res) { try { const options = { diff --git a/app/controllers/user-accounts-controller.js b/app/controllers/user-accounts-controller.js index 5662324d..9ef3a2cd 100644 --- a/app/controllers/user-accounts-controller.js +++ b/app/controllers/user-accounts-controller.js @@ -1,11 +1,10 @@ 'use strict'; -const userAccountsService = require('../services/user-accounts-service'); +const userAccountsService = require('../services/system/user-accounts-service'); const logger = require('../lib/logger'); const config = require('../config/config'); -const { BadlyFormattedParameterError, DuplicateEmailError } = require('../exceptions'); -exports.retrieveAll = async function (req, res) { +exports.retrieveAll = async function (req, res, next) { const options = { offset: req.query.offset || 0, limit: req.query.limit || 0, @@ -28,14 +27,11 @@ exports.retrieveAll = async function (req, res) { } return res.status(200).send(results); } catch (err) { - console.log('retrieveall'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get user accounts. Server error.'); + next(err); } }; -exports.retrieveById = async function (req, res) { +exports.retrieveById = async function (req, res, next) { const options = { includeStixIdentity: req.query.includeStixIdentity, }; @@ -49,17 +45,11 @@ exports.retrieveById = async function (req, res) { return res.status(200).send(userAccount); } } catch (err) { - if (err instanceof BadlyFormattedParameterError) { - logger.warn('Badly formatted user account id: ' + req.params.id); - return res.status(400).send('User account id is badly formatted.'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get user account. Server error.'); - } + next(err); } }; -exports.create = async function (req, res) { +exports.create = async function (req, res, next) { // Get the data from the request const userAccountData = req.body; @@ -75,19 +65,11 @@ exports.create = async function (req, res) { logger.debug(`Success: Created user account with id ${userAccount.id}`); return res.status(201).send(userAccount); } catch (err) { - if (err instanceof DuplicateEmailError) { - logger.warn(`Unable to create user account, duplicate email: ${userAccountData.email}`); - return res.status(400).send('Duplicate email'); - } else { - console.log('create'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to create user account. Server error.'); - } + next(err); } }; -exports.updateFullAsync = async function (req, res) { +exports.updateFullAsync = async function (req, res, next) { try { // Create the technique const userAccount = await userAccountsService.updateFull(req.params.id, req.body); @@ -98,14 +80,11 @@ exports.updateFullAsync = async function (req, res) { return res.status(200).send(userAccount); } } catch (err) { - console.log('updatefullasync'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update user account. Server error.'); + next(err); } }; -exports.updateFull = async function (req, res) { +exports.updateFull = async function (req, res, next) { // Create the technique try { const userAccount = await userAccountsService.updateFull(req.params.id, req.body); @@ -116,12 +95,11 @@ exports.updateFull = async function (req, res) { return res.status(200).send(userAccount); } } catch (err) { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to update user account. Server error.'); + next(err); } }; -exports.deleteAsync = async function (req, res) { +exports.deleteAsync = async function (req, res, next) { try { const userAccount = await userAccountsService.delete(req.params.id); if (!userAccount) { @@ -131,14 +109,11 @@ exports.deleteAsync = async function (req, res) { return res.status(204).end(); } } catch (err) { - console.log('deleteasync'); - console.log(err); - logger.error('Delete user account failed. ' + err); - return res.status(500).send('Unable to delete user account. Server error.'); + next(err); } }; -exports.delete = async function (req, res) { +exports.delete = async function (req, res, next) { try { const userAccount = await userAccountsService.delete(req.params.id); if (!userAccount) { @@ -148,14 +123,11 @@ exports.delete = async function (req, res) { return res.status(204).end(); } } catch (err) { - console.log('delete'); - console.log(err); - logger.error('Delete user account failed. ' + err); - return res.status(500).send('Unable to delete user account. Server error.'); + next(err); } }; -exports.register = async function (req, res) { +exports.register = async function (req, res, next) { // The function supports self-registration of a logged in user if (config.userAuthn.mechanism === 'anonymous') { @@ -188,19 +160,11 @@ exports.register = async function (req, res) { logger.debug(`Success: Registed user account with id ${userAccount.id}`); return res.status(201).send(userAccount); } catch (err) { - console.log('register'); - console.log(err); - if (err.message === userAccountsService.errors.duplicateEmail) { - logger.warn(`Unable to register user account, duplicate email: ${userAccountData.email}`); - return res.status(400).send('Duplicate email'); - } else { - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to register user account. Server error.'); - } + next(err); } }; -exports.retrieveTeamsByUserId = async function (req, res) { +exports.retrieveTeamsByUserId = async function (req, res, next) { const options = { offset: req.query.offset || 0, limit: req.query.limit || 0, @@ -220,9 +184,6 @@ exports.retrieveTeamsByUserId = async function (req, res) { } return res.status(200).send(results); } catch (err) { - console.log('retrieveTeamsByUserId'); - console.log(err); - logger.error('Failed with error: ' + err); - return res.status(500).send('Unable to get teams. Server error.'); + next(err); } }; diff --git a/app/controllers/validation-bypasses-controller.js b/app/controllers/validation-bypasses-controller.js new file mode 100644 index 00000000..459adea9 --- /dev/null +++ b/app/controllers/validation-bypasses-controller.js @@ -0,0 +1,82 @@ +'use strict'; + +const validationBypassesService = require('../services/system/validation-bypasses-service'); +const logger = require('../lib/logger'); +const { DuplicateIdError } = require('../exceptions'); + +exports.retrieveAll = async function (req, res) { + const options = { + offset: req.query.offset || 0, + limit: req.query.limit || 0, + includePagination: req.query.includePagination, + }; + + try { + const results = await validationBypassesService.retrieveAll(options); + if (options.includePagination) { + logger.debug( + `Success: Retrieved ${results.data.length} of ${results.pagination.total} total validation bypass rule(s)`, + ); + } else { + logger.debug(`Success: Retrieved ${results.length} validation bypass rule(s)`); + } + return res.status(200).send(results); + } catch (err) { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to get validation bypass rules. Server error.'); + } +}; + +exports.create = async function (req, res) { + const data = req.body; + + if (!data.fieldPath || !data.errorCode || !data.stixType) { + return res + .status(400) + .send( + 'Unable to create validation bypass rule. Missing required properties (fieldPath, errorCode, stixType).', + ); + } + + try { + const rule = await validationBypassesService.create(data); + logger.debug('Success: Created validation bypass rule with id ' + rule._id); + return res.status(201).send(rule); + } catch (err) { + if (err instanceof DuplicateIdError) { + logger.warn('Duplicate validation bypass rule'); + return res.status(409).send('Unable to create validation bypass rule. Duplicate rule.'); + } else { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to create validation bypass rule. Server error.'); + } + } +}; + +exports.retrieveById = async function (req, res) { + try { + const rule = await validationBypassesService.retrieveById(req.params.id); + if (!rule) { + return res.status(404).send('Validation bypass rule not found.'); + } + logger.debug('Success: Retrieved validation bypass rule with id ' + req.params.id); + return res.status(200).send(rule); + } catch (err) { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to get validation bypass rule. Server error.'); + } +}; + +exports.deleteById = async function (req, res) { + try { + const rule = await validationBypassesService.deleteById(req.params.id); + if (!rule) { + return res.status(404).send('Validation bypass rule not found.'); + } + logger.debug('Success: Deleted validation bypass rule with id ' + req.params.id); + return res.status(204).end(); + } catch (err) { + logger.error('Delete validation bypass rule failed. ' + err); + return res.status(500).send('Unable to delete validation bypass rule. Server error.'); + } +}; diff --git a/app/exceptions/index.js b/app/exceptions/index.js index f46351e5..4ad32b55 100644 --- a/app/exceptions/index.js +++ b/app/exceptions/index.js @@ -1,13 +1,58 @@ 'use strict'; +function isErrorOptions(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeErrorOptions(options) { + if (options instanceof Error) { + const normalized = {}; + + if (options.message) { + normalized.details = options.message; + } + + normalized.cause = options; + + for (const key of Object.keys(options)) { + if (!(key in normalized)) { + normalized[key] = options[key]; + } + } + + return normalized; + } + + if (isErrorOptions(options)) { + return options; + } + + return null; +} + class CustomError extends Error { constructor(message, options = {}) { super(message); + // Set the error name to the class name + this.name = this.constructor.name; + // Apply options (if defined) to the error object - for (const key in options) { - if (Object.prototype.hasOwnProperty.call(options, key)) { - this[key] = options[key]; + const normalizedOptions = normalizeErrorOptions(options); + if (normalizedOptions) { + if (normalizedOptions.cause instanceof Error) { + Object.defineProperty(this, 'cause', { + value: normalizedOptions.cause, + enumerable: false, + writable: true, + configurable: true, + }); + } + + for (const key in normalizedOptions) { + if (key !== 'cause' && Object.prototype.hasOwnProperty.call(normalizedOptions, key)) { + this[key] = normalizedOptions[key]; + } } } } @@ -26,8 +71,13 @@ class BadlyFormattedParameterError extends CustomError { } class DuplicateIdError extends CustomError { - constructor(options) { - super('Duplicate id', options); + constructor(messageOrOptions, options) { + if (typeof messageOrOptions === 'string') { + super(messageOrOptions, options); + return; + } + + super('Duplicate id', messageOrOptions); } } @@ -164,8 +214,107 @@ class AnonymousUserAccountNotFoundError extends CustomError { } class InvalidTypeError extends CustomError { + constructor(messageOrOptions, options) { + if (typeof messageOrOptions === 'string') { + super(messageOrOptions, options); + return; + } + + super('Invalid stix.type', messageOrOptions); + } +} + +class ImmutablePropertyError extends CustomError { + constructor(propertyName, options) { + super(`Cannot modify immutable property: ${propertyName}`, options); + } +} + +class InvalidPostOperationError extends CustomError { + constructor(messageOrOptions, options) { + if (typeof messageOrOptions === 'string') { + super(messageOrOptions, options); + return; + } + + super('Cannot set the following keys:', messageOrOptions); + } +} + +class ValidationError extends CustomError { + constructor(message = 'Validation failed', options) { + super(message, options); + } +} + +class SchemaValidationError extends CustomError { + constructor(schemaName, zodError, options = {}) { + const errorDetails = zodError.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join('; '); + + super(`Schema validation failed for ${schemaName}: ${errorDetails}`, { + ...options, + zodError, + schemaName, + }); + } +} + +class AlreadyRevokedError extends CustomError { constructor(options) { - super('Invalid stix.type', options); + super('Object has already been revoked', options); + } +} + +class SelfRevocationError extends CustomError { + constructor(options) { + super('An object cannot revoke itself', options); + } +} + +class AlreadyReleasedError extends CustomError { + constructor(version, options) { + super(`This snapshot has already been tagged as version ${version}`, options); + } +} + +class InvalidVersionError extends CustomError { + constructor(message, options) { + super(message || 'Invalid version', options); + } +} + +class ReleaseConflictError extends CustomError { + constructor(message, options) { + super(message || 'Release conflict: promotion aborted due to conflicting objects', options); + } +} + +class NoTaggedSnapshotsError extends CustomError { + constructor(trackId, options) { + super(`Component track ${trackId} has no tagged snapshots`, options); + } +} + +class InvalidComponentTypeError extends CustomError { + constructor(trackId, options) { + super( + `Component track ${trackId} must be a standard track (virtual nesting is not allowed)`, + options, + ); + } +} + +class TrackNotFoundError extends CustomError { + constructor(trackId, options) { + super(`Release track ${trackId} not found`, options); + } +} + +class ObjectHasValidationIssuesError extends CustomError { + constructor(message = 'Object has unresolved validation issues', options) { + super(message, options); } } @@ -179,6 +328,27 @@ module.exports = { BadlyFormattedParameterError, InvalidQueryStringParameterError, CannotUpdateStaticObjectError, + ImmutablePropertyError, + InvalidPostOperationError, + + //** Validation errors */ + ValidationError, + SchemaValidationError, + ObjectHasValidationIssuesError, + + //** Revocation errors */ + AlreadyRevokedError, + SelfRevocationError, + + //** Version control errors */ + AlreadyReleasedError, + InvalidVersionError, + + //** Release track errors */ + ReleaseConflictError, + NoTaggedSnapshotsError, + InvalidComponentTypeError, + TrackNotFoundError, //** Database-related errors */ DuplicateIdError, diff --git a/app/index.js b/app/index.js index bf8c2c7d..e7defbbb 100644 --- a/app/index.js +++ b/app/index.js @@ -129,12 +129,33 @@ exports.initializeApp = async function () { } // Configure server-side sessions - // TBD: Replace default MemoryStore with production quality session storage const session = require('express-session'); + const MongoStore = require('connect-mongo'); + const { createWebCryptoAdapter } = require('connect-mongo'); + const mongoose = require('mongoose'); + + // Generate unique session cookie name based on container hostname + // This ensures multiple instances on the same domain don't conflict + // which is important for local development environments with multiple instances + const os = require('os'); + const crypto = require('crypto'); + const hostname = os.hostname(); + const cookieName = + hostname && hostname !== 'localhost' + ? `connect.${crypto.createHash('sha256').update(hostname).digest('hex').substring(0, 8)}.sid` + : 'connect.sid'; + const sessionOptions = { + name: cookieName, secret: config.session.secret, resave: false, saveUninitialized: false, + store: MongoStore.MongoStore.create({ + client: mongoose.connection.getClient(), + cryptoAdapter: createWebCryptoAdapter({ + secret: config.session.mongoStoreCryptoSecret, + }), + }), }; app.use(session(sessionOptions)); diff --git a/app/lib/assertions.js b/app/lib/assertions.js new file mode 100644 index 00000000..e7e6fb9e --- /dev/null +++ b/app/lib/assertions.js @@ -0,0 +1,70 @@ +'use strict'; + +const { BadlyFormattedParameterError } = require('../exceptions'); + +/** + * Service-layer assertion utilities + * + * This module provides assertion helpers for validating internal invariants in the service layer. + * Unlike middleware validation (which validates user input), these assertions check for programming + * errors and data integrity issues that should never occur in normal operation. + * + * These assertions throw BadlyFormattedParameterError (resulting in 400 responses for direct API calls) + * and are caught and categorized as validation errors during bulk import operations. + * + * Usage: + * ``` + * const assertions = require('./lib/assertions'); + * assertions.assertUnique(refs, 'x_mitre_analytic_refs', { stixId: 'x-mitre-detection-strategy--123' }); + * ``` + */ + +/** + * Assert that an array contains only unique values + * + * @param {Array|undefined|null} array - The array to check for uniqueness (undefined/null are allowed and skip validation) + * @param {string} fieldName - Name of the field being checked (for error messages) + * @param {object} context - Additional context to include in error message (e.g., { stixId: '...' }) + * @throws {BadlyFormattedParameterError} If array is provided but not an array type, or contains duplicate values + * + * @example + * assertUnique(['a', 'b', 'c'], 'analytic_refs', { stixId: 'detection-strategy--123' }); + * // Passes + * + * assertUnique(undefined, 'analytic_refs', { stixId: 'detection-strategy--123' }); + * // Passes (undefined is allowed - field is optional) + * + * assertUnique(['a', 'b', 'a'], 'analytic_refs', { stixId: 'detection-strategy--123' }); + * // Throws: BadlyFormattedParameterError: analytic_refs must contain unique values. Found duplicates in detection-strategy--123 + */ +function assertUnique(array, fieldName, context = {}) { + // Allow undefined/null - the field may be optional + if (array === undefined || array === null) { + return; + } + + if (!Array.isArray(array)) { + throw new BadlyFormattedParameterError({ + parameterName: fieldName, + message: `${fieldName} must be an array, got ${typeof array}`, + }); + } + + if (array.length === 0) { + return; // Empty arrays are trivially unique + } + + const uniqueValues = new Set(array); + const contextStr = context.stixId ? ` in ${context.stixId}` : ''; + + if (uniqueValues.size !== array.length) { + throw new BadlyFormattedParameterError({ + parameterName: fieldName, + message: `${fieldName} must contain unique values. Found duplicates${contextStr}`, + }); + } +} + +module.exports = { + assertUnique, +}; diff --git a/app/lib/attack-id-generator.js b/app/lib/attack-id-generator.js new file mode 100644 index 00000000..4a77c1cf --- /dev/null +++ b/app/lib/attack-id-generator.js @@ -0,0 +1,325 @@ +'use strict'; + +const { + stixTypeToAttackIdMapping, + attackIdExamples, + createAttackIdSchema, +} = require('@mitre-attack/attack-data-model'); +const { InvalidTypeError, DuplicateIdError } = require('../exceptions'); +const logger = require('./logger'); +const config = require('../config/config'); +const systemConfigurationRepository = require('../repository/system-configurations-repository'); + +/** + * Retrieve the organization namespace configuration (if set) + * @returns {Promise<{prefix: string, range_start: number}|null>} The namespace config or null + * @private + */ +async function getOrganizationNamespace() { + const systemConfig = await systemConfigurationRepository.retrieveOne({ lean: true }); + const ns = systemConfig?.organization_namespace; + if (ns?.prefix) { + return { prefix: ns.prefix.toUpperCase(), rangeStart: ns.range_start || 1000 }; + } + return null; +} + +/** + * Determines if a given ATT&CK object type requires an ATT&CK ID. + * @param {string} objectType - The ATT&CK object type to check + * @returns {boolean} True if the object type requires an ATT&CK ID + */ +function requiresAttackId(objectType) { + return objectType in stixTypeToAttackIdMapping; +} +exports.requiresAttackId = requiresAttackId; + +/** + * Get the type prefix for an ATT&CK ID type + * @param {string} attackIdType - The ATT&CK ID type (e.g., 'technique', 'tactic') + * @returns {string} The type prefix (e.g., 'T', 'TA', 'G') + * @private + */ +function getTypePrefix(attackIdType) { + // Extract the prefix from the example format (e.g., "T####" -> "T") + const example = attackIdExamples[attackIdType]; + return example.split('#')[0]; +} + +/** + * Extract ATT&CK ID from STIX external_references (if present) + * @param {object} stix - The STIX object + * @returns {string|null} The external ATT&CK ID or null if not found + */ +function extractAttackIdFromExternalReferences(stix) { + if (!stix.external_references || stix.external_references.length === 0) { + return null; + } + + const attackRef = stix.external_references.find( + (ref) => + ref.source_name && config.attackSourceNames.includes(ref.source_name) && ref.external_id, + ); + + return attackRef ? attackRef.external_id : null; +} +exports.extractAttackIdFromExternalReferences = extractAttackIdFromExternalReferences; + +/** + * Check if an ATT&CK ID is available (not already in use) + * @param {string} attackId - The ATT&CK ID to check + * @param {object} repository - The repository for querying existing objects + * @returns {Promise} True if the ID is available, false if already in use + */ +async function isAttackIdAvailable(attackId, repository) { + const allObjects = await repository.retrieveAll({}); + return !allObjects.some((obj) => obj.workspace?.attack_id === attackId); +} +exports.isAttackIdAvailable = isAttackIdAvailable; + +/** + * Validate that an ATT&CK ID matches the expected format for a STIX type + * @param {string} attackId - The ATT&CK ID to validate + * @param {string} stixType - The STIX type + * @returns {boolean} True if valid, false otherwise + */ +function validateAttackIdFormat(attackId, stixType) { + const schema = createAttackIdSchema(stixType); + const result = schema.safeParse(attackId); + return result.success; +} +exports.validateAttackIdFormat = validateAttackIdFormat; + +/** + * Validate an ATT&CK ID provided by the client + * @param {string} attackId - The ATT&CK ID to validate + * @param {string} stixType - The STIX type + * @param {object} repository - The repository for querying existing objects + * @returns {Promise} The validated ATT&CK ID + * @throws {Error} If the ID is invalid or already in use + * @private + */ +async function validateAttackId(attackId, stixType, repository) { + // Validate format + if (!validateAttackIdFormat(attackId, stixType)) { + throw new Error(`Invalid ATT&CK ID format: ${attackId} for STIX type ${stixType}`); + } + + // Check availability + const available = await isAttackIdAvailable(attackId, repository); + if (!available) { + throw new DuplicateIdError(`ATT&CK ID ${attackId} is already in use`); + } + + logger.debug(`Validated client-provided ATT&CK ID: ${attackId}`); + return attackId; +} + +/** + * Generate a new ATT&CK ID for an object + * + * This function generates unique ATT&CK IDs by finding the highest existing ID + * in the workspace.attack_id field for the given type and incrementing it. + * + * @param {string} stixType - The STIX type of the object (e.g., 'attack-pattern', 'intrusion-set', 'x-mitre-tactic') + * @param {object} repository - The repository for querying existing objects of this type + * @param {boolean} isSubtechnique - Whether to generate a subtechnique ID (only valid for attack-pattern) + * @param {string} parentTechniqueAttackId - Parent technique ATT&CK ID (required if isSubtechnique is true) + * @returns {Promise} The generated ATT&CK ID + * + * @example + * const tacticId = await generateAttackId('x-mitre-tactic', tacticsRepository, false); + * // Returns: "TA0042" + * + * @example + * const techniqueId = await generateAttackId('attack-pattern', techniquesRepository, false); + * // Returns: "T1234" + * + * @example + * const subtechniqueId = await generateAttackId('attack-pattern', techniquesRepository, true, 'T1234'); + * // Returns: "T1234.001" + */ +async function generateAttackId( + stixType, + repository, + isSubtechnique = false, + parentTechniqueAttackId = null, +) { + // Validate that the STIX type supports ATT&CK IDs + if (!(stixType in stixTypeToAttackIdMapping)) { + throw new InvalidTypeError(`STIX type '${stixType}' does not support ATT&CK ID generation`); + } + + // Retrieve namespace configuration + const namespace = await getOrganizationNamespace(); + if (namespace) { + logger.debug( + `Using organization namespace: prefix=${namespace.prefix}, rangeStart=${namespace.rangeStart}`, + ); + } + + // Handle subtechnique generation + if (isSubtechnique) { + if (stixType !== 'attack-pattern') { + throw new Error('Subtechniques are only valid for attack-pattern STIX type'); + } + if (!parentTechniqueAttackId) { + const errorMessage = 'Parent technique ATT&CK ID is required for subtechnique generation'; + logger.warn(errorMessage); + // throw new Error(errorMessage); // TODO reenable after migrating workflow to BE + } + + // Validate parent ID format (must be T#### or PREFIX-T####) + if (!/^([A-Z]+-)?T\d{4}$/.test(parentTechniqueAttackId)) { + throw new Error( + `Invalid parent technique ATT&CK ID format: ${parentTechniqueAttackId}. Must be T#### or PREFIX-T####`, + ); + } + + logger.debug(`Generating subtechnique ID for parent: ${parentTechniqueAttackId}`); + + // Get all existing techniques + const results = await repository.retrieveAll({ + includeRevoked: true, + includeDeprecated: true, + }); + const allTechniques = results[0]?.documents || []; + + // Find all subtechniques for this parent + const existingSubtechniqueNumbers = allTechniques + .filter((tech) => { + if (!tech.stix.x_mitre_is_subtechnique) return false; + + const attackId = tech.workspace?.attack_id; + if (!attackId) return false; + + // Check if this subtechnique belongs to our parent + return attackId.startsWith(`${parentTechniqueAttackId}.`); + }) + .map((tech) => { + const attackId = tech.workspace.attack_id; + // Extract the 3-digit subtechnique number (e.g., "001" from "T1234.001") + const match = attackId.match(/\.(\d{3})$/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter((num) => num > 0); + + logger.debug( + `Found ${existingSubtechniqueNumbers.length} existing subtechniques for ${parentTechniqueAttackId}`, + ); + + // Get next available subtechnique number + const nextNum = + existingSubtechniqueNumbers.length > 0 ? Math.max(...existingSubtechniqueNumbers) + 1 : 1; + + // Construct new subtechnique ID (e.g., "T1234.001" or "FOOBAR-T1234.001") + const generatedId = `${parentTechniqueAttackId}.${nextNum.toString().padStart(3, '0')}`; + + logger.debug(`Generated subtechnique ID: ${generatedId}`); + + return generatedId; + } + + // Regular (non-subtechnique) ID generation + const attackIdType = stixTypeToAttackIdMapping[stixType]; + const typePrefix = getTypePrefix(attackIdType); + + // Build the full prefix including namespace (e.g., "FOOBAR-T" or just "T") + const fullPrefix = namespace ? `${namespace.prefix}-${typePrefix}` : typePrefix; + + logger.debug(`Generating ATT&CK ID for STIX type: ${stixType}, prefix: ${fullPrefix}`); + + // Get all existing objects of this type + // Repository returns: [{ totalCount: [...], documents: [...] }] + const results = await repository.retrieveAll({}); + const allObjects = results[0]?.documents || []; + + logger.debug(`Retrieved ${allObjects.length} objects from repository`); + + // Extract numeric IDs from workspace.attack_id that match our full prefix + const existingIds = allObjects + .map((obj) => { + const attackId = obj.workspace?.attack_id; + if (!attackId || !attackId.startsWith(fullPrefix)) { + return null; + } + + // Remove full prefix and any decimal parts (for subtechniques) + const idWithoutPrefix = attackId.slice(fullPrefix.length).replace(/\.(\d{3})$/, ''); + + const numericPart = parseInt(idWithoutPrefix, 10); + return isNaN(numericPart) ? null : numericPart; + }) + .filter((id) => id !== null); + + logger.debug(`Found ${existingIds.length} existing IDs with prefix ${fullPrefix}`); + + // Calculate next available ID + // When namespace is configured, start from range_start; otherwise start at 1 + const minId = namespace ? namespace.rangeStart : 1; + const nextId = existingIds.length > 0 ? Math.max(minId, Math.max(...existingIds) + 1) : minId; + + // Construct new ID with proper padding (e.g., "G0042", "FOOBAR-T1000") + const generatedId = `${fullPrefix}${nextId.toString().padStart(4, '0')}`; + + logger.debug(`Generated ATT&CK ID: ${generatedId}`); + + return generatedId; +} +exports.generateAttackId = generateAttackId; + +/** + * Check if ATT&CK ID is present in workspace/STIX object and validate it + * + * @param {object} data - The object data with workspace and stix properties + * @param {string} stixType - The STIX type of the object + * @param {object} repository - The repository for querying existing objects + * @returns {Promise} True if ID is present and valid, false if not present + * @throws {Error} If ID is present but invalid or if workspace.attack_id conflicts with external_id + * + * @example + * // No ID present - returns false + * const hasId = await attackIdInWorkspaceStixObject({ stix: {}, workspace: {} }, 'x-mitre-tactic', repository); + * // Returns: false + * + * @example + * // Valid ID present - returns true + * const hasId = await attackIdInWorkspaceStixObject( + * { stix: {}, workspace: { attack_id: 'TA9999' } }, + * 'x-mitre-tactic', + * repository + * ); + * // Returns: true (if TA9999 is valid and available) + * + * @example + * // Invalid ID present - throws error + * const hasId = await attackIdInWorkspaceStixObject( + * { stix: {}, workspace: { attack_id: 'INVALID' } }, + * 'x-mitre-tactic', + * repository + * ); + * // Throws: Error (invalid format) + */ +async function hasValidAttackId(data, stixType, repository) { + const workspaceAttackId = data.workspace?.attack_id; + const externalId = extractAttackIdFromExternalReferences(data.stix); + + // No ATT&CK ID present in workspace + if (!workspaceAttackId) { + return false; + } + + // ATT&CK ID present - validate it + // First check if both workspace and external IDs are present + if (externalId && workspaceAttackId !== externalId) { + throw new Error( + `Conflicting ATT&CK IDs: workspace.attack_id (${workspaceAttackId}) does not match external_references[0].external_id (${externalId})`, + ); + } + + // Validate format and availability + await validateAttackId(workspaceAttackId, stixType, repository); + + return true; +} +exports.hasValidAttackId = hasValidAttackId; diff --git a/app/lib/authenticated-request.js b/app/lib/authenticated-request.js index fb7e9696..966f05a2 100644 --- a/app/lib/authenticated-request.js +++ b/app/lib/authenticated-request.js @@ -1,7 +1,7 @@ 'use strict'; const superagent = require('superagent'); -const authenticationService = require('../services/authentication-service'); +const authenticationService = require('../services/system/authentication-service'); /** * Send an HTTP GET request to the provided URL, including the appropriate Authorization header diff --git a/app/lib/authn-anonymous.js b/app/lib/authn-anonymous.js index 5a1561b7..76237179 100644 --- a/app/lib/authn-anonymous.js +++ b/app/lib/authn-anonymous.js @@ -2,7 +2,7 @@ const AnonymousUuidStrategy = require('passport-anonym-uuid'); -const systemConfigurationService = require('../services/system-configuration-service'); +const systemConfigurationService = require('../services/system/system-configuration-service'); let strategyName; exports.strategyName = function () { diff --git a/app/lib/authn-oidc.js b/app/lib/authn-oidc.js index 8d075f6d..65e5c48c 100644 --- a/app/lib/authn-oidc.js +++ b/app/lib/authn-oidc.js @@ -4,7 +4,7 @@ const openIdClient = require('openid-client'); const retry = require('async-await-retry'); const config = require('../config/config'); -const userAccountsService = require('../services/user-accounts-service'); +const userAccountsService = require('../services/system/user-accounts-service'); let strategyName; exports.strategyName = function () { diff --git a/app/lib/automation-run-recorder.js b/app/lib/automation-run-recorder.js new file mode 100644 index 00000000..b5acd1ac --- /dev/null +++ b/app/lib/automation-run-recorder.js @@ -0,0 +1,139 @@ +'use strict'; + +const os = require('os'); +const { v4: uuidv4 } = require('uuid'); +const logger = require('./logger'); + +const AUTOMATION_RUN_SCHEMA_VERSION = 1; +const AUTOMATION_RUNS_COLLECTION = 'automationRuns'; +const AUTOMATION_RUN_ITEMS_COLLECTION = 'automationRunItems'; + +async function ensureIndexes(db) { + await Promise.all([ + db.collection(AUTOMATION_RUNS_COLLECTION).createIndex({ run_id: 1 }, { unique: true }), + db.collection(AUTOMATION_RUNS_COLLECTION).createIndex({ automation_type: 1, started_at: -1 }), + db.collection(AUTOMATION_RUNS_COLLECTION).createIndex({ name: 1, started_at: -1 }), + db.collection(AUTOMATION_RUN_ITEMS_COLLECTION).createIndex({ run_id: 1, sequence: 1 }), + db.collection(AUTOMATION_RUN_ITEMS_COLLECTION).createIndex({ run_id: 1, status: 1 }), + db + .collection(AUTOMATION_RUN_ITEMS_COLLECTION) + .createIndex({ 'target.stix_id': 1, recorded_at: -1 }, { sparse: true }), + ]); +} + +function serializeError(error) { + if (!error) return null; + + return { + name: error.name, + message: error.message, + stack: error.stack, + }; +} + +class AutomationRunRecorder { + constructor(db, options) { + this.db = db; + this.runId = options.runId || uuidv4(); + this.automationType = options.automationType; + this.name = options.name; + this.trigger = options.trigger || {}; + this.scope = options.scope || {}; + this.metadata = options.metadata || {}; + this.startedAt = new Date(); + this.sequence = 0; + this.runsCollection = db.collection(AUTOMATION_RUNS_COLLECTION); + this.itemsCollection = db.collection(AUTOMATION_RUN_ITEMS_COLLECTION); + } + + async start() { + await ensureIndexes(this.db); + + await this.runsCollection.insertOne({ + schema_version: AUTOMATION_RUN_SCHEMA_VERSION, + run_id: this.runId, + automation_type: this.automationType, + name: this.name, + status: 'running', + started_at: this.startedAt, + finished_at: null, + trigger: this.trigger, + scope: this.scope, + runtime: { + hostname: os.hostname(), + pid: process.pid, + node_version: process.version, + platform: process.platform, + arch: process.arch, + }, + metadata: this.metadata, + counts: {}, + warnings: {}, + verification: {}, + summary: null, + error_summary: null, + items: { + collection: AUTOMATION_RUN_ITEMS_COLLECTION, + }, + }); + + return this; + } + + async recordItem(item) { + this.sequence += 1; + + await this.itemsCollection.insertOne({ + schema_version: AUTOMATION_RUN_SCHEMA_VERSION, + run_id: this.runId, + automation_type: this.automationType, + name: this.name, + recorded_at: new Date(), + sequence: this.sequence, + ...item, + }); + } + + async finish({ status, counts, warnings, verification, summary, errorSummary }) { + await this.runsCollection.updateOne( + { run_id: this.runId }, + { + $set: { + status, + finished_at: new Date(), + counts: counts || {}, + warnings: warnings || {}, + verification: verification || {}, + summary: summary || null, + error_summary: errorSummary || null, + }, + }, + ); + } + + log(level, message, details) { + const prefix = `[${this.automationType}:${this.name}][${this.runId}]`; + const formattedMessage = details + ? `${prefix} ${message} ${JSON.stringify(details)}` + : `${prefix} ${message}`; + + if (typeof logger[level] === 'function') { + logger[level](formattedMessage); + } else { + logger.info(formattedMessage); + } + } +} + +async function createAutomationRunRecorder(db, options) { + const recorder = new AutomationRunRecorder(db, options); + return recorder.start(); +} + +module.exports = { + AUTOMATION_RUN_SCHEMA_VERSION, + AUTOMATION_RUNS_COLLECTION, + AUTOMATION_RUN_ITEMS_COLLECTION, + createAutomationRunRecorder, + serializeError, +}; diff --git a/app/lib/bypass-rule-constants.js b/app/lib/bypass-rule-constants.js new file mode 100644 index 00000000..7ae637fc --- /dev/null +++ b/app/lib/bypass-rule-constants.js @@ -0,0 +1,12 @@ +'use strict'; + +/** + * Central enumeration of auto-created bypass rule reasons. + * Used to distinguish bypass rules created by different system events, + * enabling targeted cleanup without affecting rules from other triggers. + */ +module.exports = Object.freeze({ + NAMESPACE: 'namespace', + IDENTITY: 'identity', + STATIC: 'static', +}); diff --git a/app/lib/create-mongo-views.js b/app/lib/create-mongo-views.js new file mode 100644 index 00000000..2a3b8f9a --- /dev/null +++ b/app/lib/create-mongo-views.js @@ -0,0 +1,188 @@ +'use strict'; + +const logger = require('./logger'); +const mongoose = require('mongoose'); + +/** + * Create or update a single MongoDB view. + * Uses `collMod` if the view already exists, `create` otherwise — making the operation idempotent. + */ +async function createOrUpdateView(db, viewName, viewOn, pipeline) { + const collections = await db.listCollections({ name: viewName }, { nameOnly: true }).toArray(); + const command = collections.length ? 'collMod' : 'create'; + const label = command === 'create' ? 'Creating' : 'Modifying'; + logger.info(`${label} view ${viewName}`); + await db.command({ [command]: viewName, viewOn, pipeline }); +} + +// --------------------------------------------------------------------------- +// Pipeline fragments +// --------------------------------------------------------------------------- +const SORT_DOCUMENTS = [{ $sort: { 'stix.id': 1, 'stix.modified': -1 } }]; + +const LATEST_DOCUMENTS = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, +]; + +const ACTIVE_FILTER = [ + { $match: { 'stix.x_mitre_deprecated': { $in: [null, false] } } }, + { $match: { 'stix.revoked': { $in: [null, false] } } }, +]; + +const DEPRECATED_FILTER = [{ $match: { 'stix.x_mitre_deprecated': true } }]; +const REVOKED_FILTER = [{ $match: { 'stix.revoked': true } }]; + +// --------------------------------------------------------------------------- +// View generators +// --------------------------------------------------------------------------- + +/** + * Create the base set of views for a given collection (no type filter). + * - view..{all,latest} + * - view..{all,latest}.{active,deprecated,revoked} + * - view..{all,latest}.{active.deprecated,active.revoked,deprecated.revoked} + */ +async function createBaseViews(db, viewType) { + const variants = [ + { suffix: 'all', pipeline: SORT_DOCUMENTS }, + { suffix: 'all.active', pipeline: [...SORT_DOCUMENTS, ...ACTIVE_FILTER] }, + { suffix: 'all.deprecated', pipeline: [...SORT_DOCUMENTS, ...DEPRECATED_FILTER] }, + { suffix: 'all.revoked', pipeline: [...SORT_DOCUMENTS, ...REVOKED_FILTER] }, + { suffix: 'latest', pipeline: LATEST_DOCUMENTS }, + { suffix: 'latest.active', pipeline: [...LATEST_DOCUMENTS, ...ACTIVE_FILTER] }, + { suffix: 'latest.deprecated', pipeline: [...LATEST_DOCUMENTS, ...DEPRECATED_FILTER] }, + { suffix: 'latest.revoked', pipeline: [...LATEST_DOCUMENTS, ...REVOKED_FILTER] }, + ]; + + for (const { suffix, pipeline } of variants) { + await createOrUpdateView(db, `view.${viewType}.${suffix}`, viewType, pipeline); + } + + // Union views + const unionCombinations = [ + { base: 'active', extra: 'deprecated' }, + { base: 'active', extra: 'revoked' }, + { base: 'deprecated', extra: 'revoked' }, + ]; + for (const scope of ['all', 'latest']) { + for (const { base, extra } of unionCombinations) { + await createOrUpdateView( + db, + `view.${viewType}.${scope}.${base}.${extra}`, + `view.${viewType}.${scope}.${base}`, + [{ $unionWith: { coll: `view.${viewType}.${scope}.${extra}` } }], + ); + } + } +} + +/** + * Create filtered views for specific STIX types / relationship types. + */ +async function createFilteredViews(db, viewType, typeToFilter) { + for (const [itemType, mongoFilter] of Object.entries(typeToFilter)) { + const sourceCollection = ['sdo', 'smo'].includes(viewType) ? 'attackObjects' : 'relationships'; + const matchField = ['sdo', 'smo'].includes(viewType) ? 'stix.type' : 'stix.relationship_type'; + const matchStage = { $match: { [matchField]: mongoFilter } }; + + const keys = [ + { key: 'all', pipeline: [matchStage, ...SORT_DOCUMENTS] }, + { key: 'all.active', pipeline: [matchStage, ...SORT_DOCUMENTS, ...ACTIVE_FILTER] }, + { key: 'all.deprecated', pipeline: [matchStage, ...SORT_DOCUMENTS, ...DEPRECATED_FILTER] }, + { key: 'all.revoked', pipeline: [matchStage, ...SORT_DOCUMENTS, ...REVOKED_FILTER] }, + { key: 'latest', pipeline: [matchStage, ...LATEST_DOCUMENTS] }, + { key: 'latest.active', pipeline: [matchStage, ...LATEST_DOCUMENTS, ...ACTIVE_FILTER] }, + { + key: 'latest.deprecated', + pipeline: [matchStage, ...LATEST_DOCUMENTS, ...DEPRECATED_FILTER], + }, + { key: 'latest.revoked', pipeline: [matchStage, ...LATEST_DOCUMENTS, ...REVOKED_FILTER] }, + ]; + + const viewNames = {}; + for (const { key, pipeline } of keys) { + const viewName = `view.${viewType}.${key}.${itemType}`; + viewNames[key] = viewName; + await createOrUpdateView(db, viewName, sourceCollection, pipeline); + } + + // Union views + const unionCombinations = [ + { base: 'active', extra: 'deprecated' }, + { base: 'active', extra: 'revoked' }, + { base: 'deprecated', extra: 'revoked' }, + ]; + for (const scope of ['all', 'latest']) { + for (const { base, extra } of unionCombinations) { + await createOrUpdateView( + db, + `view.${viewType}.${scope}.${itemType}.${base}.${extra}`, + viewNames[`${scope}.${base}`], + [{ $unionWith: { coll: viewNames[`${scope}.${extra}`] } }], + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +const ATTACK_TYPE_TO_MONGO_FILTER = { + assets: 'x-mitre-asset', + campaigns: 'campaign', + datacomponents: 'x-mitre-data-component', + datasources: 'x-mitre-data-source', + identities: 'identity', + groups: 'intrusion-set', + matrices: 'x-mitre-matrix', + mitigations: 'course-of-action', + software: { $in: ['malware', 'tool'] }, + tactics: 'x-mitre-tactic', + techniques: 'attack-pattern', + collections: 'x-mitre-collection', + analytics: 'x-mitre-analytic', + detectionstrategies: 'x-mitre-detection-strategy', +}; + +const RELATIONSHIP_TYPES = [ + 'uses', + 'mitigates', + 'detects', + 'revoked-by', + 'subtechnique-of', + 'attributed-to', + 'targets', +]; + +/** + * Ensure all MongoDB views exist (creates or updates as needed). + * Safe to call on every startup — fully idempotent. + */ +exports.createMongoViews = async function createMongoViews() { + const db = mongoose.connection.getClient().db(); + + logger.info('Ensuring MongoDB views are up to date...'); + + // Base collection views + await createBaseViews(db, 'attackObjects'); + await createBaseViews(db, 'relationships'); + + // Filtered SDO views + await createFilteredViews(db, 'sdo', ATTACK_TYPE_TO_MONGO_FILTER); + + // Filtered SRO views + const sroFilter = {}; + for (const t of RELATIONSHIP_TYPES) { + sroFilter[t] = t; + } + await createFilteredViews(db, 'sro', sroFilter); + + // Filtered SMO views + await createFilteredViews(db, 'smo', { 'marking-definitions': 'marking-definition' }); + + logger.info('MongoDB views are up to date'); +}; diff --git a/app/lib/database-configuration.js b/app/lib/database-configuration.js index 04701cb6..8b7a0191 100644 --- a/app/lib/database-configuration.js +++ b/app/lib/database-configuration.js @@ -4,9 +4,9 @@ const fs = require('fs').promises; const path = require('path'); const config = require('../config/config'); -const identitiesService = require('../services/identities-service'); -const userAccountsService = require('../services/user-accounts-service'); -const systemConfigurationService = require('../services/system-configuration-service'); +const identitiesService = require('../services/stix/identities-service'); +const userAccountsService = require('../services/system/user-accounts-service'); +const systemConfigurationService = require('../services/system/system-configuration-service'); const logger = require('../lib/logger'); const AttackObject = require('../models/attack-object-model'); const CollectionIndex = require('../models/collection-index-model'); @@ -18,6 +18,11 @@ const { AnonymousUserAccountNotSetError, } = require('../exceptions'); +// Ensure event listeners are registered before checkSystemConfiguration() emits events. +// ValidationBypassesService registers its listeners at module load time, so requiring it +// here guarantees they are in place before the SYSTEM_CONFIGURATION_IDENTITY_CHANGED event fires. +require('../services/system/validation-bypasses-service'); + async function createPlaceholderOrganizationIdentity() { // Create placeholder identity object const timestamp = new Date().toISOString(); @@ -257,10 +262,16 @@ async function checkForStaticMarkingDefinitions() { } } +async function checkForStaticBypassRules() { + const validationBypassesService = require('../services/system/validation-bypasses-service'); + await validationBypassesService.loadStaticRules(config.configurationFiles.staticBypassRulesPath); +} + exports.checkSystemConfiguration = async function () { logger.info(`Performing system configuration check...`); await checkForOrganizationIdentity(); await checkForAnonymousUserAccount(); await checkForInvalidEnterpriseCollectionId(); await checkForStaticMarkingDefinitions(); + await checkForStaticBypassRules(); }; diff --git a/app/lib/default-bypass-rules.json b/app/lib/default-bypass-rules.json new file mode 100644 index 00000000..685be3c0 --- /dev/null +++ b/app/lib/default-bypass-rules.json @@ -0,0 +1,157 @@ +[ + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-tactic", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "attack-pattern", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "intrusion-set", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "malware", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "tool", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "campaign", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "relationship", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "identity", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "course-of-action", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "marking-definition", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-asset", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-data-source", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-data-component", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-detection-strategy", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-analytic", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-matrix", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "fieldPath": ["x_mitre_modified_by_ref"], + "errorCode": "invalid_value", + "stixType": "x-mitre-collection", + "suppressError": true, + "_comment": "This error is expected for all Workbench deployments except for the official MITRE ATT&CK deployment" + }, + { + "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)" + } +] diff --git a/app/lib/error-handler.js b/app/lib/error-handler.js index ed8d725e..db79a262 100644 --- a/app/lib/error-handler.js +++ b/app/lib/error-handler.js @@ -1,6 +1,46 @@ 'use strict'; const logger = require('./logger'); +const { + MissingParameterError, + BadlyFormattedParameterError, + DuplicateIdError, + DuplicateEmailError, + DuplicateNameError, + NotFoundError, + InvalidQueryStringParameterError, + CannotUpdateStaticObjectError, + IdentityServiceError, + TechniquesServiceError, + TacticsServiceError, + GenericServiceError, + DatabaseError, + BadRequestError, + HostNotFoundError, + ConnectionRefusedError, + HTTPError, + NotImplementedError, + PropertyNotAllowedError, + SystemConfigurationNotFound, + OrganizationIdentityNotSetError, + OrganizationIdentityNotFoundError, + AnonymousUserAccountNotSetError, + AnonymousUserAccountNotFoundError, + InvalidTypeError, + ImmutablePropertyError, + InvalidPostOperationError, + ValidationError, + DefaultMarkingDefinitionsNotFoundError, + AlreadyRevokedError, + SelfRevocationError, + AlreadyReleasedError, + InvalidVersionError, + ReleaseConflictError, + NoTaggedSnapshotsError, + InvalidComponentTypeError, + TrackNotFoundError, + ObjectHasValidationIssuesError, +} = require('../exceptions'); exports.bodyParser = function (err, req, res, next) { if (err.name === 'SyntaxError') { @@ -13,14 +53,124 @@ exports.bodyParser = function (err, req, res, next) { exports.requestValidation = function (err, req, res, next) { if (err.status && err.message) { - logger.warn('Request failed validation'); - logger.info(JSON.stringify(err)); + logger.warn('Request failed validation: %s', JSON.stringify(err)); res.status(err.status).send(err.message); } else { next(err); } }; +/** + * Helper function to build error response with all custom properties + * @param {Error} err - The error object + * @returns {Object|String} Error response object or message string + */ +function buildErrorResponse(err) { + // Start with the message + const errorResponse = { message: err.message }; + + // Add any custom properties from the error (excluding standard Error properties) + const standardErrorProps = ['name', 'message', 'stack']; + let hasCustomProps = false; + + Object.keys(err).forEach((key) => { + if (!standardErrorProps.includes(key)) { + errorResponse[key] = err[key]; + hasCustomProps = true; + } + }); + + // Return object if custom properties exist, otherwise just the message string + return hasCustomProps ? errorResponse : err.message; +} + +exports.serviceExceptions = function (err, req, res, next) { + // Handle 400 Bad Request errors (user-related errors) + if ( + err instanceof MissingParameterError || + err instanceof BadlyFormattedParameterError || + err instanceof InvalidQueryStringParameterError || + err instanceof ImmutablePropertyError || + err instanceof InvalidPostOperationError || + err instanceof InvalidTypeError || + err instanceof PropertyNotAllowedError || + err instanceof CannotUpdateStaticObjectError || + err instanceof SelfRevocationError || + err instanceof BadRequestError || + err instanceof ValidationError || + err instanceof InvalidVersionError || + err instanceof NoTaggedSnapshotsError || + err instanceof InvalidComponentTypeError + ) { + logger.warn('Bad request: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(400).send(buildErrorResponse(err)); + } + + // Handle 404 Not Found errors + if ( + err instanceof NotFoundError || + err instanceof SystemConfigurationNotFound || + err instanceof OrganizationIdentityNotFoundError || + err instanceof AnonymousUserAccountNotFoundError || + err instanceof DefaultMarkingDefinitionsNotFoundError || + err instanceof TrackNotFoundError + ) { + logger.warn('Not found: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(404).send(buildErrorResponse(err)); + } + + // Handle 409 Conflict errors (duplicate resources) + if ( + err instanceof DuplicateIdError || + err instanceof DuplicateEmailError || + err instanceof DuplicateNameError || + err instanceof AlreadyRevokedError || + err instanceof AlreadyReleasedError || + err instanceof ReleaseConflictError || + err instanceof ObjectHasValidationIssuesError + ) { + logger.warn('Conflict: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(409).send(buildErrorResponse(err)); + } + + // Handle 500 Internal Server errors (service and system errors) + if ( + err instanceof IdentityServiceError || + err instanceof TechniquesServiceError || + err instanceof TacticsServiceError || + err instanceof GenericServiceError || + err instanceof DatabaseError + ) { + logger.error('Service error: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(500).send(buildErrorResponse(err)); + } + + // Handle 502 Bad Gateway errors (external service errors) + if (err instanceof HostNotFoundError || err instanceof ConnectionRefusedError) { + logger.error('Bad gateway: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(502).send(buildErrorResponse(err)); + } + + // Handle 503 Service Unavailable errors (HTTP and configuration errors) + if ( + err instanceof HTTPError || + err instanceof OrganizationIdentityNotSetError || + err instanceof AnonymousUserAccountNotSetError + ) { + logger.error('Service unavailable: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(503).send(buildErrorResponse(err)); + } + + // Handle 501 Not Implemented errors + if (err instanceof NotImplementedError) { + logger.warn('Not implemented: %s', JSON.stringify(buildErrorResponse(err))); + return res.status(501).send(buildErrorResponse(err)); + } + + // Pass through to next error handler if not a recognized exception + next(err); +}; + // eslint-disable-next-line no-unused-vars exports.catchAll = function (err, req, res, next) { logger.error('catch all: ' + err); diff --git a/app/lib/event-bus.js b/app/lib/event-bus.js new file mode 100644 index 00000000..fa00dd3c --- /dev/null +++ b/app/lib/event-bus.js @@ -0,0 +1,120 @@ +'use strict'; + +const EventEmitter = require('events'); +const logger = require('./logger'); + +/** + * Event bus for decoupling service interactions + * Enables reactive, event-driven architecture for handling cross-document updates + * Built on Node.js EventEmitter + */ +class EventBus extends EventEmitter { + constructor() { + super(); + + // Increase max listeners since we may have multiple services subscribing to common events + this.setMaxListeners(50); + + // Event log for debugging + this.eventLog = []; + this.maxLogSize = 1000; + + // Track original on() for logging + this._originalOn = super.on.bind(this); + this._originalEmit = super.emit.bind(this); + } + + /** + * Override on() to add logging + * @param {string} eventName - Name of the event to listen for + * @param {Function} listener - Function to handle the event + * @returns {EventBus} this + */ + on(eventName, listener) { + const listenerName = listener.name || 'anonymous'; + logger.debug(`EventBus: Registered listener '${listenerName}' for '${eventName}'`); + return this._originalOn(eventName, listener); + } + + /** + * Override emit() to add logging and handle async listeners + * @param {string} eventName - Name of the event to emit + * @param {object} payload - Data to pass to event handlers + * @returns {Promise} + */ + async emit(eventName, payload) { + const timestamp = new Date().toISOString(); + + // Log the event + this.logEvent({ eventName, payload, timestamp }); + + logger.debug(`EventBus: Emitting '${eventName}'`); + + const listeners = this.listeners(eventName); + if (listeners.length === 0) { + logger.debug(`EventBus: No listeners for '${eventName}'`); + return; + } + + // Execute all listeners in parallel with Promise.allSettled + const results = await Promise.allSettled( + listeners.map(async (listener) => { + try { + const listenerName = listener.name || 'anonymous'; + logger.debug(`EventBus: Executing listener '${listenerName}' for '${eventName}'`); + const result = await listener(payload); + logger.debug(`EventBus: Listener '${listenerName}' completed successfully`); + return result; + } catch (error) { + const listenerName = listener.name || 'anonymous'; + logger.error(`EventBus: Listener '${listenerName}' failed for '${eventName}':`, error); + throw error; + } + }), + ); + + // Log any failures + const failures = results.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + logger.warn( + `EventBus: ${failures.length}/${listeners.length} listeners failed for '${eventName}'`, + ); + } + + // Return fulfilled handler results for callers that need them (e.g., WorkflowResult) + return results.filter((r) => r.status === 'fulfilled' && r.value != null).map((r) => r.value); + } + + /** + * Log an event for debugging and auditing + * @param {object} event - Event details + */ + logEvent(event) { + this.eventLog.push(event); + + // Limit log size + if (this.eventLog.length > this.maxLogSize) { + this.eventLog.shift(); + } + } + + /** + * Get recent events for debugging + * @param {number} limit - Number of recent events to return + * @returns {Array} Recent events + */ + getRecentEvents(limit = 50) { + return this.eventLog.slice(-limit); + } + + /** + * Clear all listeners (useful for testing) + */ + clear() { + this.removeAllListeners(); + logger.debug('EventBus: Cleared all listeners'); + } +} + +// Export singleton instance +module.exports = new EventBus(); diff --git a/app/lib/event-constants.js b/app/lib/event-constants.js new file mode 100644 index 00000000..57afcd72 --- /dev/null +++ b/app/lib/event-constants.js @@ -0,0 +1,161 @@ +'use strict'; + +/** + * Central enumeration of all events supported on the EventBus + * Use these constants instead of string literals to ensure consistency and enable IDE autocomplete + * + * Event Naming Convention: ::[.] + * - subject: Entity type (lowercase, singular or hyphenated) + * - action: Operation performed (past tense verb) + * - detail: Optional qualifier for specialized events + */ + +module.exports = Object.freeze({ + // ============================================================================ + // Core CRUD Events + // Emitted by BaseService after lifecycle hooks complete + // ============================================================================ + + // Attack Patterns (Techniques & Sub-techniques) + ATTACK_PATTERN_CREATED: 'attack-pattern::created', + ATTACK_PATTERN_UPDATED: 'attack-pattern::updated', + ATTACK_PATTERN_DELETED: 'attack-pattern::deleted', + + // Tactics + TACTIC_CREATED: 'x-mitre-tactic::created', + TACTIC_UPDATED: 'x-mitre-tactic::updated', + TACTIC_DELETED: 'x-mitre-tactic::deleted', + + // Mitigations + COURSE_OF_ACTION_CREATED: 'course-of-action::created', + COURSE_OF_ACTION_UPDATED: 'course-of-action::updated', + COURSE_OF_ACTION_DELETED: 'course-of-action::deleted', + + // Groups + INTRUSION_SET_CREATED: 'intrusion-set::created', + INTRUSION_SET_UPDATED: 'intrusion-set::updated', + INTRUSION_SET_DELETED: 'intrusion-set::deleted', + + // Software + MALWARE_CREATED: 'malware::created', + MALWARE_UPDATED: 'malware::updated', + MALWARE_DELETED: 'malware::deleted', + + TOOL_CREATED: 'tool::created', + TOOL_UPDATED: 'tool::updated', + TOOL_DELETED: 'tool::deleted', + + // Campaigns + CAMPAIGN_CREATED: 'campaign::created', + CAMPAIGN_UPDATED: 'campaign::updated', + CAMPAIGN_DELETED: 'campaign::deleted', + + // Data Sources + DATA_SOURCE_CREATED: 'x-mitre-data-source::created', + DATA_SOURCE_UPDATED: 'x-mitre-data-source::updated', + DATA_SOURCE_DELETED: 'x-mitre-data-source::deleted', + + // Data Components + DATA_COMPONENT_CREATED: 'x-mitre-data-component::created', + DATA_COMPONENT_UPDATED: 'x-mitre-data-component::updated', + DATA_COMPONENT_DELETED: 'x-mitre-data-component::deleted', + + // Matrices + MATRIX_CREATED: 'x-mitre-matrix::created', + MATRIX_UPDATED: 'x-mitre-matrix::updated', + MATRIX_DELETED: 'x-mitre-matrix::deleted', + + // Collections + COLLECTION_CREATED: 'x-mitre-collection::created', + COLLECTION_UPDATED: 'x-mitre-collection::updated', + COLLECTION_DELETED: 'x-mitre-collection::deleted', + + // Detection Strategies + DETECTION_STRATEGY_CREATED: 'x-mitre-detection-strategy::created', + DETECTION_STRATEGY_UPDATED: 'x-mitre-detection-strategy::updated', + DETECTION_STRATEGY_DELETED: 'x-mitre-detection-strategy::deleted', + + // Analytics + ANALYTIC_CREATED: 'x-mitre-analytic::created', + ANALYTIC_UPDATED: 'x-mitre-analytic::updated', + ANALYTIC_DELETED: 'x-mitre-analytic::deleted', + + // Assets + ASSET_CREATED: 'x-mitre-asset::created', + ASSET_UPDATED: 'x-mitre-asset::updated', + ASSET_DELETED: 'x-mitre-asset::deleted', + + // Revocation Events + ATTACK_PATTERN_REVOKED: 'attack-pattern::revoked', + TACTIC_REVOKED: 'x-mitre-tactic::revoked', + COURSE_OF_ACTION_REVOKED: 'course-of-action::revoked', + INTRUSION_SET_REVOKED: 'intrusion-set::revoked', + MALWARE_REVOKED: 'malware::revoked', + TOOL_REVOKED: 'tool::revoked', + CAMPAIGN_REVOKED: 'campaign::revoked', + DATA_SOURCE_REVOKED: 'x-mitre-data-source::revoked', + DATA_COMPONENT_REVOKED: 'x-mitre-data-component::revoked', + MATRIX_REVOKED: 'x-mitre-matrix::revoked', + ASSET_REVOKED: 'x-mitre-asset::revoked', + + // ============================================================================ + // Cross-Document Events + // Emitted by Manager classes when changes affect multiple documents + // ============================================================================ + + // Embedded Relationships + EMBEDDED_RELATIONSHIP_ADDED: 'embedded-relationship::added', + EMBEDDED_RELATIONSHIP_REMOVED: 'embedded-relationship::removed', + EMBEDDED_RELATIONSHIP_UPDATED: 'embedded-relationship::updated', + + // ATT&CK IDs + ATTACK_ID_ASSIGNED: 'attack-id::assigned', + ATTACK_ID_CHANGED: 'attack-id::changed', + ATTACK_ID_REMOVED: 'attack-id::removed', + + // External References + EXTERNAL_REFERENCE_ADDED: 'external-reference::added', + EXTERNAL_REFERENCE_REMOVED: 'external-reference::removed', + EXTERNAL_REFERENCE_UPDATED: 'external-reference::updated', + + // ============================================================================ + // Specialized Domain Events + // Emitted by SDO services for significant domain-specific changes + // ============================================================================ + + // Detection Strategy - Analytics relationship + DETECTION_STRATEGY_ANALYTICS_CHANGED: 'detection-strategy::analytics-changed', + DETECTION_STRATEGY_ANALYTICS_REFERENCED: 'x-mitre-detection-strategy::analytics-referenced', + DETECTION_STRATEGY_ANALYTICS_REMOVED: 'x-mitre-detection-strategy::analytics-removed', + + // Analytic - Data Components relationship + ANALYTIC_DATA_COMPONENTS_REFERENCED: 'x-mitre-analytic::data-components-referenced', + ANALYTIC_DATA_COMPONENTS_REMOVED: 'x-mitre-analytic::data-components-removed', + + // Data Component - Data Source relationship + DATA_COMPONENT_DATA_SOURCE_REFERENCED: 'x-mitre-data-component::data-source-referenced', + DATA_COMPONENT_DATA_SOURCE_REMOVED: 'x-mitre-data-component::data-source-removed', + + // Technique - Sub-technique conversion + TECHNIQUE_CONVERTED_TO_SUBTECHNIQUE: 'attack-pattern::converted-to-subtechnique', + SUBTECHNIQUE_CONVERTED_TO_TECHNIQUE: 'attack-pattern::converted-to-technique', + + // Data Source - Data Component relationship + DATA_SOURCE_COMPONENTS_CHANGED: 'x-mitre-data-source::components-changed', + + // Tactic - shortname change (phase_name in techniques) + TACTIC_SHORTNAME_CHANGED: 'x-mitre-tactic::shortname-changed', + + // Matrix - Tactics relationship + MATRIX_TACTICS_CHANGED: 'x-mitre-matrix::tactics-changed', + + // Collection - Objects relationship + COLLECTION_OBJECTS_CHANGED: 'x-mitre-collection::objects-changed', + + // System Configuration + SYSTEM_CONFIGURATION_NAMESPACE_CHANGED: 'system-configuration::namespace-changed', + SYSTEM_CONFIGURATION_IDENTITY_CHANGED: 'system-configuration::identity-changed', + + // Validation + VALIDATION_BYPASS_CHECK_REQUESTED: 'validation-bypass::check-requested', +}); diff --git a/app/lib/external-reference-builder.js b/app/lib/external-reference-builder.js new file mode 100644 index 00000000..f6d193d8 --- /dev/null +++ b/app/lib/external-reference-builder.js @@ -0,0 +1,245 @@ +'use strict'; + +const logger = require('./logger'); + +/** + * Map STIX types to their corresponding ATT&CK website URL paths + */ +const STIX_TYPE_TO_URL_PATH = { + 'attack-pattern': 'techniques', + 'intrusion-set': 'groups', + malware: 'software', + tool: 'software', + 'course-of-action': 'mitigations', + campaign: 'campaigns', + 'x-mitre-data-source': 'datasources', + 'x-mitre-data-component': 'datacomponents', + 'x-mitre-detection-strategy': 'detectionstrategies', + 'x-mitre-analytic': 'analytics', + 'x-mitre-asset': 'assets', + 'x-mitre-tactic': 'tactics', + 'x-mitre-matrix': 'matrices', +}; + +/** + * Build the ATT&CK external reference object for a given ATT&CK ID and STIX type + * @param {string} attackId - The ATT&CK ID (e.g., T0001, G0042, T1234.001) + * @param {string} stixType - The STIX type (e.g., attack-pattern, intrusion-set) + * @param {object} options - Optional parameters + * @param {boolean} options.isSubtechnique - Whether this is a subtechnique + * @param {string} options.parentDetectionStrategyId - For analytics, the parent detection strategy ATT&CK ID + * @returns {object} External reference object with source_name, external_id, and url + */ +function buildAttackExternalReference(attackId, stixType, options = {}) { + if (!attackId && !stixType) { + logger.warn('buildAttackExternalReference called with no attackId or stixType'); + return null; + } + + // Special case: Analytics with a parent detection strategy get a custom URL + if (stixType === 'x-mitre-analytic') { + if (options.parentDetectionStrategyId) { + return { + source_name: 'mitre-attack', + external_id: attackId, + url: `https://attack.mitre.org/detectionstrategies/${options.parentDetectionStrategyId}#${attackId}`, + }; + } else { + // No parent detection strategy, return reference without URL + return { + source_name: 'mitre-attack', + external_id: attackId, + }; + } + } + + const urlPath = STIX_TYPE_TO_URL_PATH[stixType]; + if (!urlPath) { + // Type doesn't have a URL mapping, return reference without URL + logger.debug( + `No URL path mapping for STIX type '${stixType}'; omitting url from external reference`, + ); + return { + source_name: 'mitre-attack', + external_id: attackId, + }; + } + + let url; + if (stixType === 'x-mitre-matrix') { + // Matrices use the domain name as external_id (e.g., "enterprise-attack") + // and the URL path strips the "-attack" suffix (e.g., /matrices/enterprise) + const urlSegment = attackId.replace(/-attack$/, ''); + url = `https://attack.mitre.org/${urlPath}/${urlSegment}`; + } else { + const isSubtechnique = options.isSubtechnique || attackId.includes('.'); + if (stixType === 'attack-pattern' && isSubtechnique) { + // Subtechniques use format: /techniques/T1234/001 + const [parentId, subId] = attackId.split('.'); + url = `https://attack.mitre.org/${urlPath}/${parentId}/${subId}`; + } else { + url = `https://attack.mitre.org/${urlPath}/${attackId}`; + } + } + + return { + source_name: 'mitre-attack', + external_id: attackId, + url, + }; +} + +/** + * Extract parent detection strategy ATT&CK ID from workspace embedded relationships + * @param {object} data - The data object containing workspace + * @param {object} options - Optional fallback context + * @param {object} options.previousVersion - Previous persisted version for version-aware lookups + * @returns {string|null} The detection strategy ATT&CK ID or null + */ +function extractParentDetectionStrategyId(data, options = {}) { + // Check if this is an analytic + if (data.stix?.type !== 'x-mitre-analytic') { + return null; + } + + const embeddedRelationshipCandidates = [ + data.workspace?.embedded_relationships, + options.previousVersion?.workspace?.embedded_relationships, + ]; + + // Look for parent detection strategy in embedded relationships. Analytics are referenced + // by detection strategies via x_mitre_analytic_refs, so we look for inbound relationships. + for (const embeddedRelationships of embeddedRelationshipCandidates) { + if ( + embeddedRelationships && + Array.isArray(embeddedRelationships) && + embeddedRelationships.length > 0 + ) { + const parentDetectionStrategy = embeddedRelationships.find( + (rel) => + rel.direction === 'inbound' && rel.stix_id?.startsWith('x-mitre-detection-strategy--'), + ); + + if (parentDetectionStrategy) { + return parentDetectionStrategy.attack_id || null; + } + } + } + + return null; +} + +/** + * Create an ATT&CK external reference for the given data + * @param {object} data - The data object containing stix and workspace + * @param {object} options - Optional context for deriving the reference + * @param {object} options.previousVersion - Previous persisted version for version-aware lookups + * @returns {object|null} The ATT&CK external reference object, or null if not applicable + */ +function createAttackExternalReference(data, options = {}) { + const stixType = data.stix?.type; + + // Matrices don't have ATT&CK IDs; their external_id is the domain name + // (e.g., "enterprise-attack") taken from x_mitre_domains[0]. + if (stixType === 'x-mitre-matrix') { + const domain = data.stix?.x_mitre_domains?.[0]; + if (!domain) { + logger.debug('Matrix has no x_mitre_domains; omitting ATT&CK external reference'); + return null; + } + return buildAttackExternalReference(domain, stixType); + } + + const attackId = data.workspace?.attack_id; + + if (!attackId) { + // No ATT&CK ID means no external reference to generate. + // This is expected for types like relationships that never have ATT&CK IDs. + return null; + } + + // Prepare options for URL building + const buildOptions = {}; + if (stixType === 'attack-pattern') { + buildOptions.isSubtechnique = data.stix?.x_mitre_is_subtechnique === true; + } + if (stixType === 'x-mitre-analytic') { + buildOptions.parentDetectionStrategyId = + options.parentDetectionStrategyId || extractParentDetectionStrategyId(data, options); + } + + return buildAttackExternalReference(attackId, stixType, buildOptions); +} + +/** + * Check if an external reference is an ATT&CK reference + * @param {object} ref - The external reference object + * @returns {boolean} True if this is an ATT&CK reference + */ +function isAttackExternalReference(ref) { + return ref && ref.source_name === 'mitre-attack'; +} + +/** + * Find the ATT&CK external reference in an array + * @param {Array} externalReferences - Array of external reference objects + * @returns {object|null} The ATT&CK external reference, or null if not found + */ +function findAttackExternalReference(externalReferences) { + if (!externalReferences || !Array.isArray(externalReferences)) { + return null; + } + return externalReferences.find(isAttackExternalReference) || null; +} + +/** + * Remove all ATT&CK external references from an array + * @param {Array} externalReferences - Array of external reference objects + * @returns {Array} New array with ATT&CK references removed + */ +function removeAttackExternalReferences(externalReferences) { + if (!externalReferences || !Array.isArray(externalReferences)) { + return []; + } + return externalReferences.filter((ref) => !isAttackExternalReference(ref)); +} + +/** + * Validate that an ATT&CK external reference matches the expected values + * @param {object} actualRef - The actual external reference from client + * @param {object} expectedRef - The expected external reference + * @returns {object} Validation result with isValid boolean and error message if invalid + */ +function validateAttackExternalReference(actualRef, expectedRef) { + if (!actualRef || !expectedRef) { + return { isValid: true }; + } + + // Check external_id matches + if (actualRef.external_id !== expectedRef.external_id) { + return { + isValid: false, + error: `Cannot modify ATT&CK ID: expected '${expectedRef.external_id}' but received '${actualRef.external_id}'`, + }; + } + + // Check URL matches if expected URL exists + if (expectedRef.url && actualRef.url && actualRef.url !== expectedRef.url) { + return { + isValid: false, + error: `Cannot modify ATT&CK reference URL: expected '${expectedRef.url}' but received '${actualRef.url}'`, + }; + } + + return { isValid: true }; +} + +module.exports = { + buildAttackExternalReference, + createAttackExternalReference, + extractParentDetectionStrategyId, + isAttackExternalReference, + findAttackExternalReference, + removeAttackExternalReferences, + validateAttackExternalReference, +}; diff --git a/app/lib/import-safety.js b/app/lib/import-safety.js new file mode 100644 index 00000000..ab29e2b0 --- /dev/null +++ b/app/lib/import-safety.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Import-safety primitives. + * + * STIX-bundle import has a strict invariant: when a bundle is imported, the + * persisted objects must be byte-faithful to the bundle's `stix` content. The + * import path is allowed to populate Workbench-private metadata (everything + * under `workspace`), but it must NEVER alter the imported `stix` fields, + * because the bundle is the source of truth and round-trip fidelity matters + * for re-imports and downstream consumers. + * + * However, the lifecycle hooks and event listeners that fire during a normal + * create/update — `beforeCreate`, `afterCreate`, and the cross-service + * listeners on domain events — were not originally written with import + * fidelity in mind. Several of them mutate `stix.*` as part of their normal + * work (e.g. AnalyticsService.beforeCreate stamps `stix.name` from the + * ATT&CK ID; AnalyticsService.handleAnalyticsReferenced rewrites + * `stix.external_references` to embed a URL to the parent detection + * strategy). Those mutations are correct for user-driven POST/PUT flows but + * are incorrect for an import. + * + * Rather than rely on convention ("remember to gate every stix write behind + * `if (!options.import) { ... }`"), we enforce the contract structurally: + * before invoking any hook or listener in import mode, the framework calls + * `deepFreezeStix(doc)`. In Node strict mode (`'use strict'` at the top of + * every service file), an attempted assignment to a frozen property throws + * a `TypeError`. That makes a missing import gate fail loudly at the + * violating line on the first import test, instead of silently corrupting + * bundle content. + * + * The local rule for hook/listener authors becomes: + * + * 1. Workspace mutations are always allowed. + * 2. If you need to mutate `stix.*`, wrap the block in + * `if (!options.import) { ... }` (or `if (!payload.options?.import)` + * inside a listener). The framework guarantees that `options.import` + * is the only state where stix is frozen, so a missing gate produces + * an immediate TypeError pointing at the offending line. + * + * Read freely from frozen stix — only writes are blocked. + */ + +/** + * Deep-freezes the `stix` field of a document so any attempt to write to + * `doc.stix.*`, including writes through nested arrays/objects (e.g. + * `doc.stix.external_references.unshift(...)`), throws a `TypeError`. + * + * `Object.freeze` is shallow on its own, so we walk the immediate children + * (one level into nested objects/arrays) and freeze them as well. Two + * levels is sufficient for STIX in practice: the deepest commonly mutated + * paths are array elements (e.g. `stix.external_references[i]` or + * `stix.kill_chain_phases[i]`), which the loop covers. + * + * Safe to call multiple times — `Object.isFrozen` short-circuits. + * Safe to call on Mongoose documents: only the underlying `_doc.stix` + * subtree is frozen; Mongoose's wrapper accessors remain functional, and + * Mongoose does not mutate the source object when constructing new + * documents during save/insertMany. + * + * @param {Object} doc - A document of the shape `{ stix, workspace }`, or + * a Mongoose document with the same shape. No-op if `doc` or `doc.stix` + * is missing. + */ +function deepFreezeStix(doc) { + const stix = doc?.stix; + if (!stix || typeof stix !== 'object' || Object.isFrozen(stix)) return; + + Object.freeze(stix); + + for (const value of Object.values(stix)) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) continue; + Object.freeze(value); + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === 'object' && !Object.isFrozen(item)) { + Object.freeze(item); + } + } + } + } +} + +module.exports = { + deepFreezeStix, +}; diff --git a/app/lib/logger.js b/app/lib/logger.js index 81f565e7..31ebf254 100644 --- a/app/lib/logger.js +++ b/app/lib/logger.js @@ -3,33 +3,6 @@ const winston = require('winston'); const config = require('../config/config'); -// function formatId(info) { -// if (info.level.toUpperCase() === 'HTTP') { -// return ''; -// } -// else if (info.id) { -// return `[${ info.id }] `; -// } -// else { -// return '[ 000000000000 ] '; -// } -// } - -// NOTE if you want to enable one-liner logs, use this instead: -// const consoleFormat = winston.format.combine( -// winston.format.timestamp(), -// winston.format.printf( -// (info) => `${info.timestamp} [${info.level.toUpperCase()}] ${info.message}`, -// ), -// // winston.format.printf(info => `${ info.timestamp } [${ info.level.toUpperCase() }] ${ formatId(info) }${ info.message }`) -// ); - -const consoleFormat = winston.format.combine( - winston.format.timestamp(), - winston.format.errors({ stack: true }), - winston.format.prettyPrint(), -); - const logLevels = { error: 0, warn: 1, @@ -39,6 +12,26 @@ const logLevels = { debug: 5, }; +// Shared formats applied in every mode. `splat()` enables printf-style +// interpolation (e.g. logger.warn('Bad request: %s', body)); without it winston +// leaves the `%s` token uninterpolated and drops the extra argument. +const baseFormats = [ + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.splat(), +]; + +// Use detailed format for debug/verbose levels, cleaner one-liner format otherwise +const consoleFormat = + config.logging.logLevel === 'debug' || config.logging.logLevel === 'verbose' + ? winston.format.combine(...baseFormats, winston.format.prettyPrint()) + : winston.format.combine( + ...baseFormats, + winston.format.printf( + (info) => `${info.timestamp} [${info.level.toUpperCase()}] ${info.message}`, + ), + ); + const logger = winston.createLogger({ format: consoleFormat, transports: [new winston.transports.Console({ level: config.logging.logLevel })], diff --git a/app/lib/migration/migrate-database.js b/app/lib/migration/migrate-database.js index 7f4b1c85..0960be84 100644 --- a/app/lib/migration/migrate-database.js +++ b/app/lib/migration/migrate-database.js @@ -9,14 +9,21 @@ exports.migrateDatabase = async function () { file: './app/lib/migration/migration-config.js', }; - const { db, client } = await migrateMongo.database.connect(); - const migrationStatus = await migrateMongo.status(db); + // In migrate-mongo v14+, exports are Promises that must be awaited + const [database, status, up] = await Promise.all([ + migrateMongo.database, + migrateMongo.status, + migrateMongo.up, + ]); + + const { db, client } = await database.connect(); + const migrationStatus = await status(db); const actionsPending = Boolean(migrationStatus.find((elem) => elem.appliedAt === 'PENDING')); if (actionsPending) { if (config.database.migration.enable) { logger.info('Starting database migration...'); - const appliedActions = await migrateMongo.up(db, client); + const appliedActions = await up(db, client); for (const action of appliedActions) { logger.info(`Applied migration action: ${action}`); } diff --git a/app/lib/migration/migration-config.js b/app/lib/migration/migration-config.js index bbc9b8d7..76137d41 100644 --- a/app/lib/migration/migration-config.js +++ b/app/lib/migration/migration-config.js @@ -5,10 +5,6 @@ const config = require('../../config/config'); module.exports = { mongodb: { url: config.database.url, - - options: { - useNewUrlParser: true, - }, }, // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. diff --git a/app/lib/release-tracks/conflict-resolution.js b/app/lib/release-tracks/conflict-resolution.js new file mode 100644 index 00000000..5c8b3f9d --- /dev/null +++ b/app/lib/release-tracks/conflict-resolution.js @@ -0,0 +1,90 @@ +'use strict'; + +// ============================================================================= +// Conflict Resolution +// +// Applies conflict resolution policies when promoting objects between tiers. +// A "conflict" occurs when the incoming entry has the same object_ref as an +// existing entry in the target tier but a different object_modified timestamp. +// +// Policies: +// always_overwrite – Replace the incumbent with the incoming entry +// always_reject – Keep the incumbent; incoming is rejected +// prefer_latest – Keep whichever has the newer object_modified +// abort – Throw ReleaseConflictError on any conflict +// ============================================================================= + +const { ReleaseConflictError } = require('../../exceptions'); + +/** + * Merge incoming entries into an existing tier, applying a conflict policy. + * + * @param {Array} existingTier - Current entries in the target tier + * @param {Array} incomingEntries - Entries being promoted into the tier + * @param {string} policy - One of: 'always_overwrite' | 'always_reject' | 'prefer_latest' | 'abort' + * @returns {{ merged: Array, rejected: Array }} + * @throws {ReleaseConflictError} If policy is 'abort' and any conflicts are detected + */ +exports.applyConflictPolicy = function applyConflictPolicy(existingTier, incomingEntries, policy) { + const merged = [...existingTier]; + const rejected = []; + const conflicts = []; // Collect all conflicts for 'abort' policy + + for (const incoming of incomingEntries) { + const conflictIdx = merged.findIndex((e) => e.object_ref === incoming.object_ref); + + if (conflictIdx === -1) { + // No conflict — simply add the incoming entry + merged.push(incoming); + continue; + } + + const incumbent = merged[conflictIdx]; + + switch (policy) { + case 'always_overwrite': + merged[conflictIdx] = incoming; + break; + + case 'always_reject': + rejected.push(incoming); + break; + + case 'prefer_latest': { + const incomingTime = new Date(incoming.object_modified).getTime(); + const incumbentTime = new Date(incumbent.object_modified).getTime(); + if (incomingTime > incumbentTime) { + merged[conflictIdx] = incoming; + } else { + rejected.push(incoming); + } + break; + } + + case 'abort': + // Collect all conflicts instead of throwing immediately + conflicts.push({ + object_ref: incoming.object_ref, + incumbent_version: incumbent.object_modified, + incoming_version: incoming.object_modified, + }); + break; + + default: + throw new Error(`Unknown conflict resolution policy: ${policy}`); + } + } + + // If we're using 'abort' policy and found any conflicts, throw with all of them + if (policy === 'abort' && conflicts.length > 0) { + const conflictCount = conflicts.length; + const message = + conflictCount === 1 + ? `Conflict on ${conflicts[0].object_ref}: abort policy prevents promotion` + : `Cannot complete release: ${conflictCount} conflict(s) detected`; + + throw new ReleaseConflictError(message, { conflicts }); + } + + return { merged, rejected }; +}; diff --git a/app/lib/release-tracks/deduplication-strategies.js b/app/lib/release-tracks/deduplication-strategies.js new file mode 100644 index 00000000..de9090dc --- /dev/null +++ b/app/lib/release-tracks/deduplication-strategies.js @@ -0,0 +1,193 @@ +'use strict'; + +// ============================================================================= +// Deduplication Strategies +// +// Resolves duplicate objects when multiple component tracks contribute the same +// STIX object (same object_ref) to a virtual track snapshot. +// +// Strategies: +// prioritize_latest_object – Keep the version with the newest object_modified +// prioritize_latest_snapshot – Keep the version from the most recently modified component snapshot +// prioritize_higher_priority – Keep the version from the higher-priority component (lower number) +// quarantine – Send all conflicting versions to quarantine for manual review +// +// Input members are annotated with source metadata: +// _source_track_id, _source_track_name, _source_snapshot_modified, +// _source_snapshot_version, _source_priority +// ============================================================================= + +/** + * Deduplicate members collected from multiple component tracks. + * + * @param {Array} allMembers - Annotated member entries from all components. + * Each entry: { object_ref, object_modified, _source_track_id, _source_track_name, + * _source_snapshot_modified, _source_snapshot_version, _source_priority } + * @param {string} strategy - One of the four deduplication strategies + * @returns {{ members: Array, quarantined: Array, report: Object }} + */ +exports.deduplicate = function deduplicate(allMembers, strategy) { + // Group entries by object_ref to identify duplicates + const groups = new Map(); + for (const entry of allMembers) { + const key = entry.object_ref; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key).push(entry); + } + + const members = []; + const quarantined = []; + const conflictsResolved = []; + + for (const [objectRef, entries] of groups) { + if (entries.length === 1) { + // No conflict — single source + members.push(_stripSourceMeta(entries[0])); + continue; + } + + // Conflict: same object_ref from multiple component tracks + switch (strategy) { + case 'prioritize_latest_object': + _resolveByLatestObject(objectRef, entries, members, conflictsResolved); + break; + + case 'prioritize_latest_snapshot': + _resolveByLatestSnapshot(objectRef, entries, members, conflictsResolved); + break; + + case 'prioritize_higher_priority': + _resolveByHigherPriority(objectRef, entries, members, conflictsResolved); + break; + + case 'quarantine': + _resolveByQuarantine(objectRef, entries, quarantined, conflictsResolved); + break; + + default: + throw new Error(`Unknown deduplication strategy: ${strategy}`); + } + } + + const report = { + total_objects_before: allMembers.length, + total_objects_after: members.length, + duplicates_found: conflictsResolved.length, + conflicts_resolved: conflictsResolved, + }; + + return { members, quarantined, report }; +}; + +// ============================================================================= +// Strategy implementations +// ============================================================================= + +/** + * Keep the entry with the most recent object_modified timestamp. + */ +function _resolveByLatestObject(objectRef, entries, members, conflictsResolved) { + let winner = entries[0]; + for (let i = 1; i < entries.length; i++) { + if ( + new Date(entries[i].object_modified).getTime() > new Date(winner.object_modified).getTime() + ) { + winner = entries[i]; + } + } + + members.push(_stripSourceMeta(winner)); + conflictsResolved.push({ + object_ref: objectRef, + strategy: 'prioritize_latest_object', + winner_source: winner._source_track_id, + winner_modified: winner.object_modified, + candidates_count: entries.length, + }); +} + +/** + * Keep the entry from the component track whose resolved snapshot has the + * most recent modified timestamp. + */ +function _resolveByLatestSnapshot(objectRef, entries, members, conflictsResolved) { + let winner = entries[0]; + for (let i = 1; i < entries.length; i++) { + const entrySnapshotTime = new Date(entries[i]._source_snapshot_modified).getTime(); + const winnerSnapshotTime = new Date(winner._source_snapshot_modified).getTime(); + if (entrySnapshotTime > winnerSnapshotTime) { + winner = entries[i]; + } + } + + members.push(_stripSourceMeta(winner)); + conflictsResolved.push({ + object_ref: objectRef, + strategy: 'prioritize_latest_snapshot', + winner_source: winner._source_track_id, + winner_snapshot_modified: winner._source_snapshot_modified, + candidates_count: entries.length, + }); +} + +/** + * Keep the entry from the component track with the highest priority + * (lowest priority number). + */ +function _resolveByHigherPriority(objectRef, entries, members, conflictsResolved) { + let winner = entries[0]; + for (let i = 1; i < entries.length; i++) { + if (entries[i]._source_priority < winner._source_priority) { + winner = entries[i]; + } + } + + members.push(_stripSourceMeta(winner)); + conflictsResolved.push({ + object_ref: objectRef, + strategy: 'prioritize_higher_priority', + winner_source: winner._source_track_id, + winner_priority: winner._source_priority, + candidates_count: entries.length, + }); +} + +/** + * Send all conflicting versions to quarantine for manual resolution. + * No entry is added to members for this object_ref. + */ +function _resolveByQuarantine(objectRef, entries, quarantined, conflictsResolved) { + for (const entry of entries) { + quarantined.push({ + object_ref: entry.object_ref, + object_modified: entry.object_modified, + source_track_id: entry._source_track_id, + source_track_name: entry._source_track_name, + source_snapshot_version: entry._source_snapshot_version, + conflict_reason: 'duplicate_object', + }); + } + + conflictsResolved.push({ + object_ref: objectRef, + strategy: 'quarantine', + quarantined_count: entries.length, + }); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Strip internal source-tracking metadata from an entry, returning a clean + * member entry suitable for persistence. + */ +function _stripSourceMeta(entry) { + return { + object_ref: entry.object_ref, + object_modified: entry.object_modified, + }; +} diff --git a/app/lib/release-tracks/export-schemas.js b/app/lib/release-tracks/export-schemas.js new file mode 100644 index 00000000..f67be824 --- /dev/null +++ b/app/lib/release-tracks/export-schemas.js @@ -0,0 +1,179 @@ +'use strict'; + +// ============================================================================= +// Zod transform schemas for export format transformations. +// +// These schemas encapsulate the DTO transformation logic for each export format. +// Each schema takes a common input shape (snapshot + hydratedObjects) and +// transforms it to the appropriate output format. +// +// Usage: +// const { bundleTransformSchema } = require('./export-schemas'); +// const output = bundleTransformSchema.parse({ snapshot, hydratedObjects }); +// +// See docs/COLLECTIONS_V2/07_OUTPUT_FORMATS.md for format specifications. +// ============================================================================= + +const { z } = require('zod'); +const uuid = require('uuid'); + +// ----------------------------------------------------------------------------- +// Shared sub-schemas +// +// These schemas use z.looseObject() to allow additional properties from Mongoose +// documents (e.g., _id, __v) to pass through without validation errors. +// ----------------------------------------------------------------------------- + +const tierEntrySchema = z.looseObject({ + object_ref: z.string(), + object_modified: z.date().or(z.string()), +}); + +const snapshotSchema = z.looseObject({ + id: z.string(), + version: z.string().nullable().optional(), + name: z.string(), + modified: z.date().or(z.string()), + members: z.array(tierEntrySchema).default([]), + staged: z.array(tierEntrySchema).optional(), + candidates: z.array(tierEntrySchema).optional(), +}); + +const hydratedObjectSchema = z.looseObject({ + stix: z.looseObject({}), + workspace: z.looseObject({}).optional(), +}); + +const exportOptionsSchema = z + .object({ + include: z.enum(['staged', 'candidates', 'all']).optional(), + }) + .optional() + .default({}); + +// ----------------------------------------------------------------------------- +// Base input schema (shared by all transforms) +// ----------------------------------------------------------------------------- + +const exportInputSchema = z.object({ + snapshot: snapshotSchema, + hydratedObjects: z.array(hydratedObjectSchema), + options: exportOptionsSchema, +}); + +// ----------------------------------------------------------------------------- +// Helper: Build tier lookup for workbench format +// ----------------------------------------------------------------------------- + +function buildTierLookup(snapshot) { + const lookup = {}; + for (const m of snapshot.members || []) { + lookup[`${m.object_ref}::${new Date(m.object_modified).getTime()}`] = 'released'; + } + for (const s of snapshot.staged || []) { + lookup[`${s.object_ref}::${new Date(s.object_modified).getTime()}`] = 'staged'; + } + for (const c of snapshot.candidates || []) { + lookup[`${c.object_ref}::${new Date(c.object_modified).getTime()}`] = 'candidate'; + } + return lookup; +} + +// ----------------------------------------------------------------------------- +// Bundle Transform Schema +// +// Standard STIX 2.1 bundle format. Only includes `stix` properties - no +// workspace data or workflow metadata. Suitable for external publication. +// ----------------------------------------------------------------------------- + +const bundleTransformSchema = exportInputSchema.transform((input) => ({ + type: 'bundle', + id: `bundle--${uuid.v4()}`, + objects: input.hydratedObjects.map((doc) => doc.stix), +})); + +// ----------------------------------------------------------------------------- +// Workbench Transform Schema +// +// Workbench-optimized format with full metadata. Includes `stix` + `workspace` +// properties and tier annotations. Optimized for Workbench UI consumption. +// ----------------------------------------------------------------------------- + +const workbenchTransformSchema = exportInputSchema.transform((input) => { + const tierLookup = buildTierLookup(input.snapshot); + + const objects = input.hydratedObjects.map((doc) => { + const key = `${doc.stix.id}::${new Date(doc.stix.modified).getTime()}`; + return { + stix: doc.stix, + workspace: doc.workspace || {}, + metadata: { + collection_tier: tierLookup[key] || 'released', + object_type: doc.stix.type, + object_name: doc.stix.name || doc.stix.id, + }, + }; + }); + + return { + collection: { + id: input.snapshot.id, + version: input.snapshot.version, + name: input.snapshot.name, + modified: input.snapshot.modified, + }, + objects, + summary: { + released_count: (input.snapshot.members || []).length, + staged_count: (input.snapshot.staged || []).length, + candidate_count: (input.snapshot.candidates || []).length, + }, + }; +}); + +// ----------------------------------------------------------------------------- +// FilesystemStore Transform Schema +// +// STIX FileSystemStore-compatible directory structure. Objects are grouped by +// STIX type, each with a filename and content property. +// ----------------------------------------------------------------------------- + +const filesystemStoreTransformSchema = exportInputSchema.transform((input) => { + const structure = {}; + + for (const doc of input.hydratedObjects) { + const type = doc.stix.type; + if (!structure[type]) structure[type] = []; + structure[type].push({ + filename: `${doc.stix.id}.json`, + content: doc.stix, + }); + } + + return { + format: 'filesystemstore', + track_id: input.snapshot.id, + version: input.snapshot.version, + structure, + }; +}); + +// ============================================================================= +// Exports +// ============================================================================= + +module.exports = { + // Input schemas (for validation/testing) + exportInputSchema, + snapshotSchema, + hydratedObjectSchema, + exportOptionsSchema, + + // Transform schemas + bundleTransformSchema, + workbenchTransformSchema, + filesystemStoreTransformSchema, + + // Helper (exported for testing) + buildTierLookup, +}; diff --git a/app/lib/release-tracks/object-resolver.js b/app/lib/release-tracks/object-resolver.js new file mode 100644 index 00000000..e8aabe0a --- /dev/null +++ b/app/lib/release-tracks/object-resolver.js @@ -0,0 +1,82 @@ +'use strict'; + +// ============================================================================= +// Object Resolver +// +// Resolves STIX object references to concrete modified timestamps by querying +// the existing STIX service layer. This is used when adding candidates with +// `modified: "latest"` or when modified is omitted. +// +// Follows the same serviceMap pattern as import-bundle.js. +// ============================================================================= + +const types = require('../types'); +const { BadRequestError, NotFoundError } = require('../../exceptions'); + +// --------------------------------------------------------------------------- +// Service map – lazy-loaded to avoid circular dependency issues at startup. +// --------------------------------------------------------------------------- + +let _serviceMap = null; + +function getServiceMap() { + if (_serviceMap) return _serviceMap; + + _serviceMap = { + [types.Technique]: require('../../services/stix/techniques-service'), + [types.Tactic]: require('../../services/stix/tactics-service'), + [types.Group]: require('../../services/stix/groups-service'), + [types.Campaign]: require('../../services/stix/campaigns-service'), + [types.Mitigation]: require('../../services/stix/mitigations-service'), + [types.Matrix]: require('../../services/stix/matrices-service'), + [types.Relationship]: require('../../services/stix/relationships-service'), + [types.MarkingDefinition]: require('../../services/stix/marking-definitions-service'), + [types.Identity]: require('../../services/stix/identities-service'), + [types.Note]: require('../../services/system/notes-service'), + [types.DataSource]: require('../../services/stix/data-sources-service'), + [types.DataComponent]: require('../../services/stix/data-components-service'), + [types.Asset]: require('../../services/stix/assets-service'), + [types.Analytic]: require('../../services/stix/analytics-service'), + [types.DetectionStrategy]: require('../../services/stix/detection-strategies-service'), + }; + + // Software types share a single service + const softwareService = require('../../services/stix/software-service'); + _serviceMap[types.Malware] = softwareService; + _serviceMap[types.Tool] = softwareService; + + return _serviceMap; +} + +/** + * Resolve the latest `stix.modified` timestamp for a given STIX object ID. + * + * @param {string} objectRef - A STIX identifier (e.g. "attack-pattern--") + * @returns {Promise} The most recent modified timestamp for the object + * @throws {BadRequestError} If the object type is not recognized + * @throws {NotFoundError} If the object does not exist in the database + */ +exports.resolveLatestModified = async function resolveLatestModified(objectRef) { + const type = objectRef.split('--')[0]; + const serviceMap = getServiceMap(); + const service = serviceMap[type]; + + if (!service) { + throw new BadRequestError({ + message: `Unknown object type: ${type}`, + details: `Cannot resolve latest version for object ref "${objectRef}"`, + }); + } + + // retrieveById with { versions: 'latest' } returns a single-element array + // sorted by stix.modified descending. We use 'latest' to be efficient. + const results = await service.retrieveById(objectRef, { versions: 'latest' }); + + if (!results || results.length === 0) { + throw new NotFoundError({ + details: `Object ${objectRef} not found — cannot resolve latest modified timestamp`, + }); + } + + return new Date(results[0].stix.modified); +}; diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js new file mode 100644 index 00000000..99e53857 --- /dev/null +++ b/app/lib/release-tracks/release-track-schemas.js @@ -0,0 +1,413 @@ +'use strict'; + +// ============================================================================= +// Zod schemas for release track data validation. +// +// These schemas are the canonical source of truth for release track field +// formats. They are pure Zod schemas with no framework coupling, so they can +// be reused in any context: Mongoose model validators, controller request +// validation, test assertions, etc. +// +// For Mongoose-specific validator wrappers see ./release-track-validators.js. +// ============================================================================= + +const { z } = require('zod'); +const { + stixIdentifierSchema, + xMitreVersionSchema, + createStixIdValidator, +} = require('@mitre-attack/attack-data-model'); + +// ----------------------------------------------------------------------------- +// Custom STIX identifier +// ----------------------------------------------------------------------------- +// Fork of ADM's stixIdentifierSchema that accepts non-official STIX type +// prefixes. The ADM schema only accepts official STIX types (e.g., +// 'attack-pattern', 'identity'). This schema accepts any valid type prefix +// (e.g., 'release-track', 'x-custom-type'). + +const customStixIdentifierSchema = z + .string() + .refine((val) => val.includes('--') && val.split('--').length === 2, { + message: "Invalid identifier: must comply with format 'type--UUIDv4'", + }) + .refine( + (val) => { + const [type] = val.split('--'); + return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(type); + }, + { + error: (issue) => ({ + message: `Invalid identifier: '${issue.input.split('--')[0]}' is not a valid type prefix`, + }), + }, + ) + .refine( + (val) => { + const [, uuid] = val.split('--'); + return z.uuid().safeParse(uuid).success; + }, + { + message: 'Invalid identifier: contains invalid UUIDv4 format', + }, + ); + +function createCustomStixIdValidator(expectedType) { + return customStixIdentifierSchema.refine((val) => val.startsWith(`${expectedType}--`), { + message: `Invalid identifier: must start with '${expectedType}--'`, + }); +} + +// Prebuilt schema for release track IDs +const releaseTrackIdSchema = createCustomStixIdValidator('release-track'); + +// ----------------------------------------------------------------------------- +// Track name +// ----------------------------------------------------------------------------- + +const trackNameSchema = z + .string() + .min(1, { message: 'Release track name must not be empty' }) + .regex(/^[a-zA-Z0-9 ]+$/, { + message: 'Release track name may only contain alphanumeric characters and spaces', + }); + +// ----------------------------------------------------------------------------- +// Cron expression +// See: https://github.com/colinhacks/zod/issues/4239#issuecomment-3161393771 +// ----------------------------------------------------------------------------- + +const wildcardSchema = z.literal('*'); + +const stepValueSchema = z.enum(Array.from({ length: 9_999 }, (_, i) => String(i + 1))); + +function createCronFieldSchema(min, max) { + const integerSchema = z + .enum(Array.from({ length: max - min + 1 }, (_, i) => String(min + i))) + .transform(Number); + + const rangeSchema = z.templateLiteral([z.int(), z.literal('-'), z.int()]).refine((value) => { + const [start, end] = value.split('-'); + const startResult = integerSchema.safeParse(start); + const endResult = integerSchema.safeParse(end); + return startResult.success && endResult.success && startResult.data <= endResult.data; + }); + + const wildcardOrRangeSchema = wildcardSchema.or(rangeSchema); + + const stepSchema = z + .templateLiteral([wildcardOrRangeSchema, z.literal('/'), z.int()]) + .refine((value) => { + const [base, step] = value.split('/'); + return ( + wildcardOrRangeSchema.safeParse(base).success && stepValueSchema.safeParse(step).success + ); + }); + + const fieldSchema = z.string().refine((value) => { + return value + .split(',') + .every( + (part) => wildcardOrRangeSchema.or(integerSchema).or(stepSchema).safeParse(part).success, + ); + }); + + return fieldSchema; +} + +const minuteSchema = createCronFieldSchema(0, 59); +const hourSchema = createCronFieldSchema(0, 23); +const dayOfMonthSchema = createCronFieldSchema(1, 31); +const monthSchema = createCronFieldSchema(1, 12); +const dayOfWeekSchema = createCronFieldSchema(0, 6); + +const cronSchema = z + .string() + .transform((value) => value.trim().split(/\s+/)) + .refine((fields) => fields.length === 5, { + message: 'Invalid cron expression: expected 5 fields', + }) + .refine((fields) => minuteSchema.safeParse(fields[0]).success, { + message: 'Invalid cron expression: invalid minute field', + }) + .refine((fields) => hourSchema.safeParse(fields[1]).success, { + message: 'Invalid cron expression: invalid hour field', + }) + .refine((fields) => dayOfMonthSchema.safeParse(fields[2]).success, { + message: 'Invalid cron expression: invalid day of month field', + }) + .refine((fields) => monthSchema.safeParse(fields[3]).success, { + message: 'Invalid cron expression: invalid month field', + }) + .refine((fields) => dayOfWeekSchema.safeParse(fields[4]).success, { + message: 'Invalid cron expression: invalid day of week field', + }) + .transform((fields) => fields.join(' ')); + +// ============================================================================= +// Query parameter schemas (used inline by controller handlers) +// ============================================================================= + +const domainParamSchema = z.enum(['enterprise', 'ics', 'mobile']); + +const formatQuerySchema = z.enum(['snapshot', 'bundle', 'filesystemstore', 'workbench']); + +const includeQuerySchema = z.enum(['staged', 'candidates', 'all']); + +const trackTypeQuerySchema = z.enum(['standard', 'virtual']); + +const bumpTypeSchema = z.enum(['major', 'minor']); + +const workflowStatusSchema = z.enum(['work-in-progress', 'awaiting-review', 'reviewed']); + +const candidacyThresholdSchema = z.enum(['work-in-progress', 'awaiting-review', 'reviewed']); + +const deduplicationStrategySchema = z.enum([ + 'prioritize_latest_object', + 'prioritize_latest_snapshot', + 'prioritize_higher_priority', + 'quarantine', +]); + +const resolutionStrategySchema = z.enum(['latest_tagged', 'specific_version', 'specific_snapshot']); + +const conflictPolicySchema = z.enum([ + 'prefer_latest', + 'always_overwrite', + 'always_reject', + 'abort', +]); + +// Member sync schemas +const memberSyncStrategySchema = z.enum(['track_latest', 'manual']); +const memberSyncSupplantBehaviorSchema = z.enum(['replace', 'queue', 'ignore']); +const memberSyncStatusPolicySchema = z.enum(['reset', 'preserve']); + +const memberSyncSupplantSchema = z.object({ + behavior: memberSyncSupplantBehaviorSchema.optional(), + status_policy: memberSyncStatusPolicySchema.optional(), +}); + +const memberSyncConfigSchema = z.object({ + strategy: memberSyncStrategySchema.optional(), + supplant: memberSyncSupplantSchema.optional(), +}); + +// ============================================================================= +// Request body schemas (used inline by controller handlers) +// ============================================================================= + +/** POST /release-tracks/new */ +const snapshotScheduleSchema = z.object({ + mode: z.enum(['manual', 'cron', 'dates']), + cron: cronSchema.optional(), + dates: z.array(z.iso.datetime()).optional(), +}); + +const componentTrackSchema = z.object({ + track_id: releaseTrackIdSchema, + resolution_strategy: resolutionStrategySchema, + priority: z.number().int().min(0).optional(), + version: xMitreVersionSchema.optional(), + snapshot: z.iso.datetime().optional(), + filters: z + .object({ + object_types: z.array(z.string()).optional(), + domains: z.array(z.string()).optional(), + }) + .optional(), +}); + +const compositionSchema = z.object({ + component_tracks: z.array(componentTrackSchema).min(1), + deduplication: z + .object({ + strategy: deduplicationStrategySchema, + }) + .optional(), +}); + +const createTrackBodySchema = z.object({ + name: trackNameSchema, + description: z.string().optional(), + type: trackTypeQuerySchema.default('standard'), + object_marking_refs: z.array(stixIdentifierSchema).optional(), + composition: compositionSchema.optional(), + snapshot_schedule: snapshotScheduleSchema.optional(), +}); + +/** POST /release-tracks/new-from-bundle */ +const createFromBundleBodySchema = z.object({ + type: z.literal('bundle'), + id: stixIdentifierSchema, + objects: z.array(z.looseObject({})).min(1), +}); + +/** POST /release-tracks/:id/meta */ +const updateMetadataBodySchema = z.object({ + name: trackNameSchema.optional(), + description: z.string().optional(), + object_marking_refs: z.array(stixIdentifierSchema).optional(), +}); + +/** POST /release-tracks/:id/contents */ +const updateContentsBodySchema = z.object({ + x_mitre_contents: z + .array( + z.object({ + obj_ref: stixIdentifierSchema, + obj_modified: z.iso.datetime().or(z.literal('latest')), + }), + ) + .min(1), +}); + +/** POST /release-tracks/:id/bump */ +const bumpBodySchema = z.object({ + type: bumpTypeSchema.optional(), + version: xMitreVersionSchema.optional(), + dry_run: z.boolean().optional(), +}); + +/** POST /release-tracks/:id/clone */ +const cloneBodySchema = z + .object({ + name: trackNameSchema.optional(), + }) + .optional(); + +/** POST /release-tracks/:id/candidates */ +const objectRefEntrySchema = z.union([ + stixIdentifierSchema, + z.object({ + id: stixIdentifierSchema, + modified: z.iso.datetime().or(z.literal('latest')).optional(), + }), +]); + +const addCandidatesBodySchema = z.object({ + object_refs: z.array(objectRefEntrySchema).min(1), +}); + +/** POST /release-tracks/:id/candidates/review */ +const reviewCandidatesBodySchema = z.object({ + from: workflowStatusSchema, + to: workflowStatusSchema, + object_refs: z + .array( + z.union([ + stixIdentifierSchema, + z.object({ + id: stixIdentifierSchema, + modified: z.iso.datetime().optional(), + }), + ]), + ) + .optional(), +}); + +/** POST /release-tracks/:id/candidates/promote */ +const promoteCandidatesBodySchema = z.object({ + object_refs: z.array(stixIdentifierSchema).min(1), +}); + +/** POST /release-tracks/:id/staged/demote */ +const demoteStagedBodySchema = z.object({ + object_refs: z + .array( + z.object({ + id: stixIdentifierSchema, + modified: z.iso.datetime(), + }), + ) + .min(1), +}); + +/** POST /release-tracks/:id/candidates/:objectRef/update-version */ +const updateCandidateVersionBodySchema = z.object({ + old_modified: z.iso.datetime(), + new_modified: z.iso.datetime(), +}); + +/** PUT /release-tracks/:id/config */ +const promotionConflictsSchema = z.object({ + candidates_to_staged: conflictPolicySchema.exclude(['abort']).optional(), + staged_to_members: conflictPolicySchema.optional(), +}); + +const updateConfigBodySchema = z.object({ + candidacy_threshold: candidacyThresholdSchema.optional(), + auto_promote: z.boolean().optional(), + promotion_conflicts: promotionConflictsSchema.optional(), + member_sync: memberSyncConfigSchema.optional(), +}); + +/** PUT /release-tracks/:id/composition */ +const updateCompositionBodySchema = compositionSchema; + +/** POST /release-tracks/:id/snapshots/create */ +const createVirtualSnapshotBodySchema = z + .object({ + description: z.string().optional(), + }) + .optional(); + +// ============================================================================= +// Exports +// ============================================================================= + +module.exports = { + // Custom STIX identifiers (extends ADM for non-official type prefixes) + customStixIdentifierSchema, + createCustomStixIdValidator, + releaseTrackIdSchema, + + // Domain schemas + trackNameSchema, + cronSchema, + + // Re-exports from @mitre-attack/attack-data-model + stixIdentifierSchema, + xMitreVersionSchema, + createStixIdValidator, + + // Query parameter schemas + domainParamSchema, + formatQuerySchema, + includeQuerySchema, + trackTypeQuerySchema, + bumpTypeSchema, + workflowStatusSchema, + candidacyThresholdSchema, + deduplicationStrategySchema, + resolutionStrategySchema, + conflictPolicySchema, + memberSyncStrategySchema, + memberSyncSupplantBehaviorSchema, + memberSyncStatusPolicySchema, + + // Request body schemas + createTrackBodySchema, + createFromBundleBodySchema, + updateMetadataBodySchema, + updateContentsBodySchema, + bumpBodySchema, + cloneBodySchema, + addCandidatesBodySchema, + reviewCandidatesBodySchema, + promoteCandidatesBodySchema, + demoteStagedBodySchema, + updateCandidateVersionBodySchema, + updateConfigBodySchema, + updateCompositionBodySchema, + createVirtualSnapshotBodySchema, + + // Reusable sub-schemas + componentTrackSchema, + compositionSchema, + snapshotScheduleSchema, + objectRefEntrySchema, + promotionConflictsSchema, + memberSyncConfigSchema, + memberSyncSupplantSchema, +}; diff --git a/app/lib/release-tracks/release-track-validators.js b/app/lib/release-tracks/release-track-validators.js new file mode 100644 index 00000000..3890a22d --- /dev/null +++ b/app/lib/release-tracks/release-track-validators.js @@ -0,0 +1,79 @@ +'use strict'; + +// ============================================================================= +// Mongoose custom validators for release track model schemas. +// +// Each export is a { validator, message } object compatible with Mongoose's +// custom validator interface. Internally they delegate to the Zod schemas +// defined in ./release-track-schemas.js. +// +// See: https://mongoosejs.com/docs/validation.html#custom-validators +// ============================================================================= + +const { + releaseTrackIdSchema, + trackNameSchema, + cronSchema, + stixIdentifierSchema, + xMitreVersionSchema, + createStixIdValidator, +} = require('./release-track-schemas'); + +// ----------------------------------------------------------------------------- +// Mongoose validators +// ----------------------------------------------------------------------------- + +const validateTrackId = { + validator: (v) => releaseTrackIdSchema.safeParse(v).success, + message: (props) => + `"${props.value}" is not a valid release track ID (expected "release-track--")`, +}; + +const validateTrackName = { + validator: (v) => trackNameSchema.safeParse(v).success, + message: (props) => + `"${props.value}" is not a valid release track name (only alphanumeric characters and spaces allowed)`, +}; + +const validateStixId = { + validator: (v) => stixIdentifierSchema.safeParse(v).success, + message: (props) => `"${props.value}" is not a valid STIX ID (expected "--")`, +}; + +const validateIdentityRef = { + validator: (v) => createStixIdValidator('identity').safeParse(v).success, + message: (props) => + `"${props.value}" is not a valid identity reference (expected "identity--")`, +}; + +const validateMarkingDefRefs = { + validator: (v) => + v.every((ref) => createStixIdValidator('marking-definition').safeParse(ref).success), + message: () => + 'Each marking reference must be a valid marking-definition ID (expected "marking-definition--")', +}; + +const validateVersion = { + validator: (v) => v === null || xMitreVersionSchema.safeParse(v).success, + message: (props) => + `"${props.value}" is not a valid version (expected MAJOR.MINOR format, e.g. "1.0")`, +}; + +const validateCron = { + validator: (v) => cronSchema.safeParse(v).success, + message: (props) => `"${props.value}" is not a valid cron expression (expected 5 fields)`, +}; + +// ============================================================================= +// Exports +// ============================================================================= + +module.exports = { + validateTrackId, + validateTrackName, + validateStixId, + validateIdentityRef, + validateMarkingDefRefs, + validateVersion, + validateCron, +}; diff --git a/app/lib/release-tracks/version-utils.js b/app/lib/release-tracks/version-utils.js new file mode 100644 index 00000000..917b5df2 --- /dev/null +++ b/app/lib/release-tracks/version-utils.js @@ -0,0 +1,112 @@ +'use strict'; + +// ============================================================================= +// Version Utilities +// +// Parsing, comparison, calculation, and validation for MAJOR.MINOR version +// strings used by release track tagging. +// +// ATT&CK release tracks use a two-part versioning scheme (MAJOR.MINOR), +// not three-part semver. See docs/COLLECTIONS_V2/03_VERSIONING.md. +// ============================================================================= + +const { InvalidVersionError } = require('../../exceptions'); + +const VERSION_PATTERN = /^\d+\.\d+$/; + +/** + * Parse a version string into its numeric components. + * + * @param {string} str - Version string in "MAJOR.MINOR" format + * @returns {{ major: number, minor: number }} + * @throws {InvalidVersionError} If the string is not a valid version + */ +exports.parseVersion = function parseVersion(str) { + if (!str || !VERSION_PATTERN.test(str)) { + throw new InvalidVersionError(`Invalid version format: "${str}" (expected MAJOR.MINOR)`); + } + const [major, minor] = str.split('.').map(Number); + return { major, minor }; +}; + +/** + * Compare two version strings. + * + * @param {string} a - First version + * @param {string} b - Second version + * @returns {number} -1 if a < b, 0 if a === b, 1 if a > b + */ +exports.compareVersions = function compareVersions(a, b) { + const va = exports.parseVersion(a); + const vb = exports.parseVersion(b); + + if (va.major !== vb.major) return va.major < vb.major ? -1 : 1; + if (va.minor !== vb.minor) return va.minor < vb.minor ? -1 : 1; + return 0; +}; + +/** + * Calculate the next version based on version history and bump type. + * + * If an explicit version is provided, it is returned as-is (validation + * is handled separately by validateVersionProgression). + * + * If the version history is empty, the first version defaults to "1.0". + * + * @param {Array<{ version: string }>} versionHistory - Existing version history entries + * @param {string} [bumpType='minor'] - 'major' or 'minor' + * @param {string} [explicitVersion] - Explicit version override + * @returns {string} The calculated version string + */ +exports.calculateNextVersion = function calculateNextVersion( + versionHistory, + bumpType, + explicitVersion, +) { + if (explicitVersion) { + // Validate format only; monotonicity is checked by validateVersionProgression + exports.parseVersion(explicitVersion); + return explicitVersion; + } + + if (!versionHistory || versionHistory.length === 0) { + return '1.0'; + } + + // Find the highest existing version (history may not be sorted) + let highest = null; + for (const entry of versionHistory) { + if (!highest || exports.compareVersions(entry.version, highest) > 0) { + highest = entry.version; + } + } + + const { major, minor } = exports.parseVersion(highest); + const type = bumpType || 'minor'; + + return type === 'major' ? `${major + 1}.0` : `${major}.${minor + 1}`; +}; + +/** + * Validate that a new version is strictly greater than all existing versions. + * + * @param {string} newVersion - The version to validate + * @param {Array<{ version: string }>} versionHistory - Existing version history entries + * @throws {InvalidVersionError} If the version is not greater than all existing versions + */ +exports.validateVersionProgression = function validateVersionProgression( + newVersion, + versionHistory, +) { + if (!versionHistory || versionHistory.length === 0) { + return; // No history — any valid version is acceptable + } + + for (const entry of versionHistory) { + if (exports.compareVersions(newVersion, entry.version) <= 0) { + throw new InvalidVersionError( + `Version "${newVersion}" must be greater than existing version "${entry.version}"`, + ); + } + } +}; diff --git a/app/lib/validation-schemas.js b/app/lib/validation-schemas.js new file mode 100644 index 00000000..c68f6a4b --- /dev/null +++ b/app/lib/validation-schemas.js @@ -0,0 +1,142 @@ +'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, +}; diff --git a/app/lib/workflow-result.js b/app/lib/workflow-result.js new file mode 100644 index 00000000..182989ed --- /dev/null +++ b/app/lib/workflow-result.js @@ -0,0 +1,162 @@ +'use strict'; + +/** + * DTO builder for universal workflow endpoint responses. + * + * All workflow endpoints (revoke, convert-to-subtechnique, convert-to-technique) + * return a WorkflowResult so that the caller has full visibility into every + * object that was created, modified, deprecated, or deleted as a consequence + * of their request. + * + * @see docs/developer/workflow-response-pattern.md + */ +class WorkflowResult { + /** + * @param {string} workflowName - Discriminator string (e.g., 'revoke', 'convert-to-subtechnique') + */ + constructor(workflowName) { + this.workflow = workflowName; + this.primary = null; + this.sideEffects = { + created: [], + modified: [], + deprecated: [], + deleted: { count: 0, stixIds: [] }, + }; + this.warnings = []; + } + + /** + * Set the primary object that the user acted on. + * @param {Object} document - Full workspace+stix document (Mongoose doc or plain object) + */ + setPrimary(document) { + this.primary = document; + } + + /** + * Add one or more documents to the created side-effects list. + * @param {Object|Array} docOrDocs - Document(s) created as a consequence + */ + addCreated(docOrDocs) { + this._pushDocs(this.sideEffects.created, docOrDocs); + } + + /** + * Add one or more documents to the modified side-effects list. + * @param {Object|Array} docOrDocs - Document(s) modified as a consequence + */ + addModified(docOrDocs) { + this._pushDocs(this.sideEffects.modified, docOrDocs); + } + + /** + * Add one or more documents to the deprecated side-effects list. + * @param {Object|Array} docOrDocs - Document(s) deprecated as a consequence + */ + addDeprecated(docOrDocs) { + this._pushDocs(this.sideEffects.deprecated, docOrDocs); + } + + /** + * Record hard-deleted documents by their STIX IDs. + * @param {Array} stixIds - STIX IDs of deleted documents + */ + addDeleted(stixIds) { + if (!Array.isArray(stixIds)) return; + this.sideEffects.deleted.stixIds.push(...stixIds); + this.sideEffects.deleted.count = this.sideEffects.deleted.stixIds.length; + } + + /** + * Add a single warning. + * @param {string|Object} message - Warning string or structured warning object + */ + addWarning(message) { + this.warnings.push(message); + } + + /** + * Add multiple warnings. + * @param {Array} messages - Warning strings or structured warning objects + */ + addWarnings(messages) { + if (!Array.isArray(messages)) return; + this.warnings.push(...messages); + } + + /** + * Merge results returned by EventBus.emit() into this WorkflowResult. + * + * Each element in eventResults is an object returned by an event handler + * with any subset of: { created, modified, deprecated, warnings }. + * + * @param {Array} eventResults - Array of handler return values + */ + mergeEventResults(eventResults) { + if (!Array.isArray(eventResults)) return; + for (const handlerResult of eventResults) { + if (!handlerResult || typeof handlerResult !== 'object') continue; + if (handlerResult.created) this.addCreated(handlerResult.created); + if (handlerResult.modified) this.addModified(handlerResult.modified); + if (handlerResult.deprecated) this.addDeprecated(handlerResult.deprecated); + if (handlerResult.warnings) this.addWarnings(handlerResult.warnings); + } + } + + /** + * Serialize to a plain JSON-safe object. + * + * Calls .toObject() on any Mongoose documents and strips internal fields + * (_id, __v, __t) from all documents. + * + * @returns {Object} Plain object suitable for res.json() + */ + toJSON() { + return { + workflow: this.workflow, + primary: WorkflowResult._toPlain(this.primary), + sideEffects: { + created: this.sideEffects.created.map(WorkflowResult._toPlain), + modified: this.sideEffects.modified.map(WorkflowResult._toPlain), + deprecated: this.sideEffects.deprecated.map(WorkflowResult._toPlain), + deleted: { ...this.sideEffects.deleted }, + }, + warnings: [...this.warnings], + }; + } + + // ── Private helpers ────────────────────────────────────────────────── + + /** + * Push one or more documents into an array. + * @param {Array} target + * @param {Object|Array} docOrDocs + * @private + */ + _pushDocs(target, docOrDocs) { + if (Array.isArray(docOrDocs)) { + target.push(...docOrDocs); + } else if (docOrDocs) { + target.push(docOrDocs); + } + } + + /** + * Convert a Mongoose document (or plain object) to a clean plain object. + * Strips _id, __v, __t which are internal Mongoose/MongoDB fields. + * @param {Object} doc + * @returns {Object} + * @private + */ + static _toPlain(doc) { + if (!doc) return doc; + const plain = typeof doc.toObject === 'function' ? doc.toObject() : { ...doc }; + delete plain._id; + delete plain.__v; + delete plain.__t; + return plain; + } +} + +module.exports = WorkflowResult; diff --git a/app/models/analytic-model.js b/app/models/analytic-model.js index 70da2cdc..5f515b57 100644 --- a/app/models/analytic-model.js +++ b/app/models/analytic-model.js @@ -31,8 +31,8 @@ const stixAnalytic = { x_mitre_attack_spec_version: String, x_mitre_domains: { type: [String], default: undefined }, x_mitre_platforms: { type: [String], default: undefined }, - x_mitre_log_source_references: [logSourceReferenceSchema], - x_mitre_mutable_elements: [mutableElementSchema], + x_mitre_log_source_references: { type: [logSourceReferenceSchema], default: undefined }, + x_mitre_mutable_elements: { type: [mutableElementSchema], default: undefined }, }; // Create the definition diff --git a/app/models/attack-object-model.js b/app/models/attack-object-model.js index a338c6df..2349b451 100644 --- a/app/models/attack-object-model.js +++ b/app/models/attack-object-model.js @@ -4,8 +4,6 @@ const mongoose = require('mongoose'); const workspaceDefinitions = require('./subschemas/workspace'); const stixCoreDefinitions = require('./subschemas/stix-core'); -const config = require('../config/config'); - // Create the definition const attackObjectDefinition = { workspace: { @@ -20,21 +18,29 @@ const attackObjectDefinition = { // Create the schema const options = { collection: 'attackObjects', + toJSON: { + transform(_doc, ret) { + delete ret._id; + delete ret.__v; + delete ret.__t; + return ret; + }, + }, + toObject: { + transform(_doc, ret) { + delete ret._id; + delete ret.__v; + delete ret.__t; + return ret; + }, + }, }; const attackObjectSchema = new mongoose.Schema(attackObjectDefinition, options); -//Save the ATT&CK ID in a more easily queried location -attackObjectSchema.pre('save', function (next) { - if (this.stix.external_references) { - const mitreAttackReference = this.stix.external_references.find((externalReference) => - config.attackSourceNames.includes(externalReference.source_name), - ); - if (mitreAttackReference && mitreAttackReference.external_id) { - this.workspace.attack_id = mitreAttackReference.external_id; - } - } - return next(); -}); +// Note: workspace.attack_id is now managed by the service layer (BaseService) +// and external_references are built from workspace.attack_id, not the other way around. +// The pre-save hook that used to extract attack_id from external_references has been removed +// to avoid circular dependencies with the new external reference builder. // Add an index on stix.id and stix.modified // This improves the efficiency of queries and enforces uniqueness on this combination of properties diff --git a/app/models/campaign-model.js b/app/models/campaign-model.js index 57155e2d..2112f749 100644 --- a/app/models/campaign-model.js +++ b/app/models/campaign-model.js @@ -10,18 +10,18 @@ const stixCampaign = { modified: { type: Date, required: true }, name: { type: String, required: true }, description: String, - aliases: [String], - first_seen: { type: Date, required: true }, - last_seen: { type: Date, required: true }, + aliases: { type: [String], default: undefined }, + first_seen: Date, + last_seen: Date, // ATT&CK custom stix properties - x_mitre_first_seen_citation: { type: String, required: true }, - x_mitre_last_seen_citation: { type: String, required: true }, + x_mitre_first_seen_citation: String, + x_mitre_last_seen_citation: String, x_mitre_modified_by_ref: String, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], + x_mitre_contributors: { type: [String], default: undefined }, }; // Create the definition diff --git a/app/models/collection-model.js b/app/models/collection-model.js index 4261acee..adad500b 100644 --- a/app/models/collection-model.js +++ b/app/models/collection-model.js @@ -18,7 +18,7 @@ const xMitreCollection = { description: String, x_mitre_modified_by_ref: String, - x_mitre_contents: [xMitreContentSchema], + x_mitre_contents: { type: [xMitreContentSchema], default: undefined }, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_domains: { type: [String], default: undefined }, x_mitre_version: String, diff --git a/app/models/data-source-model.js b/app/models/data-source-model.js index f3caea59..8c2e0916 100644 --- a/app/models/data-source-model.js +++ b/app/models/data-source-model.js @@ -13,13 +13,13 @@ const stixDataSource = { // ATT&CK custom stix properties x_mitre_modified_by_ref: String, - x_mitre_platforms: [String], + x_mitre_platforms: { type: [String], default: undefined }, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_domains: { type: [String], default: undefined }, x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], - x_mitre_collection_layers: [String], + x_mitre_contributors: { type: [String], default: undefined }, + x_mitre_collection_layers: { type: [String], default: undefined }, }; // Create the definition diff --git a/app/models/detection-strategy-model.js b/app/models/detection-strategy-model.js index cffa0aa6..7493fd3c 100644 --- a/app/models/detection-strategy-model.js +++ b/app/models/detection-strategy-model.js @@ -13,8 +13,8 @@ const stixDetectionStrategy = { x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_domains: [String], - x_mitre_analytic_refs: [String], + x_mitre_domains: { type: [String], default: undefined }, + x_mitre_analytic_refs: { type: [String], default: undefined }, x_mitre_contributors: { type: [String], default: undefined }, }; diff --git a/app/models/group-model.js b/app/models/group-model.js index 20dfd86a..2846bfac 100644 --- a/app/models/group-model.js +++ b/app/models/group-model.js @@ -12,13 +12,13 @@ const stixIntrusionSet = { description: String, // ATT&CK custom stix properties - aliases: [String], + aliases: { type: [String], default: undefined }, x_mitre_modified_by_ref: String, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_domains: { type: [String], default: undefined }, // TBD drop this property x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], + x_mitre_contributors: { type: [String], default: undefined }, }; // Create the definition diff --git a/app/models/identity-model.js b/app/models/identity-model.js index 81b6e281..e45a2f6f 100644 --- a/app/models/identity-model.js +++ b/app/models/identity-model.js @@ -10,9 +10,9 @@ const identityProperties = { modified: { type: Date, required: true }, name: { type: String, required: true }, description: String, - roles: [String], + roles: { type: [String], default: undefined }, identity_class: String, - sectors: [String], + sectors: { type: [String], default: undefined }, contact_information: String, // ATT&CK custom stix properties diff --git a/app/models/matrix-model.js b/app/models/matrix-model.js index a84f248b..4cdbd496 100644 --- a/app/models/matrix-model.js +++ b/app/models/matrix-model.js @@ -12,7 +12,7 @@ const matrixProperties = { description: String, // ATT&CK custom stix properties - tactic_refs: [String], + tactic_refs: { type: [String], default: undefined }, x_mitre_modified_by_ref: String, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_domains: { type: [String], default: undefined }, // TBD drop this property diff --git a/app/models/release-tracks/model-factory.js b/app/models/release-tracks/model-factory.js new file mode 100644 index 00000000..e4ba1771 --- /dev/null +++ b/app/models/release-tracks/model-factory.js @@ -0,0 +1,73 @@ +'use strict'; + +const mongoose = require('mongoose'); +const logger = require('../../lib/logger'); +const { releaseTrackSnapshotSchema } = require('./release-track-snapshot-schema'); + +/** + * ModelFactory manages dynamic Mongoose model creation and caching for release tracks. + * + * Each release track gets its own MongoDB collection (named by track_id, e.g. "release-track--"). + * This factory creates Mongoose models on demand and caches them so that repeated access + * to the same track reuses the same compiled model. + */ +class ModelFactory { + constructor() { + /** @type {Map} */ + this._cache = new Map(); + } + + /** + * Get or create a cached Mongoose model for a release track collection. + * + * @param {string} trackId - The release track ID (e.g. "release-track--"), + * which is also used as the MongoDB collection name. + * @returns {mongoose.Model} The Mongoose model bound to the track's collection. + */ + getModel(trackId) { + if (this._cache.has(trackId)) { + return this._cache.get(trackId); + } + + // Mongoose model names must be unique per connection. Use the trackId directly + // since it's already globally unique (release-track--). + const model = mongoose.model(trackId, releaseTrackSnapshotSchema, trackId); + this._cache.set(trackId, model); + + logger.verbose(`ModelFactory: Created model for collection "${trackId}"`); + return model; + } + + /** + * Remove a cached model. Call this when a release track is deleted + * so the model doesn't linger in memory. + * + * @param {string} trackId - The release track ID to remove from cache. + */ + removeModel(trackId) { + if (this._cache.has(trackId)) { + // Remove from Mongoose's internal model registry + delete mongoose.connection.models[trackId]; + this._cache.delete(trackId); + logger.verbose(`ModelFactory: Removed model for collection "${trackId}"`); + } + } + + /** + * Ensure indexes are created on a release track's collection. + * Call this after creating a new track to build the indexes defined in the schema. + * + * @param {string} trackId - The release track ID. + * @returns {Promise} + */ + async ensureIndexes(trackId) { + const model = this.getModel(trackId); + await model.ensureIndexes(); + logger.verbose(`ModelFactory: Ensured indexes for collection "${trackId}"`); + } +} + +// Singleton instance -- shared across the application +const modelFactory = new ModelFactory(); + +module.exports = modelFactory; diff --git a/app/models/release-tracks/release-track-registry-model.js b/app/models/release-tracks/release-track-registry-model.js new file mode 100644 index 00000000..3bf2ef4b --- /dev/null +++ b/app/models/release-tracks/release-track-registry-model.js @@ -0,0 +1,83 @@ +'use strict'; + +const mongoose = require('mongoose'); +const { + validateTrackId, + validateTrackName, + validateVersion, + validateCron, +} = require('../../lib/release-tracks/release-track-validators'); + +// --- Sub-schemas --- + +const snapshotScheduleDefinition = { + mode: { + type: String, + enum: ['manual', 'cron', 'dates'], + default: 'manual', + }, + cron: { + type: String, + validate: validateCron, + }, + dates: { type: [Date], default: undefined }, +}; +const snapshotScheduleSchema = new mongoose.Schema(snapshotScheduleDefinition, { _id: false }); + +// --- Registry document definition --- + +const releaseTrackRegistryDefinition = { + track_id: { + type: String, + required: [true, 'Release track ID is required'], + index: { unique: true }, + validate: validateTrackId, + }, + type: { + type: String, + enum: ['standard', 'virtual'], + required: true, + }, + name: { + type: String, + required: [true, 'Release track name is required'], + validate: validateTrackName, + }, + description: { type: String }, + + // Denormalized for fast listing (updated on each snapshot/tag) + latest_snapshot_modified: { type: Date }, + latest_tagged_version: { + type: String, + default: null, + validate: validateVersion, + }, + snapshot_count: { type: Number, default: 0 }, + tagged_release_count: { type: Number, default: 0 }, + + // Virtual tracks only + snapshot_schedule: { type: snapshotScheduleSchema, default: undefined }, + + created_at: { type: Date, required: true }, + updated_at: { type: Date, required: true }, +}; + +// --- Schema creation --- + +const releaseTrackRegistrySchema = new mongoose.Schema(releaseTrackRegistryDefinition, { + collection: 'releaseTrackRegistry', + bufferCommands: false, +}); + +// --- Indexes --- + +releaseTrackRegistrySchema.index({ type: 1 }); + +// --- Model creation --- + +const ReleaseTrackRegistryModel = mongoose.model( + 'ReleaseTrackRegistry', + releaseTrackRegistrySchema, +); + +module.exports = ReleaseTrackRegistryModel; diff --git a/app/models/release-tracks/release-track-snapshot-schema.js b/app/models/release-tracks/release-track-snapshot-schema.js new file mode 100644 index 00000000..bc16ceee --- /dev/null +++ b/app/models/release-tracks/release-track-snapshot-schema.js @@ -0,0 +1,364 @@ +'use strict'; + +const mongoose = require('mongoose'); +const { + validateTrackId, + validateTrackName, + validateStixId, + validateIdentityRef, + validateMarkingDefRefs, + validateVersion, +} = require('../../lib/release-tracks/release-track-validators'); + +// ============================================================================= +// Sub-schemas (all use _id: false to match codebase conventions) +// ============================================================================= + +// --- Tier entry sub-schemas --- + +const memberEntryDefinition = { + object_ref: { + type: String, + required: true, + validate: validateStixId, + }, + object_modified: { type: Date, required: true }, +}; +const memberEntrySchema = new mongoose.Schema(memberEntryDefinition, { _id: false }); + +const stagedEntryDefinition = { + object_ref: { + type: String, + required: true, + validate: validateStixId, + }, + object_modified: { type: Date, required: true }, + object_status: { + type: String, + enum: ['work-in-progress', 'awaiting-review', 'reviewed'], + required: true, + }, + object_staged_at: { type: Date, required: true }, + object_staged_by: { type: String, required: true }, +}; +const stagedEntrySchema = new mongoose.Schema(stagedEntryDefinition, { _id: false }); + +const candidateEntryDefinition = { + object_ref: { + type: String, + required: true, + validate: validateStixId, + }, + object_modified: { type: Date, required: true }, + object_status: { + type: String, + enum: ['work-in-progress', 'awaiting-review', 'reviewed'], + required: true, + }, + object_added_at: { type: Date, required: true }, + object_added_by: { type: String, required: true }, +}; +const candidateEntrySchema = new mongoose.Schema(candidateEntryDefinition, { _id: false }); + +const quarantineEntryDefinition = { + object_ref: { + type: String, + required: true, + validate: validateStixId, + }, + object_modified: { type: Date, required: true }, + source_track_id: { + type: String, + required: true, + validate: validateTrackId, + }, + source_track_name: { type: String, required: true }, + source_snapshot_version: { + type: String, + validate: validateVersion, + }, + conflict_reason: { type: String, required: true }, +}; +const quarantineEntrySchema = new mongoose.Schema(quarantineEntryDefinition, { _id: false }); + +// --- Composition sub-schemas (virtual tracks) --- + +const componentTrackFiltersDefinition = { + object_types: { type: [String], default: undefined }, + domains: { type: [String], default: undefined }, +}; +const componentTrackFiltersSchema = new mongoose.Schema(componentTrackFiltersDefinition, { + _id: false, +}); + +const componentTrackDefinition = { + track_id: { + type: String, + required: true, + validate: validateTrackId, + }, + resolution_strategy: { + type: String, + enum: ['latest_tagged', 'specific_version', 'specific_snapshot'], + required: true, + }, + priority: { type: Number, required: true }, + version: { + type: String, + validate: validateVersion, + }, + snapshot: { type: Date }, + filters: { type: componentTrackFiltersSchema, default: undefined }, +}; +const componentTrackSchema = new mongoose.Schema(componentTrackDefinition, { _id: false }); + +const compositionDefinition = { + component_tracks: { type: [componentTrackSchema], default: undefined }, + deduplication: { + strategy: { + type: String, + enum: [ + 'prioritize_latest_object', + 'prioritize_latest_snapshot', + 'prioritize_higher_priority', + 'quarantine', + ], + }, + }, +}; +const compositionSchema = new mongoose.Schema(compositionDefinition, { _id: false }); + +// --- Composition resolution sub-schemas (virtual tracks) --- + +const componentSnapshotResolutionDefinition = { + track_id: { + type: String, + required: true, + validate: validateTrackId, + }, + track_name: { type: String, required: true }, + track_type: { type: String, required: true }, + resolved_snapshot_id: { type: Date, required: true }, + resolved_version: { + type: String, + validate: validateVersion, + }, + strategy_used: { type: String, required: true }, + filters_applied: { type: componentTrackFiltersSchema, default: undefined }, + total_objects_in_source: { type: Number, required: true }, + objects_after_filter: { type: Number, required: true }, + objects_contributed: { type: Number, required: true }, +}; +const componentSnapshotResolutionSchema = new mongoose.Schema( + componentSnapshotResolutionDefinition, + { _id: false }, +); + +const deduplicationReportDefinition = { + total_objects_before: { type: Number }, + total_objects_after: { type: Number }, + duplicates_found: { type: Number }, + conflicts_resolved: { type: [mongoose.Schema.Types.Mixed], default: undefined }, +}; +const deduplicationReportSchema = new mongoose.Schema(deduplicationReportDefinition, { + _id: false, +}); + +const compositionResolutionDefinition = { + resolved_at: { type: Date }, + component_snapshots: { type: [componentSnapshotResolutionSchema], default: undefined }, + deduplication: { type: deduplicationReportSchema, default: undefined }, + summary: { type: mongoose.Schema.Types.Mixed, default: undefined }, +}; +const compositionResolutionSchema = new mongoose.Schema(compositionResolutionDefinition, { + _id: false, +}); + +// --- Config sub-schemas --- + +const promotionConflictsDefinition = { + 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', + }, +}; +const promotionConflictsSchema = new mongoose.Schema(promotionConflictsDefinition, { _id: false }); + +const includeSecondaryObjectsDefinition = { + enabled: { type: Boolean, default: true }, + status_threshold: { + type: String, + enum: ['work-in-progress', 'awaiting-review', 'reviewed'], + default: 'reviewed', + }, +}; +const includeSecondaryObjectsSchema = new mongoose.Schema(includeSecondaryObjectsDefinition, { + _id: false, +}); + +// --- Member sync sub-schemas --- + +const memberSyncSupplantDefinition = { + behavior: { + type: String, + enum: ['replace', 'queue', 'ignore'], + default: 'replace', + }, + status_policy: { + type: String, + enum: ['reset', 'preserve'], + default: 'reset', + }, +}; +const memberSyncSupplantSchema = new mongoose.Schema(memberSyncSupplantDefinition, { _id: false }); + +const memberSyncDefinition = { + strategy: { + type: String, + enum: ['track_latest', 'manual'], + default: 'track_latest', + }, + supplant: { + type: memberSyncSupplantSchema, + default: () => ({}), + }, +}; +const memberSyncSchema = new mongoose.Schema(memberSyncDefinition, { _id: false }); + +const configDefinition = { + candidacy_threshold: { + type: String, + enum: ['work-in-progress', 'awaiting-review', 'reviewed'], + default: 'reviewed', + }, + auto_promote: { type: Boolean, default: true }, + include_secondary_objects: { type: includeSecondaryObjectsSchema, default: undefined }, + promotion_conflicts: { + type: promotionConflictsSchema, + default: () => ({}), + }, + member_sync: { + type: memberSyncSchema, + default: () => ({}), + }, +}; +const configSchema = new mongoose.Schema(configDefinition, { _id: false }); + +// --- Version history sub-schema --- + +const versionHistoryEntryDefinition = { + version: { + type: String, + required: true, + validate: validateVersion, + }, + tagged_at: { type: Date, required: true }, + tagged_by: { type: String, required: true }, + snapshot_id: { type: Date, required: true }, + summary: { + members_count: { type: Number }, + promoted_count: { type: Number }, + staged_count: { type: Number }, + candidate_count: { type: Number }, + }, + // Virtual tracks only: records which component versions were included + component_versions: { type: mongoose.Schema.Types.Mixed, default: undefined }, +}; +const versionHistoryEntrySchema = new mongoose.Schema(versionHistoryEntryDefinition, { + _id: false, +}); + +// ============================================================================= +// Main snapshot schema +// ============================================================================= + +const releaseTrackSnapshotDefinition = { + // Identity + id: { + type: String, + required: [true, 'Release track ID is required'], + validate: validateTrackId, + }, + type: { + type: String, + enum: ['standard', 'virtual'], + required: true, + }, + + // Snapshot metadata + modified: { type: Date, required: true }, + version: { + type: String, + default: null, + validate: validateVersion, + }, + + // Release track metadata + name: { + type: String, + required: [true, 'Release track name is required'], + validate: validateTrackName, + }, + description: { type: String }, + created: { type: Date, required: true }, + created_by_ref: { + type: String, + validate: validateIdentityRef, + }, + object_marking_refs: { + type: [String], + default: undefined, + validate: validateMarkingDefRefs, + }, + + // --- Standard track tiers --- + members: { type: [memberEntrySchema], default: [] }, + staged: { type: [stagedEntrySchema], default: undefined }, + candidates: { type: [candidateEntrySchema], default: undefined }, + + // --- Virtual track tiers --- + quarantine: { type: [quarantineEntrySchema], default: undefined }, + + // --- Virtual track composition --- + composition: { type: compositionSchema, default: undefined }, + composition_resolution: { type: compositionResolutionSchema, default: undefined }, + + // --- Shared --- + config: { type: configSchema, default: () => ({}) }, + version_history: { type: [versionHistoryEntrySchema], default: [] }, +}; + +const releaseTrackSnapshotSchema = new mongoose.Schema(releaseTrackSnapshotDefinition, { + bufferCommands: false, +}); + +// --- Indexes --- + +// Primary lookup: find snapshot by track id + modified timestamp +releaseTrackSnapshotSchema.index({ id: 1, modified: -1 }, { unique: true }); + +// Find the latest tagged version +releaseTrackSnapshotSchema.index({ id: 1, version: 1 }); + +// ============================================================================= +// Exports +// ============================================================================= + +module.exports = { + releaseTrackSnapshotSchema, + // Export sub-schemas for use in tests or other contexts + memberEntrySchema, + stagedEntrySchema, + candidateEntrySchema, + quarantineEntrySchema, + compositionSchema, + compositionResolutionSchema, + configSchema, + versionHistoryEntrySchema, +}; diff --git a/app/models/software-model.js b/app/models/software-model.js index 0c018f68..01207b03 100644 --- a/app/models/software-model.js +++ b/app/models/software-model.js @@ -14,13 +14,13 @@ const stixMalware = { // ATT&CK custom stix properties x_mitre_modified_by_ref: String, - x_mitre_platforms: [String], + x_mitre_platforms: { type: [String], default: undefined }, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_domains: { type: [String], default: undefined }, x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], - x_mitre_aliases: [String], + x_mitre_contributors: { type: [String], default: undefined }, + x_mitre_aliases: { type: [String], default: undefined }, }; // Create the definition diff --git a/app/models/subschemas/attack-pattern.js b/app/models/subschemas/attack-pattern.js index c1f8745d..4b0052ed 100644 --- a/app/models/subschemas/attack-pattern.js +++ b/app/models/subschemas/attack-pattern.js @@ -7,17 +7,20 @@ module.exports.attackPattern = { modified: { type: Date, required: true }, name: { type: String, required: true }, description: String, - kill_chain_phases: [stixCore.killChainPhaseSchema], + kill_chain_phases: { + type: [stixCore.killChainPhaseSchema], + default: undefined, + }, // ATT&CK custom STIX properties x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], + x_mitre_contributors: { type: [String], default: undefined }, x_mitre_deprecated: { type: Boolean, required: true, default: false }, x_mitre_detection: String, x_mitre_domains: { type: [String], default: undefined }, x_mitre_is_subtechnique: { type: Boolean, required: true, default: false }, x_mitre_modified_by_ref: String, - x_mitre_platforms: [String], + x_mitre_platforms: { type: [String], default: undefined }, x_mitre_version: String, }; diff --git a/app/models/subschemas/stix-core.js b/app/models/subschemas/stix-core.js index e66a947c..849ead53 100644 --- a/app/models/subschemas/stix-core.js +++ b/app/models/subschemas/stix-core.js @@ -48,6 +48,6 @@ module.exports.commonRequiredSDO = { module.exports.commonOptionalSDO = { created_by_ref: { type: String }, revoked: { type: Boolean }, - external_references: [externalReferenceSchema], - object_marking_refs: [String], + external_references: { type: [externalReferenceSchema], default: undefined }, + object_marking_refs: { type: [String], default: undefined }, }; diff --git a/app/models/subschemas/workspace.js b/app/models/subschemas/workspace.js index 7bb99572..7bedcd31 100644 --- a/app/models/subschemas/workspace.js +++ b/app/models/subschemas/workspace.js @@ -8,6 +8,28 @@ const collectionVersion = { }; const collectionVersionSchema = new mongoose.Schema(collectionVersion, { _id: false }); +const embedddedRelationship = { + stix_id: { type: String, required: true }, + attack_id: String, // Immutable, server-generated identifier - safe to denormalize + // Note: 'name' field removed - names are mutable and should be fetched on read + // Services that need names should fetch the full document using stix_id + direction: { + type: String, + // inbound: The embedded relationship points TO this document (I am referenced) + // outbound: The embedded relationship points FROM this document (I reference another) + enum: ['inbound', 'outbound'], + required: true, + }, +}; +const embeddedRelationshipSchema = new mongoose.Schema(embedddedRelationship, { _id: false }); + +const validationIssue = { + message: { type: String, required: true }, + path: [String], + code: { type: String, required: true }, +}; +const validationIssueSchema = new mongoose.Schema(validationIssue, { _id: false }); + /** * Workspace property definition for most object types */ @@ -15,12 +37,19 @@ module.exports.common = { workflow: { state: { type: String, - enum: ['work-in-progress', 'awaiting-review', 'reviewed', 'static'], + enum: ['work-in-progress', 'awaiting-review', 'reviewed', 'static', 'draft'], }, created_by_user_account: String, }, attack_id: String, collections: [collectionVersionSchema], + embedded_relationships: { type: [embeddedRelationshipSchema], default: undefined }, + validation: { + errors: { type: [validationIssueSchema], default: undefined }, + attack_spec_version: String, + adm_version: String, + validated_at: Date, + }, }; // x-mitre-collection workspace structure diff --git a/app/models/system-configuration-model.js b/app/models/system-configuration-model.js index 7092518a..2eb5001f 100644 --- a/app/models/system-configuration-model.js +++ b/app/models/system-configuration-model.js @@ -11,6 +11,7 @@ const systemConfigurationDefinition = { range_start: { type: Number, default: null }, prefix: { type: String, default: null }, }, + created_at: { type: Date, default: Date.now }, }; // Create the schema diff --git a/app/models/tactic-model.js b/app/models/tactic-model.js index 24b5f803..38b995ff 100644 --- a/app/models/tactic-model.js +++ b/app/models/tactic-model.js @@ -17,7 +17,7 @@ const stixTactic = { x_mitre_domains: { type: [String], default: undefined }, x_mitre_version: String, x_mitre_attack_spec_version: String, - x_mitre_contributors: [String], + x_mitre_contributors: { type: [String], default: undefined }, x_mitre_shortname: String, }; diff --git a/app/models/validation-bypass-rule-model.js b/app/models/validation-bypass-rule-model.js new file mode 100644 index 00000000..06d4c1cc --- /dev/null +++ b/app/models/validation-bypass-rule-model.js @@ -0,0 +1,34 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const BypassRuleReasons = require('../lib/bypass-rule-constants'); + +const validationBypassRuleDefinition = { + fieldPath: { type: [String], required: true }, + errorCode: { type: String, required: true }, + stixType: { type: String, required: true }, + suppressError: { type: Boolean, default: true }, + autoCreated: { type: Boolean, default: false }, + autoCreatedReason: { + type: String, + enum: [...Object.values(BypassRuleReasons), null], + default: null, + }, + triggerEvent: { type: String, default: null }, + warningMessage: { type: String, default: null }, +}; + +const validationBypassRuleSchema = new mongoose.Schema(validationBypassRuleDefinition, { + bufferCommands: false, +}); + +// Prevent duplicate rules for the same field/code/type combination +validationBypassRuleSchema.index({ fieldPath: 1, errorCode: 1, stixType: 1 }, { unique: true }); + +const ValidationBypassRuleModel = mongoose.model( + 'ValidationBypassRule', + validationBypassRuleSchema, +); + +module.exports = ValidationBypassRuleModel; diff --git a/app/repository/_abstract.repository.js b/app/repository/_abstract.repository.js index a6eafafe..89d413eb 100644 --- a/app/repository/_abstract.repository.js +++ b/app/repository/_abstract.repository.js @@ -37,7 +37,7 @@ class AbstractRepository { * @param {*} stixId The unique identifier for the document. * @returns {Object} The retrieved document. */ - async retrieveLatestByStixId(stixId) { + async retrieveLatestByStixIdLean(stixId) { throw new NotImplementedError(this.constructor.name, 'retrieveLatestByStixId'); } diff --git a/app/repository/_base.repository.js b/app/repository/_base.repository.js index eac445a1..fbd7abc7 100644 --- a/app/repository/_base.repository.js +++ b/app/repository/_base.repository.js @@ -97,6 +97,10 @@ class BaseRepository extends AbstractRepository { aggregation.push({ $limit: options.limit }); } + // Aggregation bypasses Mongoose toJSON/toObject transforms, so we + // must strip internal fields explicitly via $project. + aggregation.push({ $project: { _id: 0, __v: 0, __t: 0 } }); + // Retrieve the documents const documents = await this.model.aggregate(aggregation).exec(); @@ -153,6 +157,10 @@ class BaseRepository extends AbstractRepository { { $match: query }, ]; + // Aggregation bypasses Mongoose toJSON/toObject transforms, so we + // must strip internal fields explicitly via $project. + aggregation.push({ $project: { _id: 0, __v: 0, __t: 0 } }); + // Bundle export needs ALL matching documents, not a paginated subset const documents = await this.model.aggregate(aggregation).exec(); @@ -171,17 +179,54 @@ class BaseRepository extends AbstractRepository { } } + async retrieveLatestByStixId(stixId) { + try { + return await this.model.findOne({ 'stix.id': stixId }).sort('-stix.modified').exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async retrieveAllById(stixId) { try { - return await this.model.find({ 'stix.id': stixId }).sort('-stix.modified').lean().exec(); + // .lean() bypasses Mongoose toJSON/toObject transforms, so .select() + // is needed to exclude internal fields at the query level. + return await this.model + .find({ 'stix.id': stixId }) + .sort('-stix.modified') + .select('-_id -__v -__t') + .lean() + .exec(); } catch (err) { throw new DatabaseError(err); } } - async retrieveLatestByStixId(stixId) { + /** + * Retrieve the latest version of an object by its ATT&CK ID (e.g., "T1234", "G0001"). + * + * @param {string} attackId - The workspace ATT&CK ID to look up + * @returns {Promise} The latest object version, or null if not found + */ + async retrieveLatestByAttackId(attackId) { try { - return await this.model.findOne({ 'stix.id': stixId }).sort('-stix.modified').lean().exec(); + return await this.model + .findOne({ 'workspace.attack_id': attackId }) + .sort('-stix.modified') + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async retrieveLatestByStixIdLean(stixId) { + try { + return await this.model + .findOne({ 'stix.id': stixId }) + .sort('-stix.modified') + .select('-_id -__v -__t') + .lean() + .exec(); } catch (err) { throw new DatabaseError(err); } @@ -232,7 +277,7 @@ class BaseRepository extends AbstractRepository { })); // Use cursor for true streaming - const cursor = this.model.find({ $or: conditions }).lean().cursor(); + const cursor = this.model.find({ $or: conditions }).select('-_id -__v -__t').lean().cursor(); let count = 0; for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) { @@ -301,7 +346,11 @@ class BaseRepository extends AbstractRepository { 'stix.modified': object_modified, })); - const documents = await this.model.find({ $or: conditions }).lean().exec(); + const documents = await this.model + .find({ $or: conditions }) + .select('-_id -__v -__t') + .lean() + .exec(); const queryTime = Date.now() - startTime; logger.debug( @@ -340,6 +389,7 @@ class BaseRepository extends AbstractRepository { const document = new this.model(data); return await document.save(); } catch (err) { + logger.error(`A database error occurred: ${err.message}`); if (err.name === 'MongoServerError' && err.code === 11000) { throw new DuplicateIdError({ details: `Document with id '${data.stix.id}' already exists.`, @@ -349,6 +399,124 @@ class BaseRepository extends AbstractRepository { } } + /** + * Bulk insert. Used by the STIX bundle import path to avoid one round-trip + * per object. + * + * `ordered: false` keeps MongoDB inserting the remaining docs after an + * individual failure. `throwOnValidationError: true` is critical: without + * it, Mongoose's `insertMany` silently drops documents that fail schema + * validation (e.g. a required field is missing) and reports success for + * the remaining valid docs — leaving the caller unable to record per-object + * import errors. With the flag, Mongoose throws a `MongooseBulkWriteError` + * after attempting the valid docs, carrying both the validation errors and + * the `results` array we use to map each failure back to its source index. + * + * Discriminator-aware: each child model's `insertMany` sets the correct + * `__t` discriminator key automatically, so callers should invoke this on + * the type-specific repository (not the AttackObject parent). + * + * @param {Array} dataArr - Array of plain objects to insert + * @param {Object} [options] + * @param {boolean} [options.ordered=false] - Stop on first error if true + * @returns {Promise<{ inserted: Array, errors: Array<{ index, message, code }> }>} + * `errors[].index` is the index into the input `dataArr`; the caller can + * use it to recover the original document for error reporting. + */ + async saveMany(dataArr, { ordered = false } = {}) { + if (!Array.isArray(dataArr) || dataArr.length === 0) { + return { inserted: [], errors: [] }; + } + try { + const inserted = await this.model.insertMany(dataArr, { + ordered, + throwOnValidationError: true, + }); + return { inserted, errors: [] }; + } catch (err) { + // MongooseBulkWriteError: one or more docs failed Mongoose schema + // validation. `err.results` mirrors the input order — successfully + // inserted entries are Mongoose documents (identifiable by `_id`), + // while failures are the original input objects (no `_id`). Walking + // the results in order, the k-th failure corresponds to + // `err.validationErrors[k]` (Mongoose pre-sorts validationErrors by + // source index). + if (err?.name === 'MongooseBulkWriteError') { + const errors = []; + const inserted = []; + const validationErrors = err.validationErrors || []; + const results = err.results || []; + let veIdx = 0; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (r && r._id) { + inserted.push(r); + } else { + const ve = validationErrors[veIdx++]; + errors.push({ + index: i, + message: ve?.message ?? 'Mongoose validation error', + code: ve?.name || 'ValidationError', + }); + } + } + return { inserted, errors }; + } + // MongoDB driver-side failure (e.g., duplicate-key race). Per-doc + // errors are on `err.writeErrors`; successful inserts on + // `err.insertedDocs`. + if (err?.name === 'MongoBulkWriteError' || err?.writeErrors) { + const errors = (err.writeErrors || []).map((we) => ({ + index: we.index ?? we.err?.index, + message: we.errmsg || we.err?.errmsg || we.message, + code: we.code || we.err?.code, + })); + return { inserted: err.insertedDocs || [], errors }; + } + throw new DatabaseError(err); + } + } + + /** + * Retrieve every version of every document whose `stix.id` is in `stixIds`. + * Returns a Map keyed by stixId, value is an array of versions sorted + * newest-first (matching `retrieveAllById`'s ordering). + * + * Used by the bundle-import path to pre-fetch all existing versions in one + * query instead of N queries (one per imported object). + * + * @param {Array} stixIds - List of STIX IDs to look up + * @returns {Promise>>} + */ + async retrieveAllByStixIds(stixIds) { + if (!Array.isArray(stixIds) || stixIds.length === 0) { + return new Map(); + } + + try { + const documents = await this.model + .find({ 'stix.id': { $in: stixIds } }) + .sort('-stix.modified') + .select('-_id -__v -__t') + .lean() + .exec(); + + const byStixId = new Map(); + for (const doc of documents) { + const id = doc.stix.id; + let arr = byStixId.get(id); + if (!arr) { + arr = []; + byStixId.set(id, arr); + } + arr.push(doc); + } + return byStixId; + } catch (err) { + throw new DatabaseError(err); + } + } + async updateAndSave(document, data) { try { // TODO validate that document is valid mongoose object first @@ -359,6 +527,14 @@ class BaseRepository extends AbstractRepository { } } + async unsetField(documentId, fieldPath) { + try { + return await this.model.updateOne({ _id: documentId }, { $unset: { [fieldPath]: '' } }); + } catch (err) { + throw new DatabaseError(err); + } + } + async findOneAndDelete(stixId, modified) { try { return await this.model diff --git a/app/repository/attack-objects-repository.js b/app/repository/attack-objects-repository.js index 0adba68c..c0e816ca 100644 --- a/app/repository/attack-objects-repository.js +++ b/app/repository/attack-objects-repository.js @@ -95,6 +95,26 @@ class AttackObjectsRepository extends BaseRepository { ]; } + async retrieveAllWithAttackURLInDescription() { + const aggregation = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $sort: { 'stix.id': 1 } }, + { + $match: { + 'stix.revoked': { $in: [null, false] }, + 'stix.x_mitre_deprecated': { $in: [null, false] }, + 'stix.description': { $regex: 'attack.mitre.org', $options: 'i' }, + }, + }, + ]; + + const documents = await this.model.aggregate(aggregation).exec(); + + return documents; + } + // A lean variant of BaseService.retrieveOneByVersion // TODO merge the two methods by supporting method argument 'lean=false' that toggles .lean() on/off async retrieveOneByVersionLean(stixId, modified) { @@ -113,6 +133,33 @@ class AttackObjectsRepository extends BaseRepository { } } + /** + * Retrieve all latest versions of objects whose created_by_ref or x_mitre_modified_by_ref + * matches any of the provided identity refs. Used for organization identity propagation. + * @param {string[]} identityRefs - Array of identity STIX IDs (the provenance chain) + * @returns {Promise} Array of plain objects (latest version per stix.id) + */ + async retrieveAllLatestByOrgIdentityRefs(identityRefs) { + try { + const aggregation = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { + $match: { + $or: [ + { 'stix.created_by_ref': { $in: identityRefs } }, + { 'stix.x_mitre_modified_by_ref': { $in: identityRefs } }, + ], + }, + }, + ]; + return await this.model.aggregate(aggregation).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async findByIdAndDelete(documentId) { try { return await this.model.findByIdAndDelete(documentId).exec(); diff --git a/app/repository/relationships-repository.js b/app/repository/relationships-repository.js index 766e4a4b..24e1ef73 100644 --- a/app/repository/relationships-repository.js +++ b/app/repository/relationships-repository.js @@ -110,6 +110,95 @@ class RelationshipsRepository extends BaseRepository { throw new DatabaseError(err); } } + + async retrieveAllWithAttackURLInDescription() { + const aggregation = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $sort: { 'stix.id': 1 } }, + { + $match: { + 'stix.revoked': { $in: [null, false] }, + 'stix.x_mitre_deprecated': { $in: [null, false] }, + 'stix.description': { $regex: 'attack.mitre.org', $options: 'i' }, + }, + }, + ]; + + return await this.model.aggregate(aggregation).exec(); + } + + /** + * Retrieve the latest version of all relationships where source_ref or target_ref matches the given STIX ID + * @param {string} stixId - The STIX ID to match against source_ref and target_ref + * @returns {Promise} Array of latest-version relationship documents + */ + async retrieveAllBySourceOrTarget(stixId) { + try { + const aggregation = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { + $match: { + $or: [{ 'stix.source_ref': stixId }, { 'stix.target_ref': stixId }], + }, + }, + ]; + return await this.model.aggregate(aggregation).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + /** + * Delete all relationship documents (all versions) where source_ref or target_ref matches, + * excluding relationships with specified STIX IDs + * @param {string} stixId - The STIX ID to match against source_ref and target_ref + * @param {Array} excludeStixIds - STIX IDs of relationships to exclude from deletion + * @returns {Promise<{deletedCount: number}>} Deletion result + */ + async deleteManyBySourceOrTarget(stixId, excludeStixIds = []) { + try { + const query = { + $or: [{ 'stix.source_ref': stixId }, { 'stix.target_ref': stixId }], + }; + if (excludeStixIds.length > 0) { + query['stix.id'] = { $nin: excludeStixIds }; + } + return await this.model.deleteMany(query).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async retrieveParallelRelationships() { + const all_relationships = await this.retrieveAll({ + versions: 'latest', + lookupRefs: true, + }); + + // Create a mapping of rel_key (source_ref--relationship_type--target_ref) + // to an array of relationships that share it. + let rel_map = new Map(); + for (const rel of all_relationships) { + const rel_key = + rel.stix.source_ref + '--' + rel.stix.relationship_type + '--' + rel.stix.target_ref; + if (!rel_map.has(rel_key)) { + rel_map.set(rel_key, []); + } + const entry = rel_map.get(rel_key); + entry.push(rel); + } + + // Return only the rel_keys that have more than one item in the array. + const parallel_relationships = new Map( + [...rel_map.entries()].filter(([, value]) => value.length > 1), + ); + + return parallel_relationships; + } } module.exports = new RelationshipsRepository(Relationship); diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js new file mode 100644 index 00000000..e34be760 --- /dev/null +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -0,0 +1,226 @@ +'use strict'; + +const modelFactory = require('../../models/release-tracks/model-factory'); +const { + DatabaseError, + DuplicateIdError, + BadlyFormattedParameterError, +} = require('../../exceptions'); +const logger = require('../../lib/logger'); + +class ReleaseTrackDynamicRepository { + constructor(factory) { + this.modelFactory = factory; + } + + /** + * Resolve the Mongoose model for a given track. + * @param {string} trackId + * @returns {import('mongoose').Model} + */ + _getModel(trackId) { + return this.modelFactory.getModel(trackId); + } + + async getLatestSnapshot(trackId) { + try { + const Model = this._getModel(trackId); + return await Model.findOne({ id: trackId }).sort({ modified: -1 }).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async getSnapshotByModified(trackId, modified) { + try { + const Model = this._getModel(trackId); + return await Model.findOne({ id: trackId, modified }).lean().exec(); + } catch (err) { + if (err.name === 'CastError') { + throw new BadlyFormattedParameterError({ parameterName: 'modified' }); + } + throw new DatabaseError(err); + } + } + + async getLatestTaggedSnapshot(trackId) { + try { + const Model = this._getModel(trackId); + return await Model.findOne({ + id: trackId, + version: { $ne: null }, + }) + .sort({ modified: -1 }) + .lean() + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async getSnapshotByVersion(trackId, version) { + try { + const Model = this._getModel(trackId); + return await Model.findOne({ id: trackId, version }).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async getAllSnapshots(trackId, options = {}) { + try { + const Model = this._getModel(trackId); + const query = { id: trackId }; + + if (options.taggedOnly) { + query.version = { $ne: null }; + } + + let findQuery = Model.find(query); + + if (options.projection) { + findQuery = findQuery.select(options.projection); + } + + findQuery = findQuery.sort({ modified: -1 }); + + const totalCount = await Model.countDocuments(query).exec(); + + findQuery = findQuery.skip(options.offset || 0); + if (options.limit) { + findQuery = findQuery.limit(options.limit); + } + + const documents = await findQuery.lean().exec(); + + return { + data: documents, + pagination: { + total: totalCount, + offset: options.offset || 0, + limit: options.limit || 0, + }, + }; + } catch (err) { + throw new DatabaseError(err); + } + } + + async saveSnapshot(trackId, snapshotData) { + try { + const Model = this._getModel(trackId); + const document = new Model(snapshotData); + const saved = await document.save(); + return saved.toObject(); + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: `Snapshot with modified '${snapshotData.modified}' already exists for track '${trackId}'.`, + }); + } + throw new DatabaseError(err); + } + } + + async tagSnapshotInPlace(trackId, modified, versionData) { + try { + const Model = this._getModel(trackId); + + const setOps = { version: versionData.version }; + + // Merge additional atomic operations (e.g., staged → members promotion) + if (versionData.additionalOps) { + Object.assign(setOps, versionData.additionalOps); + } + + const result = await Model.findOneAndUpdate( + { + id: trackId, + modified: modified, + version: null, // Guard: only tag untagged snapshots + }, + { + $set: setOps, + $push: { version_history: versionData.versionHistoryEntry }, + }, + { + new: true, + runValidators: true, + lean: true, + }, + ).exec(); + + return result; + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: `Version conflict while tagging snapshot for track '${trackId}'.`, + }); + } + throw new DatabaseError(err); + } + } + + async updateSnapshot(trackId, modified, updateOps) { + try { + const Model = this._getModel(trackId); + const result = await Model.findOneAndUpdate({ id: trackId, modified }, updateOps, { + new: true, + runValidators: true, + }).exec(); + + return result; + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: `Duplicate key conflict while updating snapshot for track '${trackId}'.`, + }); + } + throw new DatabaseError(err); + } + } + + async deleteSnapshot(trackId, modified) { + try { + const Model = this._getModel(trackId); + return await Model.findOneAndDelete({ id: trackId, modified }).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async deleteAllSnapshots(trackId) { + try { + const Model = this._getModel(trackId); + const result = await Model.deleteMany({ id: trackId }).exec(); + logger.verbose( + `DynamicRepository: Deleted ${result.deletedCount} snapshots for track "${trackId}"`, + ); + return result; + } catch (err) { + throw new DatabaseError(err); + } + } + + async dropCollection(trackId) { + try { + const Model = this._getModel(trackId); + await Model.collection.drop(); + logger.verbose(`DynamicRepository: Dropped collection for track "${trackId}"`); + } catch (err) { + // MongoDB throws "ns not found" if the collection doesn't exist -- safe to ignore + if (err.message && err.message.includes('ns not found')) { + logger.verbose( + `DynamicRepository: Collection for track "${trackId}" did not exist, skipping drop`, + ); + } else { + throw new DatabaseError(err); + } + } finally { + // Always clean up the cached model, even if drop failed or collection didn't exist + this.modelFactory.removeModel(trackId); + } + } +} + +module.exports = new ReleaseTrackDynamicRepository(modelFactory); diff --git a/app/repository/release-tracks/release-track-registry.repository.js b/app/repository/release-tracks/release-track-registry.repository.js new file mode 100644 index 00000000..de21751e --- /dev/null +++ b/app/repository/release-tracks/release-track-registry.repository.js @@ -0,0 +1,119 @@ +'use strict'; + +const ReleaseTrackRegistryModel = require('../../models/release-tracks/release-track-registry-model'); +const regexValidator = require('../../lib/regex'); +const { + DatabaseError, + DuplicateIdError, + BadlyFormattedParameterError, +} = require('../../exceptions'); + +class ReleaseTrackRegistryRepository { + constructor(model) { + this.model = model; + } + + async create(data) { + try { + const document = new this.model(data); + const saved = await document.save(); + return saved.toObject(); + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: `Release track with id '${data.track_id}' already exists.`, + }); + } + throw new DatabaseError(err); + } + } + + async findByTrackId(trackId) { + try { + return await this.model.findOne({ track_id: trackId }).lean().exec(); + } catch (err) { + if (err.name === 'CastError') { + throw new BadlyFormattedParameterError({ parameterName: 'trackId' }); + } + throw new DatabaseError(err); + } + } + + async findAll(options = {}) { + try { + const query = {}; + + if (options.type) { + query.type = options.type; + } + + const aggregation = [{ $sort: { name: 1 } }, { $match: query }]; + + if (options.search) { + const sanitized = regexValidator.sanitizeRegex(options.search); + aggregation.push({ + $match: { + $or: [ + { name: { $regex: sanitized, $options: 'i' } }, + { description: { $regex: sanitized, $options: 'i' } }, + ], + }, + }); + } + + // Total count before pagination + const totalCountResult = await this.model.aggregate(aggregation).count('totalCount').exec(); + const totalCount = totalCountResult[0]?.totalCount || 0; + + // Pagination + aggregation.push({ $skip: options.offset || 0 }); + if (options.limit) { + aggregation.push({ $limit: options.limit }); + } + + const documents = await this.model.aggregate(aggregation).exec(); + + return { + data: documents, + pagination: { + total: totalCount, + offset: options.offset || 0, + limit: options.limit || 0, + }, + }; + } catch (err) { + throw new DatabaseError(err); + } + } + + async updateByTrackId(trackId, updates) { + try { + const result = await this.model + .findOneAndUpdate( + { track_id: trackId }, + { $set: updates }, + { new: true, runValidators: true, lean: true }, + ) + .exec(); + + return result; + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: `Duplicate key conflict while updating track '${trackId}'.`, + }); + } + throw new DatabaseError(err); + } + } + + async deleteByTrackId(trackId) { + try { + return await this.model.findOneAndDelete({ track_id: trackId }).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } +} + +module.exports = new ReleaseTrackRegistryRepository(ReleaseTrackRegistryModel); diff --git a/app/repository/system-configurations-repository.js b/app/repository/system-configurations-repository.js index ede51662..c5d84ba4 100644 --- a/app/repository/system-configurations-repository.js +++ b/app/repository/system-configurations-repository.js @@ -18,12 +18,30 @@ class SystemConfigurationsRepository { } } + /** + * Retrieve the latest (most recent) system configuration document. + * Sorts by created_at descending so the newest document is returned. + */ async retrieveOne(options) { options = options ?? {}; if (options.lean) { - return await this.model.findOne().lean(); + return await this.model.findOne().sort({ created_at: -1 }).lean(); } else { - return await this.model.findOne(); + return await this.model.findOne().sort({ created_at: -1 }); + } + } + + /** + * Retrieve all distinct organization_identity_ref values across all config documents. + * Used to determine the full provenance chain of organization identities. + * @returns {Promise} Array of distinct identity ref strings + */ + async retrieveAllDistinctIdentityRefs() { + try { + const refs = await this.model.distinct('organization_identity_ref').exec(); + return refs.filter((ref) => ref != null); + } catch (err) { + throw new DatabaseError(err); } } } diff --git a/app/repository/techniques-repository.js b/app/repository/techniques-repository.js index 86ec081d..2ea6df82 100644 --- a/app/repository/techniques-repository.js +++ b/app/repository/techniques-repository.js @@ -2,7 +2,78 @@ const BaseRepository = require('./_base.repository'); const Technique = require('../models/technique-model'); +const { DatabaseError } = require('../exceptions'); -class TechniqueRepository extends BaseRepository {} +class TechniqueRepository extends BaseRepository { + /** + * Retrieve the latest version of each technique whose kill_chain_phases contains + * a phase with the given phase_name (and optionally matching kill_chain_name). + * Returns plain objects (no _id or internal fields). + * Used when creating new technique versions to propagate a tactic shortname change. + * + * @param {string} phaseName - The phase_name value to filter by + * @param {string[]} [killChainNames] - If non-empty, only match phases whose + * kill_chain_name is in this list (scopes the change to specific domains) + * @returns {Promise>} Array of plain technique objects (latest version each) + */ + async retrieveAllLatestByPhaseName(phaseName, killChainNames = []) { + try { + const phaseMatch = + killChainNames.length > 0 + ? { + 'stix.kill_chain_phases': { + $elemMatch: { + phase_name: phaseName, + kill_chain_name: { $in: killChainNames }, + }, + }, + } + : { 'stix.kill_chain_phases.phase_name': phaseName }; + + const aggregation = [ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $match: phaseMatch }, + { $project: { _id: 0, __v: 0, __t: 0 } }, + ]; + return await this.model.aggregate(aggregation).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + /** + * Bulk-update the phase_name in kill_chain_phases across all technique versions. + * Called when a tactic's x_mitre_shortname changes so that connected techniques + * remain consistent. + * + * When killChainNames is provided, only phases whose kill_chain_name is in the + * list are updated — this prevents cross-domain propagation (e.g. an enterprise + * tactic rename should not affect mobile techniques). + * + * @param {string} oldPhaseName - The previous x_mitre_shortname value + * @param {string} newPhaseName - The new x_mitre_shortname value + * @param {string[]} [killChainNames] - If non-empty, restrict updates to phases + * whose kill_chain_name is in this list + * @returns {Promise} + */ + async updatePhaseName(oldPhaseName, newPhaseName, killChainNames = []) { + try { + const arrayFilter = + killChainNames.length > 0 + ? { 'elem.phase_name': oldPhaseName, 'elem.kill_chain_name': { $in: killChainNames } } + : { 'elem.phase_name': oldPhaseName }; + + return await this.model.updateMany( + { 'stix.kill_chain_phases.phase_name': oldPhaseName }, + { $set: { 'stix.kill_chain_phases.$[elem].phase_name': newPhaseName } }, + { arrayFilters: [arrayFilter] }, + ); + } catch (err) { + throw new DatabaseError(err); + } + } +} module.exports = new TechniqueRepository(Technique); diff --git a/app/repository/user-accounts-repository.js b/app/repository/user-accounts-repository.js index b6c58479..447acbc5 100644 --- a/app/repository/user-accounts-repository.js +++ b/app/repository/user-accounts-repository.js @@ -171,7 +171,7 @@ class UserAccountsRepository { } } catch (err) { if (err.name === 'CastError') { - throw new BadlyFormattedParameterError('userId'); + throw new BadlyFormattedParameterError({ parameterName: 'userId' }); } else { throw err; } diff --git a/app/repository/validation-bypasses-repository.js b/app/repository/validation-bypasses-repository.js new file mode 100644 index 00000000..a2a4ab47 --- /dev/null +++ b/app/repository/validation-bypasses-repository.js @@ -0,0 +1,93 @@ +'use strict'; + +const ValidationBypassRule = require('../models/validation-bypass-rule-model'); +const { DuplicateIdError, DatabaseError } = require('../exceptions'); + +class ValidationBypassesRepository { + constructor(model) { + this.model = model; + } + + async retrieveAll(options) { + const aggregation = [{ $sort: { stixType: 1 } }]; + + const totalCount = await this.model.aggregate(aggregation).count('totalCount').exec(); + + if (options.offset) { + aggregation.push({ $skip: options.offset }); + } else { + aggregation.push({ $skip: 0 }); + } + + if (options.limit) { + aggregation.push({ $limit: options.limit }); + } + + const documents = await this.model.aggregate(aggregation).exec(); + + return [ + { + totalCount: [{ totalCount: totalCount[0]?.totalCount || 0 }], + documents: documents, + }, + ]; + } + + async save(data) { + const document = new this.model(data); + try { + return await document.save(); + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateIdError({ + details: + 'A validation bypass rule with this fieldPath, errorCode, and stixType already exists.', + }); + } else { + throw new DatabaseError(err); + } + } + } + + async retrieveById(id) { + try { + return await this.model.findById(id).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async deleteById(id) { + try { + return await this.model.findByIdAndDelete(id).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async deleteAutoCreated() { + try { + return await this.model.deleteMany({ autoCreated: true }).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async deleteByReason(reason) { + try { + return await this.model.deleteMany({ autoCreated: true, autoCreatedReason: reason }).exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async findAll() { + try { + return await this.model.find({}).lean().exec(); + } catch (err) { + throw new DatabaseError(err); + } + } +} + +module.exports = new ValidationBypassesRepository(ValidationBypassRule); diff --git a/app/routes/assets-routes.js b/app/routes/assets-routes.js index bc434fe7..7463c439 100644 --- a/app/routes/assets-routes.js +++ b/app/routes/assets-routes.js @@ -36,4 +36,8 @@ router .put(authn.authenticate, authz.requireRole(authz.editorOrHigher), assetsController.updateFull) .delete(authn.authenticate, authz.requireRole(authz.admin), assetsController.deleteVersionById); +router + .route('/assets/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), assetsController.revoke); + module.exports = router; diff --git a/app/routes/campaigns-routes.js b/app/routes/campaigns-routes.js index efdb09fb..97ba468e 100644 --- a/app/routes/campaigns-routes.js +++ b/app/routes/campaigns-routes.js @@ -40,4 +40,8 @@ router campaignsController.deleteVersionById, ); +router + .route('/campaigns/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), campaignsController.revoke); + module.exports = router; diff --git a/app/routes/collection-bundles-routes.js b/app/routes/collection-bundles-routes.js index a95c34aa..c5168c3c 100644 --- a/app/routes/collection-bundles-routes.js +++ b/app/routes/collection-bundles-routes.js @@ -8,6 +8,16 @@ const authz = require('../lib/authz-middleware'); const router = express.Router(); +// Middleware to route import requests to streaming or regular endpoint +const importBundleRouter = (req, res, next) => { + // Use streaming if requested + if (req.query.stream === 'true' || req.query.stream === true) { + return collectionBundlesController.streamImportBundle(req, res, next); + } + // Otherwise use regular import + return collectionBundlesController.importBundle(req, res, next); +}; + router .route('/collection-bundles') .get( @@ -18,7 +28,7 @@ router .post( authn.authenticate, authz.requireRole(authz.editorOrHigher, [authz.serviceRoles.collectionManager]), - collectionBundlesController.importBundle, + importBundleRouter, ); module.exports = router; diff --git a/app/routes/data-components-routes.js b/app/routes/data-components-routes.js index 3dbc326f..5f42e662 100644 --- a/app/routes/data-components-routes.js +++ b/app/routes/data-components-routes.js @@ -64,4 +64,12 @@ router dataComponentsController.deleteVersionById, ); +router + .route('/data-components/:stixId/revoke') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + dataComponentsController.revoke, + ); + module.exports = router; diff --git a/app/routes/data-sources-routes.js b/app/routes/data-sources-routes.js index f220850f..9e51d42a 100644 --- a/app/routes/data-sources-routes.js +++ b/app/routes/data-sources-routes.js @@ -44,4 +44,8 @@ router dataSourcesController.deleteVersionById, ); +router + .route('/data-sources/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), dataSourcesController.revoke); + module.exports = router; diff --git a/app/routes/groups-routes.js b/app/routes/groups-routes.js index 41b0ad9d..709e2490 100644 --- a/app/routes/groups-routes.js +++ b/app/routes/groups-routes.js @@ -36,4 +36,8 @@ router .put(authn.authenticate, authz.requireRole(authz.editorOrHigher), groupsController.updateFull) .delete(authn.authenticate, authz.requireRole(authz.admin), groupsController.deleteVersionById); +router + .route('/groups/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), groupsController.revoke); + module.exports = router; diff --git a/app/routes/index.js b/app/routes/index.js index c6180c81..97100137 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -17,13 +17,15 @@ router.use('/api', bodyParser.json({ limit: '50mb' })); router.use('/api', bodyParser.urlencoded({ limit: '1mb', extended: true })); // Setup request validation -router.use( - OpenApiValidator.middleware({ - apiSpec: config.openApi.specPath, - validateRequests: true, - validateResponses: false, - }), -); +if (config.validateRequests.withOpenApi) { + router.use( + OpenApiValidator.middleware({ + apiSpec: config.openApi.specPath, + validateRequests: true, + validateResponses: false, + }), + ); +} // Setup passport middleware router.use('/api', authnConfiguration.passportMiddleware()); @@ -41,6 +43,7 @@ fs.readdirSync(path.join(__dirname, '.')).forEach(function (filename) { // Handle errors that haven't otherwise been caught router.use(errorHandler.bodyParser); router.use(errorHandler.requestValidation); +router.use(errorHandler.serviceExceptions); router.use(errorHandler.catchAll); module.exports = router; diff --git a/app/routes/matrices-routes.js b/app/routes/matrices-routes.js index 20a27c32..8b78d78c 100644 --- a/app/routes/matrices-routes.js +++ b/app/routes/matrices-routes.js @@ -36,6 +36,10 @@ router .put(authn.authenticate, authz.requireRole(authz.editorOrHigher), matricesController.updateFull) .delete(authn.authenticate, authz.requireRole(authz.admin), matricesController.deleteVersionById); +router + .route('/matrices/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), matricesController.revoke); + router .route('/matrices/:stixId/modified/:modified/techniques') .get( diff --git a/app/routes/mitigations-routes.js b/app/routes/mitigations-routes.js index 04b7e234..2f062f28 100644 --- a/app/routes/mitigations-routes.js +++ b/app/routes/mitigations-routes.js @@ -44,4 +44,8 @@ router mitigationsController.deleteVersionById, ); +router + .route('/mitigations/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), mitigationsController.revoke); + module.exports = router; diff --git a/app/routes/release-tracks-routes.js b/app/routes/release-tracks-routes.js new file mode 100644 index 00000000..17209fb2 --- /dev/null +++ b/app/routes/release-tracks-routes.js @@ -0,0 +1,305 @@ +'use strict'; + +const express = require('express'); + +const releaseTracksController = require('../controllers/release-tracks-controller'); +const authn = require('../lib/authn-middleware'); +const authz = require('../lib/authz-middleware'); + +const router = express.Router(); + +// ============================================================================= +// Ephemeral (stateless) bundles +// ============================================================================= + +router + .route('/release-tracks/ephemeral/:domain') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.retrieveEphemeralByDomain, + ); + +// ============================================================================= +// Track management (static paths before :id param) +// ============================================================================= + +router + .route('/release-tracks') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.listReleaseTracks, + ); + +router + .route('/release-tracks/new') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.createReleaseTrack, + ); + +router + .route('/release-tracks/new-from-bundle') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.createReleaseTrackFromBundle, + ); + +router + .route('/release-tracks/import') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.importReleaseTrack, + ); + +// ============================================================================= +// Latest snapshot operations (parameterised by :id) +// ============================================================================= + +/** Bump preview must be registered before :id/bump to avoid param conflict */ +router + .route('/release-tracks/:id/bump/preview') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.previewBump, + ); + +router + .route('/release-tracks/:id/meta') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateMetadataByLatest, + ); + +/** + * !!IMPORTANT + * The following endpoint is considered dangerous. It is intended for retroactive hotfixes only. Thus, only admins may use it. + * The main workflow for enrolling new member objects into members is through the candidate-staging promotion cycle. + */ +router + .route('/release-tracks/:id/contents') + .post( + authn.authenticate, + authz.requireRole(authz.admin), + releaseTracksController.updateContentsByLatest, + ); + +router + .route('/release-tracks/:id/bump') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.bumpByLatest, + ); + +router + .route('/release-tracks/:id/clone') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.cloneByLatest, + ); + +// ============================================================================= +// Candidate management (static sub-paths before :objectRef param) +// ============================================================================= + +router + .route('/release-tracks/:id/candidates/review') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.reviewCandidates, + ); + +router + .route('/release-tracks/:id/candidates/promote') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.promoteCandidates, + ); + +router + .route('/release-tracks/:id/candidates/:objectRef/update-version') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateCandidateVersion, + ); + +router + .route('/release-tracks/:id/candidates/:objectRef') + .delete( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.removeCandidate, + ); + +router + .route('/release-tracks/:id/candidates') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.listCandidates, + ) + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.addCandidates, + ); + +// ============================================================================= +// Staged objects +// ============================================================================= + +router + .route('/release-tracks/:id/staged/demote') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.demoteStaged, + ); + +router + .route('/release-tracks/:id/staged') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.listStaged, + ); + +// ============================================================================= +// Configuration +// ============================================================================= + +router + .route('/release-tracks/:id/config') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.getConfig, + ) + .put( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateConfig, + ); + +// ============================================================================= +// Object version history +// ============================================================================= + +router + .route('/release-tracks/:id/objects/:objectRef/versions') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.listObjectVersions, + ); + +// ============================================================================= +// Virtual track operations (static snapshot sub-paths before :modified param) +// ============================================================================= + +router + .route('/release-tracks/:id/snapshots/preview') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.previewVirtualSnapshot, + ); + +router + .route('/release-tracks/:id/snapshots/create') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.createVirtualSnapshot, + ); + +// ============================================================================= +// Snapshot-specific operations (parameterised by :modified) +// ============================================================================= + +router + .route('/release-tracks/:id/snapshots/:modified/meta') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateMetadataByModified, + ); + +router + .route('/release-tracks/:id/snapshots/:modified/contents') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateContentsByModified, + ); + +router + .route('/release-tracks/:id/snapshots/:modified/bump') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.bumpByModified, + ); + +router + .route('/release-tracks/:id/snapshots/:modified/clone') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.cloneByModified, + ); + +router + .route('/release-tracks/:id/snapshots/:modified') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.retrieveSnapshotByModified, + ) + .delete( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.deleteSnapshotByModified, + ); + +// ============================================================================= +// Virtual track composition +// ============================================================================= + +router + .route('/release-tracks/:id/composition') + .put( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateComposition, + ); + +// ============================================================================= +// Retrieve / delete release track (must be last -- :id is a catch-all param) +// ============================================================================= + +router + .route('/release-tracks/:id') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.retrieveLatestSnapshot, + ) + .delete( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.deleteReleaseTrack, + ); + +module.exports = router; diff --git a/app/routes/reports-routes.js b/app/routes/reports-routes.js new file mode 100644 index 00000000..2478652b --- /dev/null +++ b/app/routes/reports-routes.js @@ -0,0 +1,27 @@ +'use strict'; + +const express = require('express'); + +const reportsController = require('../controllers/reports-controller'); +const authn = require('../lib/authn-middleware'); +const authz = require('../lib/authz-middleware'); + +const router = express.Router(); + +router + .route('/reports/link-by-id/missing') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + reportsController.getMissingLinkById, + ); + +router + .route('/reports/parallel-relationships') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + reportsController.getParallelRelationships, + ); + +module.exports = router; diff --git a/app/routes/software-routes.js b/app/routes/software-routes.js index c1707464..3579936c 100644 --- a/app/routes/software-routes.js +++ b/app/routes/software-routes.js @@ -36,4 +36,8 @@ router .put(authn.authenticate, authz.requireRole(authz.editorOrHigher), softwareController.updateFull) .delete(authn.authenticate, authz.requireRole(authz.admin), softwareController.deleteVersionById); +router + .route('/software/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), softwareController.revoke); + module.exports = router; diff --git a/app/routes/tactics-routes.js b/app/routes/tactics-routes.js index 0e7b6080..56e28640 100644 --- a/app/routes/tactics-routes.js +++ b/app/routes/tactics-routes.js @@ -36,6 +36,10 @@ router .put(authn.authenticate, authz.requireRole(authz.editorOrHigher), tacticsController.updateFull) .delete(authn.authenticate, authz.requireRole(authz.admin), tacticsController.deleteVersionById); +router + .route('/tactics/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), tacticsController.revoke); + router .route('/tactics/:stixId/modified/:modified/techniques') .get( diff --git a/app/routes/techniques-routes.js b/app/routes/techniques-routes.js index c9908aac..cf3c32cf 100644 --- a/app/routes/techniques-routes.js +++ b/app/routes/techniques-routes.js @@ -40,6 +40,26 @@ router techniquesController.deleteVersionById, ); +router + .route('/techniques/:stixId/revoke') + .post(authn.authenticate, authz.requireRole(authz.editorOrHigher), techniquesController.revoke); + +router + .route('/techniques/:stixId/convert-to-subtechnique') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + techniquesController.convertToSubtechnique, + ); + +router + .route('/techniques/:stixId/convert-to-technique') + .post( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + techniquesController.convertToTechnique, + ); + router .route('/techniques/:stixId/modified/:modified/tactics') .get( diff --git a/app/routes/validation-bypasses-routes.js b/app/routes/validation-bypasses-routes.js new file mode 100644 index 00000000..56fd44b4 --- /dev/null +++ b/app/routes/validation-bypasses-routes.js @@ -0,0 +1,33 @@ +'use strict'; + +const express = require('express'); + +const validationBypassesController = require('../controllers/validation-bypasses-controller'); +const authn = require('../lib/authn-middleware'); +const authz = require('../lib/authz-middleware'); + +const router = express.Router(); + +router + .route('/config/validation-bypasses') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + validationBypassesController.retrieveAll, + ) + .post(authn.authenticate, authz.requireRole(authz.admin), validationBypassesController.create); + +router + .route('/config/validation-bypasses/:id') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + validationBypassesController.retrieveById, + ) + .delete( + authn.authenticate, + authz.requireRole(authz.admin), + validationBypassesController.deleteById, + ); + +module.exports = router; diff --git a/app/scheduler/index.js b/app/scheduler/index.js new file mode 100644 index 00000000..545a48f7 --- /dev/null +++ b/app/scheduler/index.js @@ -0,0 +1,56 @@ +'use strict'; + +const schedule = require('node-schedule'); +const logger = require('../lib/logger'); +const config = require('../config/config'); +const fs = require('fs'); +const path = require('path'); + +/** + * Initialize the scheduler by loading all task modules + * Task modules self-initialize when required + */ +function initializeScheduler() { + if (!config.scheduler.enableScheduler) { + logger.info('Scheduler is disabled by configuration'); + return; + } + + logger.info('Loading scheduled tasks'); + + // Find all *-task.js files in the scheduler directory + const schedulerDir = __dirname; + const taskFiles = fs.readdirSync(schedulerDir).filter((file) => file.endsWith('-task.js')); + + logger.info(`Found ${taskFiles.length} task file(s) to load`); + + // Require each task file (they self-initialize) + for (const taskFile of taskFiles) { + const taskPath = path.join(schedulerDir, taskFile); + try { + // Loading the module will trigger its `initializeTask` method which schedules the task + require(taskPath); + logger.info(`Loaded task from ${taskFile}`); + } catch (err) { + logger.error(`Failed to load task from ${taskFile}: ${err.message}`); + logger.error(err.stack); + } + } + + logger.info('Scheduler initialization complete'); +} + +/** + * Gracefully shutdown all scheduled jobs + * @returns {Promise} Promise that resolves when all jobs are terminated + */ +async function gracefulShutdown() { + logger.info('Gracefully shutting down scheduled tasks'); + await schedule.gracefulShutdown(); + logger.info('All scheduled tasks have been shut down'); +} + +module.exports = { + initializeScheduler, + gracefulShutdown, +}; diff --git a/app/scheduler/scheduler.js b/app/scheduler/sync-collection-indexes-task.js similarity index 69% rename from app/scheduler/scheduler.js rename to app/scheduler/sync-collection-indexes-task.js index 2cb12cbe..f948b505 100644 --- a/app/scheduler/scheduler.js +++ b/app/scheduler/sync-collection-indexes-task.js @@ -1,8 +1,9 @@ 'use strict'; -const collectionIndexesService = require('../services/collection-indexes-service'); -const collectionsService = require('../services/collections-service'); -const collectionBundlesService = require('../services/collection-bundles-service'); +const schedule = require('node-schedule'); +const collectionIndexesService = require('../services/stix/collection-indexes-service'); +const collectionsService = require('../services/stix/collections-service'); +const collectionBundlesService = require('../services/stix/collection-bundles-service'); const { MissingParameterError, NotFoundError, @@ -17,20 +18,6 @@ const config = require('../config/config'); const superagent = require('superagent'); -let timer; -exports.initializeScheduler = function () { - logger.info('Starting the scheduler'); - - const intervalMilliseconds = config.scheduler.checkWorkbenchInterval * 1000; - timer = setInterval(runCheckCollectionIndexes, intervalMilliseconds); -}; - -exports.stopScheduler = function () { - if (timer) { - clearInterval(timer); - } -}; - const scheduledSubscriptions = new Map(); async function retrieveByUrl(url) { @@ -58,13 +45,13 @@ async function retrieveByUrl(url) { const runCheckCollectionIndexes = async function () { const updatedCollections = []; - logger.info('Scheduler running...'); + logger.info('[sync-collection-indexes] Scheduler running...'); let collectionIndexes; try { collectionIndexes = await collectionIndexesService.retrieveAll({ offset: 0, limit: 0 }); } catch (err) { - logger.error('Unable to get existing collection indexes: ' + err); + logger.error('[sync-collection-indexes] Unable to get existing collection indexes: ' + err); } for (const collectionIndex of collectionIndexes) { @@ -80,24 +67,27 @@ const runCheckCollectionIndexes = async function () { now - lastRetrieval > 1000 * collectionIndex.workspace.update_policy.interval ) { logger.info( - `Checking collection index: ${collectionIndex.collection_index.name} (${collectionIndex.collection_index.id})`, + `[sync-collection-indexes] Checking collection index: ${collectionIndex.collection_index.name} (${collectionIndex.collection_index.id})`, ); logger.verbose( - 'Retrieving collection index from remote url ' + collectionIndex.workspace.remote_url, + '[sync-collection-indexes] Retrieving collection index from remote url ' + + collectionIndex.workspace.remote_url, ); let remoteCollectionIndex; try { remoteCollectionIndex = await retrieveByUrl(collectionIndex.workspace.remote_url); } catch (err) { - logger.error('Unable to retrieve collection index from remote url. ' + err); + logger.error( + '[sync-collection-indexes] Unable to retrieve collection index from remote url. ' + err, + ); } const remoteTimestamp = new Date(remoteCollectionIndex.modified); const existingTimestamp = new Date(collectionIndex.collection_index.modified); if (remoteTimestamp > existingTimestamp) { logger.info( - 'The retrieved collection index is newer. Updating collection index in workbench.', + '[sync-collection-indexes] The retrieved collection index is newer. Updating collection index in workbench.', ); collectionIndex.collection_index = remoteCollectionIndex; collectionIndex.workspace.update_policy.last_retrieval = new Date(now).toISOString(); @@ -109,18 +99,20 @@ const runCheckCollectionIndexes = async function () { collectionIndex, ); } catch (err) { - logger.error('Unable to update collection index in workbench. ' + err); + logger.error( + '[sync-collection-indexes] Unable to update collection index in workbench. ' + err, + ); return updatedCollections; } // Check subscribed collections if (scheduledSubscriptions.has(savedCollectionIndex.collection_index.id)) { logger.info( - `Subscriptions for collection index ${savedCollectionIndex.collection_index.id} are already being checked`, + `[sync-collection-indexes] Subscriptions for collection index ${savedCollectionIndex.collection_index.id} are already being checked`, ); } else { logger.verbose( - `Checking Subscriptions for collection index ${savedCollectionIndex.collection_index.id}`, + `[sync-collection-indexes] Checking Subscriptions for collection index ${savedCollectionIndex.collection_index.id}`, ); scheduledSubscriptions.set(savedCollectionIndex.collection_index.id, true); try { @@ -128,12 +120,15 @@ const runCheckCollectionIndexes = async function () { updatedCollections.push(collectionIndex.collection_index.id); scheduledSubscriptions.delete(savedCollectionIndex.collection_index.id); } catch (err) { - logger.error('Error checking subscriptions in collection index. ' + err); + logger.error( + '[sync-collection-indexes] Error checking subscriptions in collection index. ' + + err, + ); return updatedCollections; } } } else { - logger.verbose('The retrieved collection index is not newer.'); + logger.verbose('[sync-collection-indexes] The retrieved collection index is not newer.'); collectionIndex.workspace.update_policy.last_retrieval = new Date(now).toISOString(); try { await collectionIndexesService.updateFull( @@ -141,18 +136,20 @@ const runCheckCollectionIndexes = async function () { collectionIndex, ); } catch (err) { - logger.error('Unable to update collection index in workbench. ' + err); + logger.error( + '[sync-collection-indexes] Unable to update collection index in workbench. ' + err, + ); return updatedCollections; } // Check subscribed collections if (scheduledSubscriptions.has(collectionIndex.collection_index.id)) { logger.info( - `Subscriptions for collection index ${collectionIndex.collection_index.id} are already being checked`, + `[sync-collection-indexes] Subscriptions for collection index ${collectionIndex.collection_index.id} are already being checked`, ); } else { logger.info( - `Checking Subscriptions for collection index ${collectionIndex.collection_index.id}`, + `[sync-collection-indexes] Checking Subscriptions for collection index ${collectionIndex.collection_index.id}`, ); scheduledSubscriptions.set(collectionIndex.collection_index.id, true); try { @@ -160,7 +157,10 @@ const runCheckCollectionIndexes = async function () { updatedCollections.push(collectionIndex.collection_index.id); scheduledSubscriptions.delete(collectionIndex.collection_index.id); } catch (err) { - logger.error('Error checking subscriptions in collection index. ' + err); + logger.error( + '[sync-collection-indexes] Error checking subscriptions in collection index. ' + + err, + ); return updatedCollections; } } @@ -197,7 +197,9 @@ async function subscriptionHandler(collectionIndex) { ) { // Latest version in collection index is later than latest version in the Workbench data store, // so we should import it - logger.info(`Retrieving collection bundle from remote url ${collectionInfo.versions[0].url}`); + logger.info( + `[sync-collection-indexes] Retrieving collection bundle from remote url ${collectionInfo.versions[0].url}`, + ); let collectionBundle; try { @@ -205,7 +207,9 @@ async function subscriptionHandler(collectionIndex) { } catch (err) { throw new Error('Unable to retrieve updated collection bundle. ' + err); } - logger.info(`Downloaded updated collection bundle with id ${collectionBundle.id}`); + logger.info( + `[sync-collection-indexes] Downloaded updated collection bundle with id ${collectionBundle.id}`, + ); // Find the x-mitre-collection objects const collections = collectionBundle.objects.filter( @@ -241,7 +245,7 @@ async function subscriptionHandler(collectionIndex) { importOptions, ); logger.info( - `Imported collection bundle with x-mitre-collection id ${importedCollection.stix.id}`, + `[sync-collection-indexes] Imported collection bundle with x-mitre-collection id ${importedCollection.stix.id}`, ); } catch (err) { throw new Error( @@ -251,3 +255,26 @@ async function subscriptionHandler(collectionIndex) { } } } + +/** + * Initialize and schedule this task + */ +function initializeTask() { + const cronPattern = config.scheduler.syncCollectionIndexesCron; + + logger.info(`[sync-collection-indexes] Scheduling task with cron pattern: ${cronPattern}`); + + schedule.scheduleJob(cronPattern, async () => { + try { + await runCheckCollectionIndexes(); + } catch (err) { + logger.error(`[sync-collection-indexes] Task execution failed: ${err.message}`); + } + }); + + logger.info(`[sync-collection-indexes] Task scheduled successfully`); +} + +if (config.scheduler.enableScheduler) { + initializeTask(); +} diff --git a/app/scheduler/validate-objects-task.js b/app/scheduler/validate-objects-task.js new file mode 100644 index 00000000..01777000 --- /dev/null +++ b/app/scheduler/validate-objects-task.js @@ -0,0 +1,195 @@ +'use strict'; + +const schedule = require('node-schedule'); +const logger = require('../lib/logger'); +const config = require('../config/config'); +const { getSchema } = require('../lib/validation-schemas'); + +/** + * Recursively convert Date instances to ISO strings. + * MongoDB/.lean() returns BSON Date objects, but ADM Zod schemas + * expect RFC3339 strings (z.iso.datetime). + */ +function serializeDates(obj) { + if (obj instanceof Date) return obj.toISOString(); + if (Array.isArray(obj)) return obj.map(serializeDates); + if (obj !== null && typeof obj === 'object') { + const out = {}; + for (const [key, val] of Object.entries(obj)) { + out[key] = serializeDates(val); + } + return out; + } + return obj; +} + +// Repositories for all collections that hold STIX objects +const attackObjectModel = require('../models/attack-object-model'); +const RelationshipModel = require('../models/relationship-model'); +const validationBypassesRepository = require('../repository/validation-bypasses-repository'); + +/** + * Re-validate all STIX documents and refresh `workspace.validation`. + * + * This combats "concept drift": when the ADM library or ATTACK_SPEC_VERSION + * changes, previously recorded validation results may become stale. Running + * this on a schedule keeps every document's validation metadata current. + * + * Logic mirrors BaseService._createFromImport and the backfill migration: + * - Revoked/deprecated objects skip validation (stale validation is cleared). + * - Errors that match a bypass rule are filtered out. + * - Documents that pass have workspace.validation removed. + * - Documents that fail get workspace.validation set/updated. + * + * @returns {Promise} Summary of validation results + */ +async function validateObjects() { + logger.info('[validate-objects] Starting scheduled validation of all STIX objects'); + + const { ATTACK_SPEC_VERSION } = require('@mitre-attack/attack-data-model'); + const admPkg = require('@mitre-attack/attack-data-model/package.json'); + + // Load bypass rules once for the entire run + let bypassRules = []; + try { + bypassRules = await validationBypassesRepository.findAll(); + } catch (err) { + logger.warn(`[validate-objects] Could not load bypass rules: ${err.message}`); + } + + const results = { + timestamp: new Date().toISOString(), + totalValidated: 0, + totalErrored: 0, + totalCleared: 0, + admVersion: admPkg.version, + attackSpecVersion: ATTACK_SPEC_VERSION, + }; + + // Process both collections: attackObjects (SDOs) and relationships (SROs) + const models = [ + { model: attackObjectModel, name: 'attackObjects' }, + { model: RelationshipModel, name: 'relationships' }, + ]; + + for (const { model, name } of models) { + logger.debug(`[validate-objects] Processing ${name} collection`); + + // Target ALL objects (including non-latest, revoked, and deprecated) + const cursor = model.find({}).lean().cursor(); + + for await (const doc of cursor) { + results.totalValidated++; + + const stixType = doc.stix?.type; + if (!stixType) continue; + + const status = doc.workspace?.workflow?.state || 'reviewed'; + const schema = getSchema(stixType, status); + if (!schema) continue; + + const parseResult = schema.safeParse(serializeDates(doc.stix)); + + if (parseResult.success) { + // Valid — clear any stale validation errors + if (doc.workspace?.validation) { + await model.updateOne({ _id: doc._id }, { $unset: { 'workspace.validation': '' } }); + results.totalCleared++; + } + continue; + } + + // Convert Zod issues to error objects + let errors = parseResult.error.issues.map((issue) => ({ + message: `${issue.path.join('.')} is ${issue.message}`, + path: issue.path, + code: issue.code, + })); + + // Apply bypass rules (mirrors ValidationBypassesService.checkBypassRule) + if (bypassRules.length > 0) { + errors = errors.filter((error) => { + const errorPathStr = JSON.stringify(error.path.map(String)); + return !bypassRules.some((rule) => { + if (!rule.suppressError && !rule.warningMessage) return false; + if (rule.stixType !== 'all' && rule.stixType !== stixType) return false; + if (rule.errorCode !== error.code) return false; + const rulePathStr = JSON.stringify(rule.fieldPath.map(String)); + return rulePathStr === errorPathStr; + }); + }); + } + + if (errors.length === 0) { + if (doc.workspace?.validation) { + await model.updateOne({ _id: doc._id }, { $unset: { 'workspace.validation': '' } }); + results.totalCleared++; + } + continue; + } + + // Determine if validation data has actually changed to avoid unnecessary writes + const existingValidation = doc.workspace?.validation; + const errorsChanged = + !existingValidation || + existingValidation.adm_version !== admPkg.version || + existingValidation.attack_spec_version !== ATTACK_SPEC_VERSION || + existingValidation.errors?.length !== errors.length; + + if (errorsChanged) { + await model.updateOne( + { _id: doc._id }, + { + $set: { + 'workspace.validation': { + errors: errors.map((e) => ({ message: e.message, path: e.path, code: e.code })), + attack_spec_version: ATTACK_SPEC_VERSION, + adm_version: admPkg.version, + validated_at: new Date(), + }, + }, + }, + ); + } + results.totalErrored++; + } + } + + logger.info( + `[validate-objects] Validation complete: ${results.totalValidated} documents validated, ` + + `${results.totalErrored} with errors, ${results.totalCleared} stale validations cleared ` + + `(ADM v${results.admVersion}, spec v${results.attackSpecVersion})`, + ); + + return results; +} + +/** + * Initialize and schedule this task + */ +function initializeTask() { + const cronPattern = config.scheduler.validateObjectsCron; + + logger.info(`[validate-objects] Scheduling task with cron pattern: ${cronPattern}`); + + schedule.scheduleJob(cronPattern, async () => { + try { + await validateObjects(); + } catch (err) { + logger.error(`[validate-objects] Task execution failed: ${err.message}`); + logger.error(err.stack); + } + }); + + logger.info('[validate-objects] Task scheduled successfully'); +} + +// Initialize the task when this module is loaded +if (config.scheduler.enableScheduler) { + initializeTask(); +} + +// Export for testing +module.exports = { + validateObjects, +}; diff --git a/app/services/_abstract.service.js b/app/services/_abstract.service.js deleted file mode 100644 index 6fcc8398..00000000 --- a/app/services/_abstract.service.js +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable no-unused-vars */ -// Note there is a bug in eslint where single line comment will not work ^ -'use strict'; - -const { NotImplementedError } = require('../exceptions'); - -class AbstractService { - retrieveAll(options) { - throw new NotImplementedError(this.constructor.name, 'retrieveAll'); - } - - retrieveById(stixId, options) { - throw new NotImplementedError(this.constructor.name, 'retrieveById'); - } - - retrieveVersionById(stixId, modified) { - throw new NotImplementedError(this.constructor.name, 'retrieveVersionById'); - } - - create(data, options) { - throw new NotImplementedError(this.constructor.name, 'create'); - } - - // ... other abstract methods ... -} - -module.exports = AbstractService; diff --git a/app/services/_base.service.js b/app/services/_base.service.js deleted file mode 100644 index f3d8890e..00000000 --- a/app/services/_base.service.js +++ /dev/null @@ -1,412 +0,0 @@ -'use strict'; - -const uuid = require('uuid'); -const logger = require('../lib/logger'); -const config = require('../config/config'); -const { - DatabaseError, - IdentityServiceError, - MissingParameterError, - InvalidQueryStringParameterError, - InvalidTypeError, - OrganizationIdentityNotSetError, -} = require('../exceptions'); -const AbstractService = require('./_abstract.service'); - -// Import required repositories -const systemConfigurationRepository = require('../repository/system-configurations-repository'); -const identitiesRepository = require('../repository/identities-repository'); -const userAccountsService = require('./user-accounts-service'); - -class BaseService extends AbstractService { - constructor(type, repository) { - super(); - this.type = type; - this.repository = repository; - - // Initialize caches for identity lookups - this.identityCache = new Map(); - this.userAccountCache = new Map(); - } - - // ============================ - // Pagination and Utility Methods - // ============================ - - static paginate(options, results) { - if (options.includePagination) { - let derivedTotalCount = 0; - if (results[0].totalCount && results[0].totalCount.length > 0) { - derivedTotalCount = results[0].totalCount[0].totalCount; - } - return { - pagination: { - total: derivedTotalCount, - offset: options.offset, - limit: options.limit, - }, - data: results[0].documents, - }; - } else { - return results[0].documents; - } - } - - // ============================ - // System Configuration Methods - // ============================ - - async retrieveOrganizationIdentityRef() { - const systemConfig = await systemConfigurationRepository.retrieveOne(); - - if (systemConfig && systemConfig.organization_identity_ref) { - return systemConfig.organization_identity_ref; - } else { - throw new OrganizationIdentityNotSetError(); - } - } - - async setDefaultMarkingDefinitionsForObject(attackObject) { - const systemConfig = await systemConfigurationRepository.retrieveOne({ lean: true }); - if (!systemConfig) return; - - const defaultMarkingDefinitions = systemConfig.default_marking_definitions || []; - - if (attackObject.stix.object_marking_refs) { - attackObject.stix.object_marking_refs = attackObject.stix.object_marking_refs.concat( - defaultMarkingDefinitions.filter((e) => !attackObject.stix.object_marking_refs.includes(e)), - ); - } else { - attackObject.stix.object_marking_refs = defaultMarkingDefinitions; - } - } - - // ============================ - // Identity Management Methods - // ============================ - - async addCreatedByAndModifiedByIdentitiesToAll(attackObjects) { - for (const attackObject of attackObjects) { - await this.addCreatedByAndModifiedByIdentities(attackObject); - } - } - - async addCreatedByAndModifiedByIdentities(attackObject) { - if (attackObject?.stix?.created_by_ref) { - await this.addCreatedByIdentity(attackObject); - } - - if (attackObject?.stix?.x_mitre_modified_by_ref) { - await this.addModifiedByIdentity(attackObject); - } - - if (attackObject?.workspace?.workflow?.created_by_user_account) { - await this.addCreatedByUserAccountWithCache(attackObject); - } - } - - async addCreatedByIdentity(attackObject) { - if (this.identityCache.has(attackObject.stix.created_by_ref)) { - attackObject.created_by_identity = this.identityCache.get(attackObject.stix.created_by_ref); - return; - } - - if (!attackObject.created_by_identity) { - try { - const identityObject = await identitiesRepository.retrieveLatestByStixId( - attackObject.stix.created_by_ref, - ); - attackObject.created_by_identity = identityObject; - this.identityCache.set(attackObject.stix.created_by_ref, identityObject); - } catch (err) { - // Ignore lookup errors - logger.warn(err.message); - } - } - } - - async addModifiedByIdentity(attackObject) { - if (this.identityCache.has(attackObject.stix.x_mitre_modified_by_ref)) { - attackObject.modified_by_identity = this.identityCache.get( - attackObject.stix.x_mitre_modified_by_ref, - ); - return; - } - - if (!attackObject.modified_by_identity) { - try { - const identityObject = await identitiesRepository.retrieveLatestByStixId( - attackObject.stix.x_mitre_modified_by_ref, - ); - attackObject.modified_by_identity = identityObject; - this.identityCache.set(attackObject.stix.x_mitre_modified_by_ref, identityObject); - } catch (err) { - // Ignore lookup errors - logger.warn(err.message); - } - } - } - - async addCreatedByUserAccountWithCache(attackObject) { - const userAccountRef = attackObject?.workspace?.workflow?.created_by_user_account; - if (!userAccountRef) return; - - if (this.userAccountCache.has(userAccountRef)) { - attackObject.created_by_user_account = this.userAccountCache.get(userAccountRef); - return; - } - - if (!attackObject.created_by_user_account) { - await userAccountsService.addCreatedByUserAccount(attackObject); - this.userAccountCache.set(userAccountRef, attackObject.created_by_user_account); - } - } - - // ============================ - // CRUD Operations - // ============================ - - async retrieveAll(options) { - let results; - try { - results = await this.repository.retrieveAll(options); - } catch (err) { - throw new DatabaseError(err); - } - - try { - await this.addCreatedByAndModifiedByIdentitiesToAll(results[0].documents); - } catch (err) { - throw new IdentityServiceError({ - details: err.message, - cause: err, - }); - } - return BaseService.paginate(options, results); - } - - async retrieveById(stixId, options) { - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (options.versions === 'all') { - const documents = await this.repository.retrieveAllById(stixId); - - try { - await this.addCreatedByAndModifiedByIdentitiesToAll(documents); - } catch (err) { - throw new IdentityServiceError({ - details: err.message, - cause: err, - }); - } - return documents; - } else if (options.versions === 'latest') { - const document = await this.repository.retrieveLatestByStixId(stixId); - - if (document) { - try { - await this.addCreatedByAndModifiedByIdentities(document); - } catch (err) { - throw new IdentityServiceError({ - details: err.message, - cause: err, - }); - } - return [document]; - } else { - return []; - } - } else { - throw new InvalidQueryStringParameterError({ parameterName: 'versions' }); - } - } - - async retrieveVersionById(stixId, modified) { - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!modified) { - throw new MissingParameterError('modified'); - } - - const document = await this.repository.retrieveOneByVersion(stixId, modified); - - if (!document) { - return null; - } else { - try { - await this.addCreatedByAndModifiedByIdentities(document); - } catch (err) { - throw new IdentityServiceError({ - details: err.message, - cause: err, - }); - } - return document; - } - } - - /** - * Stream multiple attack objects by their version identifiers - * @param {Array<{object_ref: string, object_modified: string}>} xMitreContents - Array of x_mitre_contents elements - * @yields {Object} Attack objects with identities populated - */ - async *streamBulkByIdAndModified(xMitreContents) { - if (!xMitreContents || !Array.isArray(xMitreContents) || xMitreContents.length === 0) { - return; - } - - // Process identities in small batches as we stream - const identityBatch = []; - const IDENTITY_BATCH_SIZE = 50; - - for await (const doc of this.repository.streamManyByIdAndModified(xMitreContents)) { - identityBatch.push(doc); - - // Process identities when batch is full - if (identityBatch.length >= IDENTITY_BATCH_SIZE) { - await Promise.all(identityBatch.map((d) => this.addCreatedByAndModifiedByIdentities(d))); - - // Yield processed documents - for (const processedDoc of identityBatch) { - yield processedDoc; - } - - // Clear the batch - identityBatch.length = 0; - } - } - - // Process remaining documents - if (identityBatch.length > 0) { - await Promise.all(identityBatch.map((d) => this.addCreatedByAndModifiedByIdentities(d))); - - for (const processedDoc of identityBatch) { - yield processedDoc; - } - } - } - - /** - * Retrieve multiple attack objects by their version identifiers - * @param {Array<{object_ref: string, object_modified: string}>} xMitreContents - Array of x_mitre_contents elements - * @returns {Promise>} Array of attack objects with identities populated - */ - async getBulkByIdAndModified(xMitreContents) { - if (!xMitreContents || !Array.isArray(xMitreContents) || xMitreContents.length === 0) { - return []; - } - const documents = await this.repository.findManyByIdAndModified(xMitreContents); - - // Process identities in parallel - await Promise.all(documents.map((doc) => this.addCreatedByAndModifiedByIdentities(doc))); - - return documents; - } - - async create(data, options) { - if (data?.stix?.type !== this.type) { - throw new InvalidTypeError(); - } - - options = options || {}; - if (!options.import) { - // Set the ATT&CK Spec Version - data.stix.x_mitre_attack_spec_version = - data.stix.x_mitre_attack_spec_version ?? config.app.attackSpecVersion; - - // Record the user account that created the object - if (options.userAccountId) { - data.workspace.workflow.created_by_user_account = options.userAccountId; - } - - // Set the default marking definitions - await this.setDefaultMarkingDefinitionsForObject(data); - - // Get the organization identity - const organizationIdentityRef = await this.retrieveOrganizationIdentityRef(); - - // Check for an existing object - let existingObject; - if (data.stix.id) { - existingObject = await this.repository.retrieveOneById(data.stix.id); - } - - if (existingObject) { - // New version of an existing object - // Only set the x_mitre_modified_by_ref property - data.stix.x_mitre_modified_by_ref = organizationIdentityRef; - } else { - // New object - // Assign a new STIX id if not already provided - if (!data.stix.id) { - data.stix.id = `${data.stix.type}--${uuid.v4()}`; - } - - // Set the created_by_ref and x_mitre_modified_by_ref properties - data.stix.created_by_ref = organizationIdentityRef; - data.stix.x_mitre_modified_by_ref = organizationIdentityRef; - } - } - return await this.repository.save(data); - } - - async updateFull(stixId, stixModified, data) { - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!stixModified) { - throw new MissingParameterError('modified'); - } - - const document = await this.repository.retrieveOneByVersion(stixId, stixModified); - if (!document) { - return null; - } - - const newDocument = await this.repository.updateAndSave(document, data); - - if (newDocument === document) { - // Document successfully saved - return newDocument; - } else { - throw new DatabaseError({ - details: 'Document could not be saved', - document, // Pass along the document that could not be saved - }); - } - } - - // TODO rename to deleteVersionByStixId and repurpose the existing name for deleting by the document's unique _id - async deleteVersionById(stixId, stixModified) { - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!stixModified) { - throw new MissingParameterError('modified'); - } - - const document = await this.repository.findOneAndDelete(stixId, stixModified); - - if (!document) { - //Note: document is null if not found - return null; - } - return document; - } - - // TODO rename to deleteManyByStixId - async deleteById(stixId) { - if (!stixId) { - throw new MissingParameterError('stixId'); - } - return await this.repository.deleteMany(stixId); - } -} - -module.exports = BaseService; diff --git a/app/services/analytics-service.js b/app/services/analytics-service.js deleted file mode 100644 index 5d753247..00000000 --- a/app/services/analytics-service.js +++ /dev/null @@ -1,143 +0,0 @@ -'use strict'; - -const analyticsRepository = require('../repository/analytics-repository'); -const BaseService = require('./_base.service'); -const { Analytic: AnalyticType } = require('../lib/types'); -const detectionStrategiesService = require('./detection-strategies-service'); -const dataComponentsService = require('./data-components-service'); - -class AnalyticsService extends BaseService { - async retrieveAll(options) { - const results = await super.retrieveAll(options); - - if (options.includeRefs) { - if (options.includePagination) { - await this.addRelatedObjectsToAll(results.data); - } else { - await this.addRelatedObjectsToAll(results); - } - } - - return results; - } - - async retrieveById(stixId, options) { - const results = await super.retrieveById(stixId, options); - - if (options.includeRefs) { - await this.addRelatedObjectsToAll(results); - } - - return results; - } - - async addRelatedObjectsToAll(analytics) { - for (const analytic of analytics) { - await this.addRelatedObjects(analytic); - } - } - - async addRelatedObjects(analytic) { - const relatedObjects = []; - - try { - // Find detection strategies that reference this analytic - const detectionStrategies = await this.findDetectionStrategiesReferencingAnalytic( - analytic.stix.id, - ); - for (const detectionStrategy of detectionStrategies) { - relatedObjects.push( - this.formatRelatedObject(detectionStrategy, 'x-mitre-detection-strategy'), - ); - } - - // Find data components referenced by this analytic - const dataComponents = await this.findDataComponentsReferencedByAnalytic(analytic); - for (const dataComponent of dataComponents) { - relatedObjects.push(this.formatRelatedObject(dataComponent, 'x-mitre-data-component')); - } - } catch (err) { - // Log error but don't fail the main request - console.warn('Error fetching related objects for analytic:', err.message); - } - - analytic.related_to = relatedObjects; - } - - async findDetectionStrategiesReferencingAnalytic(analyticId) { - try { - // Query detection strategies where x_mitre_analytics array contains the analytic ID - const options = { - offset: 0, - limit: 0, - includeRevoked: false, - includeDeprecated: false, - includePagination: false, - }; - - // Get all detection strategies and filter in memory - // (BaseRepository doesn't have a direct way to query array contains) - const allStrategies = await detectionStrategiesService.retrieveAll(options); - - return allStrategies.filter( - (strategy) => - strategy.stix.x_mitre_analytic_refs && - strategy.stix.x_mitre_analytic_refs.includes(analyticId), - ); - } catch (err) { - console.warn('Error finding detection strategies:', err.message); - return []; - } - } - - async findDataComponentsReferencedByAnalytic(analytic) { - try { - if ( - !analytic.stix.x_mitre_log_source_references || - analytic.stix.x_mitre_log_source_references.length === 0 - ) { - return []; - } - - const dataComponentIds = analytic.stix.x_mitre_log_source_references.map( - (ref) => ref.x_mitre_data_component_ref, - ); - const dataComponents = []; - - // Fetch each data component by ID - for (const dataComponentId of dataComponentIds) { - try { - const dataComponentResults = await dataComponentsService.retrieveById(dataComponentId, { - versions: 'latest', - }); - if (dataComponentResults.length > 0) { - dataComponents.push(dataComponentResults[0]); - } - } catch (err) { - console.warn(`Error fetching data component ${dataComponentId}:`, err.message); - } - } - - return dataComponents; - } catch (err) { - console.warn('Error finding data components:', err.message); - return []; - } - } - - formatRelatedObject(obj, type) { - const attackId = - obj.stix.external_references && obj.stix.external_references.length > 0 - ? obj.stix.external_references[0].external_id - : null; - - return { - id: obj.stix.id, - name: obj.stix.name, - attack_id: attackId, - type: type, - }; - } -} - -module.exports = new AnalyticsService(AnalyticType, analyticsRepository); diff --git a/app/services/assets-service.js b/app/services/assets-service.js deleted file mode 100644 index 931ade13..00000000 --- a/app/services/assets-service.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const assetsRepository = require('../repository/assets-repository'); -const BaseService = require('./_base.service'); -const { Asset: AssetType } = require('../lib/types'); - -class AssetsService extends BaseService {} - -module.exports = new AssetsService(AssetType, assetsRepository); diff --git a/app/services/campaigns-service.js b/app/services/campaigns-service.js deleted file mode 100644 index 9194e246..00000000 --- a/app/services/campaigns-service.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const campaignsRepository = require('../repository/campaigns-repository'); -const BaseService = require('./_base.service'); -const { Campaign: CampaignType } = require('../lib/types'); - -class CampaignService extends BaseService {} - -module.exports = new CampaignService(CampaignType, campaignsRepository); diff --git a/app/services/collection-bundles-service/import-bundle.js b/app/services/collection-bundles-service/import-bundle.js deleted file mode 100644 index d58af922..00000000 --- a/app/services/collection-bundles-service/import-bundle.js +++ /dev/null @@ -1,521 +0,0 @@ -'use strict'; - -const semver = require('semver'); - -const { - errors, - importErrors, - forceImportParameters, - makeKey, - makeKeyFromObject, - defaultAttackSpecVersion, - toEpoch, -} = require('./bundle-helpers'); -const { DuplicateIdError } = require('../../exceptions'); -const { Collection: CollectionType } = require('../../lib/types'); - -const logger = require('../../lib/logger'); -const config = require('../../config/config'); -const types = require('../../lib/types'); - -const collectionsService = require('../../services/collections-service'); -const referencesService = require('../../services/references-service'); - -const Collection = require('../../models/collection-model'); - -// Service mapping object using the type constants -const serviceMap = { - [types.Technique]: require('../../services/techniques-service'), - [types.Tactic]: require('../../services/tactics-service'), - [types.Group]: require('../../services/groups-service'), - [types.Campaign]: require('../../services/campaigns-service'), - [types.Mitigation]: require('../../services/mitigations-service'), - [types.Matrix]: require('../../services/matrices-service'), - [types.Relationship]: require('../../services/relationships-service'), - [types.MarkingDefinition]: require('../../services/marking-definitions-service'), - [types.Identity]: require('../../services/identities-service'), - [types.Note]: require('../../services/notes-service'), - [types.DataSource]: require('../../services/data-sources-service'), - [types.DataComponent]: require('../../services/data-components-service'), - [types.Asset]: require('../../services/assets-service'), - [types.Analytic]: require('../../services/analytics-service'), - [types.DetectionStrategy]: require('../../services/detection-strategies-service'), -}; - -// Handle special cases that share a service -const softwareTypes = [types.Malware, types.Tool]; -softwareTypes.forEach((type) => { - serviceMap[type] = require('../../services/software-service'); -}); - -/** - * Maps STIX object types to their corresponding services - * @param {string} type - STIX object type - * @returns {Object|null} Service for the given type or null if not found - */ -const getServiceForType = (type) => serviceMap[type] || null; - -/** - * Checks if a STIX object is a duplicate of existing objects - * @param {Object} importObject - Object being imported - * @param {Array} existingObjects - Array of existing objects - * @returns {boolean} True if object is a duplicate - */ -function checkForDuplicate(importObject, existingObjects) { - if (importObject.type === 'marking-definition') { - return existingObjects.some( - (object) => toEpoch(object.stix.created) === toEpoch(importObject.created), - ); - } - return existingObjects.some( - (object) => toEpoch(object.stix.modified) === toEpoch(importObject.modified), - ); -} - -/** - * Categorizes an object as addition, change, revocation, etc. - * @param {Object} importObject - Object being imported - * @param {Array} existingObjects - Array of existing objects - * @param {Object} importedCollection - Collection being imported - */ -function categorizeObject(importObject, existingObjects, importedCollection) { - if (existingObjects.length === 0) { - importedCollection.workspace.import_categories.additions.push(importObject.id); - return; - } - - const latestExistingObject = existingObjects[0]; - - if (importObject.revoked && !latestExistingObject.stix.revoked) { - importedCollection.workspace.import_categories.revocations.push(importObject.id); - } else if (importObject.x_mitre_deprecated && !latestExistingObject.stix.x_mitre_deprecated) { - importedCollection.workspace.import_categories.deprecations.push(importObject.id); - } else if (toEpoch(latestExistingObject.stix.modified) < toEpoch(importObject.modified)) { - if (latestExistingObject.stix.x_mitre_version < importObject.x_mitre_version) { - importedCollection.workspace.import_categories.changes.push(importObject.id); - } else if (latestExistingObject.stix.x_mitre_version === importObject.x_mitre_version) { - importedCollection.workspace.import_categories.minor_changes.push(importObject.id); - } - } else { - importedCollection.workspace.import_categories.out_of_date.push(importObject.id); - } -} - -/** - * Processes external references from a STIX object - * @param {Object} importObject - Object being imported - * @param {Map} importReferences - Map of references being imported - * @param {Object} referenceImportResults - Reference import statistics - */ -function processExternalReferences(importObject, importReferences, referenceImportResults) { - if (!importObject.external_references?.length) return; - - for (const externalReference of importObject.external_references) { - if ( - !externalReference.source_name || - !externalReference.description || - externalReference.external_id - ) { - continue; - } - - // Check if reference is an alias - const isAlias = checkIfAlias(importObject, externalReference.source_name); - if (isAlias) { - referenceImportResults.aliasReferences++; - continue; - } - - if (importReferences.has(externalReference.source_name)) { - referenceImportResults.duplicateReferences++; - } else { - referenceImportResults.uniqueReferences++; - importReferences.set(externalReference.source_name, externalReference); - } - } -} - -/** - * Checks if a source name is an alias for the object - * @param {Object} importObject - STIX object - * @param {string} sourceName - Source name to check - * @returns {boolean} True if source name is an alias - */ -function checkIfAlias(importObject, sourceName) { - if (importObject.type === 'intrusion-set') { - return importObject.aliases?.includes(sourceName); - } - if (importObject.type === 'malware' || importObject.type === 'tool') { - return importObject.x_mitre_aliases?.includes(sourceName); - } - return false; -} - -/** - * Process a single STIX object during bundle import - * @param {Object} importObject - The STIX object to process - * @param {Object} options - Import options - * @param {Object} importedCollection - Collection being imported - * @param {Object} collectionReference - Reference to the collection - * @param {Map} importReferences - Map of references being imported - * @param {Object} referenceImportResults - Tracking of reference import stats - * @returns {Promise} Resolves when object is processed - */ -async function processStixObject( - importObject, - options, - importedCollection, - collectionReference, - importReferences, - referenceImportResults, -) { - const service = getServiceForType(importObject.type); - - if (!service) { - if (importObject.type === CollectionType) { - return; // Skip x-mitre-collection objects - } - - // Record error for unknown type but continue import - const importError = { - object_ref: importObject.id, - object_modified: importObject.modified, - error_type: importErrors.unknownObjectType, - error_message: `Unknown object type: ${importObject.type}`, - }; - logger.verbose( - `Import Bundle Error: Unknown object type. id=${importObject.id}, modified=${importObject.modified}, type=${importObject.type}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); - return; - } - - try { - // Retrieve existing objects with same STIX ID - const objects = await service.retrieveById(importObject.id, { versions: 'all' }); - - // Check for duplicate object - const isDuplicate = checkForDuplicate(importObject, objects); - if (isDuplicate) { - importedCollection.workspace.import_categories.duplicates.push(importObject.id); - return; - } - - // Categorize the object (addition, change, etc) - categorizeObject(importObject, objects, importedCollection); - - // Process external references - processExternalReferences(importObject, importReferences, referenceImportResults); - - // Save the object if not preview mode - if (!options.previewOnly) { - const newObject = { - workspace: { - collections: [collectionReference], - }, - stix: importObject, - }; - - try { - 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); - } - } - } catch (err) { - logger.error(err); - - // Record retrieval error but continue import - const importError = { - object_ref: importObject.id, - object_modified: importObject.modified, - error_type: importErrors.retrievalError, - }; - logger.verbose( - `Import Bundle Error: Unable to retrieve objects with matching STIX id. id=${importObject.id}, modified=${importObject.modified}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); - } -} - -/** - * Process all objects in the bundle - * @param {Array} objects - Array of STIX objects to process - * @param {Object} options - Import options - * @param {Object} importedCollection - Collection being imported - * @param {Map} contentsMap - Map of objects in x_mitre_contents - * @param {Object} collectionReference - Reference to the collection - * @param {Map} importReferences - Map of references being imported - * @param {Object} referenceImportResults - Tracking of reference import stats - */ -async function processObjects( - objects, - options, - importedCollection, - contentsMap, - collectionReference, - importReferences, - referenceImportResults, -) { - for (const importObject of objects) { - // Check if object is in x_mitre_contents - if ( - !contentsMap.delete(makeKeyFromObject(importObject)) && - importObject.type !== CollectionType - ) { - const importError = { - object_ref: importObject.id, - object_modified: importObject.modified, - error_type: importErrors.notInContents, - error_message: - 'Warning: Object in bundle but not in x_mitre_contents. Object will be saved in database.', - }; - logger.verbose( - `Import Bundle Warning: Object not in x_mitre_contents. id=${importObject.id}, modified=${importObject.modified}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); - } - - if (importObject.type != 'marking-definition') { - // Check ATT&CK Spec Version compatibility - const objectAttackSpecVersion = - importObject.x_mitre_attack_spec_version ?? defaultAttackSpecVersion; - if (semver.gt(objectAttackSpecVersion, config.app.attackSpecVersion)) { - const importError = { - object_ref: importObject.id, - object_modified: importObject.modified, - error_type: importErrors.attackSpecVersionViolation, - error_message: 'Error: Object x_mitre_attack_spec_version later than system.', - }; - logger.verbose( - `Import Bundle Error: Object's x_mitre_attack_spec_version later than system. id=${importObject.id}, modified=${importObject.modified}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); - - if ( - !options.forceImportParameters?.includes( - forceImportParameters.attackSpecVersionViolations, - ) - ) { - throw new Error(errors.attackSpecVersionViolation); - } - continue; - } - } - await processStixObject( - importObject, - options, - importedCollection, - collectionReference, - importReferences, - referenceImportResults, - ); - } - - // Check for objects in x_mitre_contents but not in bundle - for (const entry of contentsMap.values()) { - const importError = { - object_ref: entry.object_ref, - object_modified: entry.object_modified, - error_type: importErrors.missingObject, - error_message: 'Object listed in x_mitre_contents, but not in bundle', - }; - logger.verbose( - `Import Bundle Error: Object in x_mitre_contents but not in bundle. id=${entry.object_ref}, modified=${entry.object_modified}`, - ); - importedCollection.workspace.import_categories.errors.push(importError); - } -} - -/** - * Import references found in the bundle - * @param {Map} importReferences - Map of references to import - * @param {Object} options - Import options - * @param {Object} importedCollection - Collection being imported - */ -async function importReferences(importReferences, options, importedCollection) { - const references = await referencesService.retrieveAll({}); - const existingReferences = new Map(references.map((item) => [item.source_name, item])); - - for (const importReference of importReferences.values()) { - if (existingReferences.has(importReference.source_name)) { - // Update existing reference - importedCollection.workspace.import_references.changes.push(importReference.source_name); - if (!options.previewOnly) { - await referencesService.update(importReference); - } - } else { - // Create new reference - importedCollection.workspace.import_references.additions.push(importReference.source_name); - if (!options.previewOnly) { - await referencesService.create(importReference); - } - } - } -} - -/** - * Save the collection after import - * @param {Object} importedCollection - Collection to save - * @param {Object} duplicateCollection - Existing duplicate collection if any - * @param {Object} options - Import options - * @returns {Promise} Saved collection - */ -async function saveCollection(importedCollection, duplicateCollection, options) { - if (duplicateCollection) { - // Add reimport results to existing collection - const reimport = { - imported: new Date().toISOString(), - import_categories: importedCollection.workspace.import_categories, - import_references: importedCollection.workspace.import_references, - }; - - if (!duplicateCollection.workspace.reimports) { - duplicateCollection.workspace.reimports = []; - } - duplicateCollection.workspace.reimports.push(reimport); - - if (!options.previewOnly) { - return Collection.findByIdAndUpdate(duplicateCollection._id, duplicateCollection, { - new: true, - lean: true, - }); - } - return importedCollection; - } - - // Create new collection - if (!options.previewOnly) { - try { - const result = await collectionsService.create(importedCollection, { - addObjectsToCollection: false, - import: true, - }); - return result.savedCollection; - } catch (err) { - if (err.name === 'MongoServerError' && err.code === 11000) { - throw new Error(errors.duplicateCollection); - } - throw err; - } - } - return importedCollection; -} - -/** - * Checks for a duplicate collection - * @param {Object} importedCollection - Collection being imported - * @param {Object} options - Import options - * @returns {Promise} Duplicate collection if found - */ -async function checkDuplicateCollection(importedCollection, options) { - const collections = await collectionsService.retrieveById(importedCollection.stix.id, { - versions: 'all', - }); - - const duplicateCollection = collections.find( - (collection) => toEpoch(collection.stix.modified) === toEpoch(importedCollection.stix.modified), - ); - - if (duplicateCollection) { - if (options.forceImportParameters?.includes(forceImportParameters.duplicateCollection)) { - const importError = { - object_ref: importedCollection.stix.id, - object_modified: importedCollection.stix.modified, - error_type: importErrors.duplicateCollection, - error_message: 'Warning: Duplicate x-mitre-collection object.', - }; - logger.verbose( - 'Import Bundle Warning: Duplicate x-mitre-collection object. Continuing import due to forceImport parameter.', - ); - importedCollection.workspace.import_categories.errors.push(importError); - return duplicateCollection; - } - throw new Error(errors.duplicateCollection); - } - return null; -} - -/** - * Import a STIX bundle into the system - * @param {Object} collection - The collection to import - * @param {Object} data - The bundle data containing STIX objects - * @param {Object} options - Import options - * @returns {Promise} The imported collection - */ -module.exports = async function importBundle(collection, data, options) { - const referenceImportResults = { - uniqueReferences: 0, - duplicateReferences: 0, - aliasReferences: 0, - }; - - const collectionReference = { - collection_ref: collection.id, - collection_modified: collection.modified, - }; - - const importedCollection = { - workspace: { - imported: new Date().toISOString(), - exported: [], - import_categories: { - additions: [], - changes: [], - minor_changes: [], - revocations: [], - deprecations: [], - supersedes_user_edits: [], - supersedes_collection_changes: [], - duplicates: [], - out_of_date: [], - errors: [], - }, - import_references: { - additions: [], - changes: [], - duplicates: [], - }, - }, - stix: collection, - }; - - const contentsMap = new Map(); - for (const entry of collection.x_mitre_contents) { - contentsMap.set(makeKey(entry.object_ref, entry.object_modified), entry); - } - - const referenceMap = new Map(); - // Check for duplicate collection - const duplicateCollection = await checkDuplicateCollection(importedCollection, options); - - // Process all objects in bundle - await processObjects( - data.objects, - options, - importedCollection, - contentsMap, - collectionReference, - referenceMap, - referenceImportResults, - ); - - // Import references - await importReferences(referenceMap, options, importedCollection); - - // Save collection - return await saveCollection(importedCollection, duplicateCollection, options); -}; diff --git a/app/services/data-components-service.js b/app/services/data-components-service.js deleted file mode 100644 index 07ee7529..00000000 --- a/app/services/data-components-service.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const dataComponentsRepository = require('../repository/data-components-repository.js'); -const BaseService = require('./_base.service'); -const { DataComponent: DataComponentType } = require('../lib/types.js'); - -class DataComponentsService extends BaseService {} - -module.exports = new DataComponentsService(DataComponentType, dataComponentsRepository); diff --git a/app/services/data-sources-service.js b/app/services/data-sources-service.js deleted file mode 100644 index ecf5479a..00000000 --- a/app/services/data-sources-service.js +++ /dev/null @@ -1,119 +0,0 @@ -'use strict'; - -const dataSourcesRepository = require('../repository/data-sources-repository'); -const identitiesService = require('./identities-service'); -const dataComponentsService = require('./data-components-service'); -const BaseService = require('./_base.service'); -const { DataSource: DataSourceType } = require('../lib/types'); -const { - MissingParameterError, - BadlyFormattedParameterError, - InvalidQueryStringParameterError, -} = require('../exceptions'); - -class DataSourcesService extends BaseService { - errors = { - missingParameter: 'Missing required parameter', - badlyFormattedParameter: 'Badly formatted parameter', - duplicateId: 'Duplicate id', - notFound: 'Document not found', - invalidQueryStringParameter: 'Invalid query string parameter', - }; - - static async addExtraData(dataSource, retrieveDataComponents) { - await identitiesService.addCreatedByAndModifiedByIdentities(dataSource); - if (retrieveDataComponents) { - await DataSourcesService.addDataComponents(dataSource); - } - } - - static async addExtraDataToAll(dataSources, retrieveDataComponents) { - for (const dataSource of dataSources) { - await DataSourcesService.addExtraData(dataSource, retrieveDataComponents); - } - } - - static async addDataComponents(dataSource) { - // We have to work with the latest version of all data components to avoid mishandling a situation - // where an earlier version of a data component may reference a data source, but the latest - // version doesn't. - - // Retrieve the latest version of all data components - const allDataComponents = await dataComponentsService.retrieveAll({ - includeDeprecated: true, - includeRevoked: true, - }); - - // Add the data components that reference the data source - dataSource.dataComponents = allDataComponents.filter( - (dataComponent) => dataComponent.stix.x_mitre_data_source_ref === dataSource.stix.id, - ); - } - - async retrieveById(stixId, options) { - try { - // versions=all Retrieve all versions of the data source with the stixId - // versions=latest Retrieve the data source with the latest modified date for this stixId - - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (options.versions === 'all') { - const dataSources = await this.repository.retrieveAllById(stixId); - await DataSourcesService.addExtraDataToAll(dataSources, options.retrieveDataComponents); - return dataSources; - } else if (options.versions === 'latest') { - const dataSource = await this.repository.retrieveLatestByStixId(stixId); - - // Note: document is null if not found - if (dataSource) { - await DataSourcesService.addExtraData(dataSource, options.retrieveDataComponents); - return [dataSource]; - } else { - return []; - } - } else { - throw new InvalidQueryStringParameterError(); - } - } catch (err) { - if (err.name === 'CastError') { - throw new BadlyFormattedParameterError(); - } else { - throw err; - } - } - } - - async retrieveVersionById(stixId, modified, options) { - try { - // Retrieve the version of the data source with the matching stixId and modified date - - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!modified) { - throw new MissingParameterError('modified'); - } - - const dataSource = await this.repository.retrieveOneByVersion(stixId, modified); - - // Note: document is null if not found - if (dataSource) { - await DataSourcesService.addExtraData(dataSource, options.retrieveDataComponents); - return dataSource; - } else { - return null; - } - } catch (err) { - if (err.name === 'CastError') { - throw new BadlyFormattedParameterError(); - } else { - throw err; - } - } - } -} - -module.exports = new DataSourcesService(DataSourceType, dataSourcesRepository); diff --git a/app/services/detection-strategies-service.js b/app/services/detection-strategies-service.js deleted file mode 100644 index c2d5be02..00000000 --- a/app/services/detection-strategies-service.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -const detectionStrategiesRepository = require('../repository/detection-strategies-repository'); -const BaseService = require('./_base.service'); -const { DetectionStrategy: DetectionStrategyType } = require('../lib/types'); - -class DetectionStrategiesService extends BaseService {} - -module.exports = new DetectionStrategiesService( - DetectionStrategyType, - detectionStrategiesRepository, -); diff --git a/app/services/groups-service.js b/app/services/groups-service.js deleted file mode 100644 index 840de2af..00000000 --- a/app/services/groups-service.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const BaseService = require('./_base.service'); -const groupsRepository = require('../repository/groups-repository'); -const { Group: GroupType } = require('../lib/types'); - -class GroupsService extends BaseService {} - -module.exports = new GroupsService(GroupType, groupsRepository); diff --git a/app/services/index.js b/app/services/index.js deleted file mode 100644 index ba4b67d3..00000000 --- a/app/services/index.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -//** import repositories */ -const attackObjectsRepository = require('../repository/attack-objects-repository'); -const identitiesRepository = require('../repository/identities-repository'); -const relationshipsRepository = require('../repository/relationships-repository'); - -//** imports services */ -const AttackObjectsService = require('./attack-objects-service'); -const { IdentitiesService } = require('./identities-service'); -const RelationshipsService = require('./relationships-service'); - -//** import types */ -const { Identity: IdentityType, Relationship: RelationshipType } = require('../lib/types'); - -// ** initialize services */ -const identitiesService = new IdentitiesService(IdentityType, identitiesRepository); -const relationshipsService = new RelationshipsService(RelationshipType, relationshipsRepository); -const attackObjectsService = new AttackObjectsService( - attackObjectsRepository, - identitiesService, - relationshipsService, -); - -module.exports = { - identitiesService, - relationshipsService, - attackObjectsService, -}; diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js new file mode 100644 index 00000000..ee174b25 --- /dev/null +++ b/app/services/meta-classes/base.service.js @@ -0,0 +1,1255 @@ +'use strict'; + +const uuid = require('uuid'); +const logger = require('../../lib/logger'); +const config = require('../../config/config'); +const attackIdGenerator = require('../../lib/attack-id-generator'); +const { + buildAttackExternalReference, + createAttackExternalReference, + findAttackExternalReference, +} = require('../../lib/external-reference-builder'); +const { + DatabaseError, + IdentityServiceError, + MissingParameterError, + InvalidQueryStringParameterError, + InvalidTypeError, + OrganizationIdentityNotSetError, + //InvalidPostOperationError, + ValidationError, + BadRequestError, + NotFoundError, + AlreadyRevokedError, + SelfRevocationError, +} = require('../../exceptions'); +const { getSchema } = require('../../lib/validation-schemas'); +const { deepFreezeStix } = require('../../lib/import-safety'); +const ServiceWithHooks = require('./hooks.service'); +const WorkflowResult = require('../../lib/workflow-result'); + +// Import required repositories +const systemConfigurationRepository = require('../../repository/system-configurations-repository'); +const identitiesRepository = require('../../repository/identities-repository'); +const userAccountsService = require('../system/user-accounts-service'); + +class BaseService extends ServiceWithHooks { + constructor(type, repository) { + super(); + this.type = type; + this.repository = repository; + + // Initialize caches for identity lookups + this.identityCache = new Map(); + this.userAccountCache = new Map(); + } + + // ============================ + // Pagination and Utility Methods + // ============================ + + static paginate(options, results) { + if (options.includePagination) { + let derivedTotalCount = 0; + if (results[0].totalCount && results[0].totalCount.length > 0) { + derivedTotalCount = results[0].totalCount[0].totalCount; + } + return { + pagination: { + total: derivedTotalCount, + offset: options.offset, + limit: options.limit, + }, + data: results[0].documents, + }; + } else { + return results[0].documents; + } + } + + // ============================ + // System Configuration Methods + // ============================ + + async retrieveOrganizationIdentityRef() { + const systemConfig = await systemConfigurationRepository.retrieveOne(); + + if (systemConfig && systemConfig.organization_identity_ref) { + return systemConfig.organization_identity_ref; + } else { + throw new OrganizationIdentityNotSetError(); + } + } + + async setDefaultMarkingDefinitionsForObject(attackObject) { + const systemConfig = await systemConfigurationRepository.retrieveOne({ lean: true }); + if (!systemConfig) return; + + const defaultMarkingDefinitions = systemConfig.default_marking_definitions || []; + + if (attackObject.stix.object_marking_refs) { + attackObject.stix.object_marking_refs = attackObject.stix.object_marking_refs.concat( + defaultMarkingDefinitions.filter((e) => !attackObject.stix.object_marking_refs.includes(e)), + ); + } else { + attackObject.stix.object_marking_refs = defaultMarkingDefinitions; + } + } + + // ============================ + // Identity Management Methods + // ============================ + + async addCreatedByAndModifiedByIdentitiesToAll(attackObjects) { + for (const attackObject of attackObjects) { + await this.addCreatedByAndModifiedByIdentities(attackObject); + } + } + + async addCreatedByAndModifiedByIdentities(attackObject) { + if (attackObject?.stix?.created_by_ref) { + await this.addCreatedByIdentity(attackObject); + } + + if (attackObject?.stix?.x_mitre_modified_by_ref) { + await this.addModifiedByIdentity(attackObject); + } + + if (attackObject?.workspace?.workflow?.created_by_user_account) { + await this.addCreatedByUserAccountWithCache(attackObject); + } + } + + async addCreatedByIdentity(attackObject) { + if (this.identityCache.has(attackObject.stix.created_by_ref)) { + attackObject.created_by_identity = this.identityCache.get(attackObject.stix.created_by_ref); + return; + } + + if (!attackObject.created_by_identity) { + try { + const identityObject = await identitiesRepository.retrieveLatestByStixIdLean( + attackObject.stix.created_by_ref, + ); + attackObject.created_by_identity = identityObject; + this.identityCache.set(attackObject.stix.created_by_ref, identityObject); + } catch (err) { + // Ignore lookup errors + logger.warn(err.message); + } + } + } + + async addModifiedByIdentity(attackObject) { + if (this.identityCache.has(attackObject.stix.x_mitre_modified_by_ref)) { + attackObject.modified_by_identity = this.identityCache.get( + attackObject.stix.x_mitre_modified_by_ref, + ); + return; + } + + if (!attackObject.modified_by_identity) { + try { + const identityObject = await identitiesRepository.retrieveLatestByStixIdLean( + attackObject.stix.x_mitre_modified_by_ref, + ); + attackObject.modified_by_identity = identityObject; + this.identityCache.set(attackObject.stix.x_mitre_modified_by_ref, identityObject); + } catch (err) { + // Ignore lookup errors + logger.warn(err.message); + } + } + } + + async addCreatedByUserAccountWithCache(attackObject) { + const userAccountRef = attackObject?.workspace?.workflow?.created_by_user_account; + if (!userAccountRef) return; + + if (this.userAccountCache.has(userAccountRef)) { + attackObject.created_by_user_account = this.userAccountCache.get(userAccountRef); + return; + } + + if (!attackObject.created_by_user_account) { + await userAccountsService.addCreatedByUserAccount(attackObject); + this.userAccountCache.set(userAccountRef, attackObject.created_by_user_account); + } + } + + // ============================ + // CRUD Operations + // ============================ + + async retrieveAll(options) { + let results; + try { + results = await this.repository.retrieveAll(options); + } catch (err) { + throw new DatabaseError(err); + } + + try { + await this.addCreatedByAndModifiedByIdentitiesToAll(results[0].documents); + } catch (err) { + throw new IdentityServiceError({ + details: err.message, + cause: err, + }); + } + return BaseService.paginate(options, results); + } + + async retrieveById(stixId, options) { + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + if (options.versions === 'all') { + const documents = await this.repository.retrieveAllById(stixId); + + try { + await this.addCreatedByAndModifiedByIdentitiesToAll(documents); + } catch (err) { + throw new IdentityServiceError({ + details: err.message, + cause: err, + }); + } + return documents; + } else if (options.versions === 'latest') { + const document = await this.repository.retrieveLatestByStixIdLean(stixId); + + if (document) { + try { + await this.addCreatedByAndModifiedByIdentities(document); + } catch (err) { + throw new IdentityServiceError({ + details: err.message, + cause: err, + }); + } + return [document]; + } else { + return []; + } + } else { + throw new InvalidQueryStringParameterError({ parameterName: 'versions' }); + } + } + + async retrieveVersionById(stixId, modified) { + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + if (!modified) { + throw new MissingParameterError('modified'); + } + + const document = await this.repository.retrieveOneByVersion(stixId, modified); + + if (!document) { + return null; + } else { + try { + await this.addCreatedByAndModifiedByIdentities(document); + } catch (err) { + throw new IdentityServiceError({ + details: err.message, + cause: err, + }); + } + return document; + } + } + + /** + * Stream multiple attack objects by their version identifiers + * @param {Array<{object_ref: string, object_modified: string}>} xMitreContents - Array of x_mitre_contents elements + * @yields {Object} Attack objects with identities populated + */ + async *streamBulkByIdAndModified(xMitreContents) { + if (!xMitreContents || !Array.isArray(xMitreContents) || xMitreContents.length === 0) { + return; + } + + // Process identities in small batches as we stream + const identityBatch = []; + const IDENTITY_BATCH_SIZE = 50; + + for await (const doc of this.repository.streamManyByIdAndModified(xMitreContents)) { + identityBatch.push(doc); + + // Process identities when batch is full + if (identityBatch.length >= IDENTITY_BATCH_SIZE) { + await Promise.all(identityBatch.map((d) => this.addCreatedByAndModifiedByIdentities(d))); + + // Yield processed documents + for (const processedDoc of identityBatch) { + yield processedDoc; + } + + // Clear the batch + identityBatch.length = 0; + } + } + + // Process remaining documents + if (identityBatch.length > 0) { + await Promise.all(identityBatch.map((d) => this.addCreatedByAndModifiedByIdentities(d))); + + for (const processedDoc of identityBatch) { + yield processedDoc; + } + } + } + + /** + * Retrieve multiple attack objects by their version identifiers + * @param {Array<{object_ref: string, object_modified: string}>} xMitreContents - Array of x_mitre_contents elements + * @returns {Promise>} Array of attack objects with identities populated + */ + async getBulkByIdAndModified(xMitreContents) { + if (!xMitreContents || !Array.isArray(xMitreContents) || xMitreContents.length === 0) { + return []; + } + const documents = await this.repository.findManyByIdAndModified(xMitreContents); + + // Process identities in parallel + await Promise.all(documents.map((doc) => this.addCreatedByAndModifiedByIdentities(doc))); + + return documents; + } + + /** + * Fields that are always server-controlled, regardless of STIX type or operation. + * Declared as a static class property for discoverability and future expansion. + * + * Note: Fields like `created_by_ref`, `x_mitre_modified_by_ref`, and `object_marking_refs` + * are intentionally NOT here — they are only server-controlled in certain contexts + * (e.g., new objects only, specific STIX types) or use merge semantics (marking definitions). + * Their handling remains in the existing composition logic of create() and updateFull(). + * + * Future additions: 'id', 'created', 'modified' (when server takes control of these) + */ + static ALWAYS_STRIPPED_STIX_FIELDS = ['x_mitre_attack_spec_version', 'revoked']; + + /** + * Silently strips universally server-controlled fields from client input. + * + * The API is idempotent with respect to these fields: clients can send them + * and they'll be ignored. The server always composes the correct values during + * the subsequent composition and set-server-controlled-fields pipeline stages. + * + * @param {Object} data - The incoming request data ({ stix, workspace }) + * @param {Object} [options] - Options + * @param {boolean} [options.preserveAttackId] - If true, preserve workspace.attack_id + * and ATT&CK external references (plumbing for future admin override scenarios) + */ + static stripServerControlledFields(data, options = {}) { + const stix = data.stix; + if (!stix) return; + + // Strip universally server-controlled STIX fields + for (const field of BaseService.ALWAYS_STRIPPED_STIX_FIELDS) { + delete stix[field]; + } + + // Strip workspace.validation — server-controlled; recomputed on every + // create/update so a stale entry from a prior GET cannot ride along. + if (data.workspace) { + delete data.workspace.validation; + } + + if (!options.preserveAttackId) { + // Strip workspace.attack_id — server generates/carries forward + if (data.workspace) { + delete data.workspace.attack_id; + } + + // Filter ATT&CK source refs from external_references; preserve user-provided refs. + // The server will generate the correct ATT&CK ref and prepend it at index 0. + if (stix.external_references) { + stix.external_references = stix.external_references.filter( + (ref) => !config.attackSourceNames.includes(ref.source_name), + ); + } + } + } + + /** + * Recursively removes properties whose value is an empty string from an object. + * This prevents clients from persisting meaningless empty-string values. + * + * @param {Object} obj - Any plain object (stix, workspace, nested sub-objects) + */ + static stripEmptyStrings(obj) { + if (!obj || typeof obj !== 'object') return; + + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val === '') { + delete obj[key]; + } else if (val && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) { + BaseService.stripEmptyStrings(val); + } + } + } + + /** + * 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 }) + */ + static 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(); + } + } + } + + /** + * Validates the fully-composed STIX object against the ADM schema. + * + * This runs AFTER all server-controlled fields have been populated (external_references, + * x_mitre_attack_spec_version, created_by_ref, etc.) and BEFORE the repository save. + * Validation errors that match a stored bypass rule are filtered out. + * + * @param {Object} data - The composed request data ({ stix, workspace }) + * @returns {Promise<{ errors: Array, warnings: Array }>} Validation results + */ + 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 via the event bus + const EventBus = require('../../lib/event-bus'); + const Events = require('../../lib/event-constants'); + const results = await EventBus.emit(Events.VALIDATION_BYPASS_CHECK_REQUESTED, { + errors: allErrors, + stixType, + }); + + // The handler returns { errors, warnings } + const bypassResult = results?.[0] ?? { errors: allErrors, warnings: [] }; + + return { errors: bypassResult.errors, warnings: bypassResult.warnings }; + } + + /** + * Creates a new STIX object or a new version of an existing object. + * + * Pipeline stages: + * 1. ANALYZE REQUEST — validate type, determine new vs new-version + * 2. COMPOSE OBJECT — strip server-controlled fields, generate ATT&CK ID + external refs + * 3. SET SERVER-CONTROLLED FIELDS — spec version, identity refs, marking definitions + * 4. LIFECYCLE HOOKS — subclass data transformations (beforeCreate) + * 5. VALIDATE WITH ADM — full schema validation on the composed object + * 6. PERSIST — save document, run afterCreate hook, emit event (skip if dryRun) + * + * @param {Object} data - The request data ({ stix, workspace }) + * @param {Object} [options] - Options + * @param {boolean} [options.import] - If true, use the import path (STIX bundle import) + * @param {string} [options.userAccountId] - The authenticated user's account ID + * @param {string} [options.parentTechniqueId] - Parent technique ATT&CK ID (for subtechniques) + * @param {boolean} [options.dryRun] - If true, compose and validate but skip persistence + * @param {object} [options.automationContext] - Optional automation metadata for logging ({ automationName, runId }) + * @returns {Object} The created document (or composed data if dryRun) with warnings array + */ + async create(data, options) { + options = options || {}; + + // ────────────────────────────────────────────── + // 1. ANALYZE REQUEST + // ────────────────────────────────────────────── + if (data?.stix?.type !== this.type) { + throw new InvalidTypeError(); + } + + if (options.import) { + return this._createFromImport(data, options); + } + + // Determine if this is a new object or a new version of an existing object + let existingVersion = null; + if (data.stix?.id) { + // TODO change this to repository's get latest method - there should be a method for that + const existingVersions = await this.repository.retrieveAllById(data.stix.id); + if (existingVersions?.length > 0) { + existingVersion = existingVersions[0]; + logger.debug( + `Found existing version(s) with stix.id: ${data.stix.id}, will reuse attack_id: ${existingVersion.workspace?.attack_id}`, + ); + } + } + // TODO: diff analysis — compare posted fields vs existingVersion fields + + // ────────────────────────────────────────────── + // 2. COMPOSE OBJECT + // ────────────────────────────────────────────── + + // For matrices, capture the external_id from the client-provided ATT&CK reference + // before stripping removes it. Matrices don't have auto-generated ATT&CK IDs; + // their external_id is the domain name (e.g., "enterprise-attack"). + let matrixExternalId; + if (data.stix?.type === 'x-mitre-matrix') { + matrixExternalId = + findAttackExternalReference(existingVersion?.stix?.external_references)?.external_id || + findAttackExternalReference(data.stix?.external_references)?.external_id; + } + + BaseService.stripServerControlledFields(data, options); + BaseService.stripEmptyStrings(data.stix); + BaseService.stripEmptyStrings(data.workspace); + BaseService.normalizeDateFields(data); + data.stix.external_references = data.stix.external_references || []; + + // Generate or reuse the ATT&CK ID + if (attackIdGenerator.requiresAttackId(this.type)) { + let attackId; + + if (existingVersion) { + // Reuse the attack_id from the existing version + attackId = existingVersion.workspace.attack_id; + logger.debug(`Reusing ATT&CK ID from existing version: ${attackId}`); + } else { + const isSubtechnique = data.stix?.x_mitre_is_subtechnique === true; + const parentTechniqueId = options?.parentTechniqueId; + + // Validate subtechnique requirements + if (isSubtechnique && !parentTechniqueId) { + logger.warn( + 'Subtechniques require a parentTechniqueId query parameter. Provide the parent technique ATT&CK ID (e.g., T1234).', + ); + // TODO start throwing after migrating workflow to BE + // throw new InvalidPostOperationError( + // 'Subtechniques require a parentTechniqueId query parameter. Provide the parent technique ATT&CK ID (e.g., T1234).', + // ); + } + if (!isSubtechnique && parentTechniqueId) { + logger.warn( + 'parentTechniqueId query parameter is only valid for subtechniques (x_mitre_is_subtechnique: true).', + ); + // TODO start throwing after migrating workflow to BE + // throw new InvalidPostOperationError( + // 'parentTechniqueId query parameter is only valid for subtechniques (x_mitre_is_subtechnique: true).', + // ); + } + + // 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; + } + + // Generate and prepend the ATT&CK external reference + let attackRef; + if (matrixExternalId) { + // Matrices derive their external reference from the domain name, not workspace.attack_id + attackRef = buildAttackExternalReference(matrixExternalId, data.stix.type); + } else { + attackRef = createAttackExternalReference(data, { previousVersion: existingVersion }); + } + if (attackRef) { + data.stix.external_references.unshift(attackRef); + } + + // TODO is this the best approach? + if (data.stix.external_references.length === 0) { + // remove field + delete data.stix.external_references; + } + + // ────────────────────────────────────────────── + // 3. SET SERVER-CONTROLLED FIELDS + // ────────────────────────────────────────────── + // 3a. STIX fields + data.stix.x_mitre_attack_spec_version = config.app.attackSpecVersion; + // TODO: data.stix.modified = new Date().toISOString() (when server controls timestamps) + + const organizationIdentityRef = await this.retrieveOrganizationIdentityRef(); + + // Check for an existing object (may differ from existingVersion if stix.id was just generated) + let existingObject; + if (data.stix.id) { + existingObject = await this.repository.retrieveOneById(data.stix.id); + } + + if (existingObject) { + // New version of an existing object — carry forward revoked status, set modified_by + data.stix.revoked = existingObject.stix.revoked ?? false; + data.stix.x_mitre_modified_by_ref = organizationIdentityRef; + } else { + // Brand-new object — set ID, created_by, modified_by, revoked + if (!data.stix.id) { + data.stix.id = `${data.stix.type}--${uuid.v4()}`; + } + if (!data.stix.created) { + data.stix.created = new Date().toISOString(); + } + data.stix.revoked = false; + data.stix.created_by_ref = organizationIdentityRef; + data.stix.x_mitre_modified_by_ref = organizationIdentityRef; + } + + // Set modified timestamp if not set by client — set for both new and existing objects + if (!data.stix.modified) { + data.stix.modified = new Date().toISOString(); + } + + // Set default spec_version if not provided by client + if (!data.stix.spec_version) { + data.stix.spec_version = '2.1'; + } + + // 3b. Metadata fields + if (options.userAccountId) { + // TODO is this the best approach? We should explore using a DTO or similar pattern to avoid mutating the input data object directly + if (!data.workspace.workflow) { + data.workspace.workflow = {}; + } + data.workspace.workflow.created_by_user_account = options.userAccountId; + } + await this.setDefaultMarkingDefinitionsForObject(data); + + // ────────────────────────────────────────────── + // 4. LIFECYCLE HOOKS + // ────────────────────────────────────────────── + await this.beforeCreate(data, options); + + // ────────────────────────────────────────────── + // 5. VALIDATE WITH ADM + // ────────────────────────────────────────────── + const { errors, warnings } = await this.validateComposedObject(data); + + if (errors.length > 0) { + throw new ValidationError('ADM validation failed', { details: errors, warnings }); + } + + // ────────────────────────────────────────────── + // 6. PERSIST (skip if dry-run) + // ────────────────────────────────────────────── + if (options.dryRun) { + return { ...data, warnings }; + } + + const createdDocument = await this.repository.save(data); + await this.afterCreate(createdDocument, options); + await this.emitCreatedEvent(createdDocument, options); + + const result = createdDocument.toObject ? createdDocument.toObject() : createdDocument; + result.warnings = warnings; + return result; + } + + /** + * Import path for create(): handles STIX bundle imports where the object + * already has server-controlled fields populated by the source system. + * + * @param {Object} data - The request data ({ stix, workspace }) + * @param {Object} options - Options passed from create() + * @returns {Object} The created document + * @private + */ + async _createFromImport(data, options) { + const { + data: composed, + warnings, + throwIfValidating, + } = await this.composeForImport(data, options); + + if (throwIfValidating) throw throwIfValidating; + + if (options.dryRun) { + return { ...composed, warnings }; + } + + // Import-fidelity contract: bundle stix must be persisted byte-faithful. + // Several `beforeCreate` hooks normally rewrite stix fields (e.g. + // AnalyticsService stamps stix.name from the ATT&CK ID; SoftwareService + // defaults stix.is_family). Those rewrites are intentional on user-driven + // POST/PUT flows but must NOT run during import. We freeze stix before + // invoking the hook so any forgotten `if (!options.import)` gate throws + // a TypeError at the violating line instead of silently corrupting the + // imported content. See app/lib/import-safety.js for the full rationale. + deepFreezeStix(composed); + await this.beforeCreate(composed, options); + + const createdDocument = await this.repository.save(composed); + + // Same contract applies to `afterCreate` and the listeners it triggers + // via emitted domain events — anything that reaches stix on this freshly + // saved document during import must crash, not silently mutate. + deepFreezeStix(createdDocument); + await this.afterCreate(createdDocument, options); + await this.emitCreatedEvent(createdDocument, options); + + const result = createdDocument.toObject ? createdDocument.toObject() : createdDocument; + result.warnings = warnings; + return result; + } + + /** + * Compose and validate an object for import — no I/O, no events. + * + * Stamps `workspace.attack_id` from the bundle's ATT&CK external reference, + * runs ADM validation (unless the object is revoked/deprecated), and + * applies fail-open semantics by attaching `workspace.validation` when + * errors are found and `options.validateContents` is not set. + * + * The result is a plain object ready to hand to `repository.save()` or + * `repository.saveMany()`. The bundle-import path uses this directly so it + * can batch persistence; the single-object import path wraps it in + * `_createFromImport` to keep lifecycle hooks and event emission. + * + * @param {Object} data - The request data ({ stix, workspace }) + * @param {Object} options - Options passed from create() + * @returns {Promise<{ + * data: Object, + * warnings: Array, + * throwIfValidating: ValidationError|null, + * validationErrors: Array<{message,path,code,input}> + * }>} + * - `validationErrors` is the full list of ADM errors that fired against + * this object (after bypass filtering). The bundle-import path uses it + * to surface per-object validation failures in + * `workspace.import_categories.errors` so an import response + * accurately reflects what was wrong — without that, fail-open mode + * silently buries the errors on each document and the import looks + * successful even when many objects are malformed. + */ + async composeForImport(data, options) { + // Strip workspace.validation — server-controlled; the fail-open block + // below is the only legitimate writer of this field on the import path. + if (data.workspace) { + delete data.workspace.validation; + } + + // Extract ATT&CK ID from external_references and propagate to workspace.attack_id + const attackIdInExternalReferences = attackIdGenerator.extractAttackIdFromExternalReferences( + data.stix, + ); + if (attackIdInExternalReferences) { + data.workspace = data.workspace || {}; + data.workspace.attack_id = attackIdInExternalReferences; + } + + // Skip validation entirely for revoked or deprecated objects + const isRevoked = data.stix?.revoked === true; + const isDeprecated = data.stix?.x_mitre_deprecated === true; + + let errors = []; + let warnings = []; + + if (!isRevoked && !isDeprecated) { + ({ errors, warnings } = await this.validateComposedObject(data)); + } + + let throwIfValidating = null; + if (errors.length > 0) { + if (options.validateContents) { + throwIfValidating = new ValidationError('ADM validation failed', { + details: errors, + warnings, + }); + } else { + // Fail-open: store validation errors on the document + const { ATTACK_SPEC_VERSION } = require('@mitre-attack/attack-data-model'); + const admPkg = require('@mitre-attack/attack-data-model/package.json'); + + data.workspace = data.workspace || {}; + data.workspace.validation = { + errors: errors.map((e) => ({ message: e.message, path: e.path, code: e.code })), + attack_spec_version: ATTACK_SPEC_VERSION, + adm_version: admPkg.version, + validated_at: new Date(), + }; + + logger.warn( + `Import: ${data.stix.id} has ${errors.length} validation error(s), storing on document`, + ); + } + } + + return { data, warnings, throwIfValidating, validationErrors: errors }; + } + + /** + * Updates an existing STIX object version in-place. + * + * Pipeline stages: + * 1. ANALYZE REQUEST — retrieve existing document by stixId + modified + * 2. COMPOSE OBJECT — strip server-controlled fields, compose from existing document + * 3. SET SERVER-CONTROLLED FIELDS — (future: bump modified timestamp) + * 4. LIFECYCLE HOOKS — subclass data transformations (beforeUpdate) + * 5. VALIDATE WITH ADM — full schema validation on the composed object + * 6. PERSIST — merge and save document, run afterUpdate hook, emit event (skip if dryRun) + * + * @param {string} stixId - The STIX ID of the object to update + * @param {string} stixModified - The modified timestamp identifying the specific version + * @param {Object} data - The request data ({ stix, workspace }) + * @param {Object} [options] - Options + * @param {boolean} [options.dryRun] - If true, compose and validate but skip persistence + * @returns {Object|null} The updated document (or composed data if dryRun), null if not found + */ + async updateFull(stixId, stixModified, data, options) { + options = options || {}; + + // ────────────────────────────────────────────── + // 1. ANALYZE REQUEST + // ────────────────────────────────────────────── + if (!stixId) { + throw new MissingParameterError('stixId'); + } + if (!stixModified) { + throw new MissingParameterError('modified'); + } + + const document = await this.repository.retrieveOneByVersion(stixId, stixModified); + if (!document) { + return null; + } + // TODO: diff analysis — detect field-level changes vs document + // TODO: if no changes detected, short-circuit (no-op) + + // ────────────────────────────────────────────── + // 2. COMPOSE OBJECT + // ────────────────────────────────────────────── + + // For matrices, capture the external_id before stripping removes the client-provided + // ATT&CK reference. Used as a fallback when the stored document lacks one + // (e.g., matrices created before ATT&CK ref generation was added). + let matrixExternalId; + if (data.stix?.type === 'x-mitre-matrix') { + matrixExternalId = + findAttackExternalReference(document.stix?.external_references)?.external_id || + findAttackExternalReference(data.stix?.external_references)?.external_id; + } + + BaseService.stripServerControlledFields(data, options); + BaseService.stripEmptyStrings(data.stix); + BaseService.stripEmptyStrings(data.workspace); + BaseService.normalizeDateFields(data); + + // Compose server-controlled fields from existing document + data.stix.x_mitre_attack_spec_version = document.stix.x_mitre_attack_spec_version; + data.stix.revoked = document.stix.revoked ?? false; + + // Preserve x_mitre_is_subtechnique — changing subtechnique status requires + // the dedicated conversion endpoints, not the generic update path. + if (document.stix.x_mitre_is_subtechnique !== undefined) { + data.stix.x_mitre_is_subtechnique = document.stix.x_mitre_is_subtechnique; + } + + if (document.workspace?.attack_id) { + data.workspace = data.workspace || {}; + data.workspace.attack_id = document.workspace.attack_id; + } + + // Compose external_references: prepend existing ATT&CK ref onto user's refs + data.stix.external_references = data.stix.external_references || []; + const existingAttackRef = findAttackExternalReference(document.stix.external_references); + if (existingAttackRef) { + data.stix.external_references.unshift(existingAttackRef); + } else if (matrixExternalId) { + // Fallback for matrices created before ATT&CK ref generation was added + const matrixRef = buildAttackExternalReference(matrixExternalId, data.stix.type); + if (matrixRef) { + data.stix.external_references.unshift(matrixRef); + } + } + + // ────────────────────────────────────────────── + // 3. SET SERVER-CONTROLLED FIELDS + // ────────────────────────────────────────────── + // TODO: bump stix.modified if diff analysis detects changes + // TODO: set x_mitre_modified_by_ref to current user's org identity + + // ────────────────────────────────────────────── + // 4. LIFECYCLE HOOKS + // ────────────────────────────────────────────── + await this.beforeUpdate(stixId, stixModified, data, document, options); + + // ────────────────────────────────────────────── + // 5. VALIDATE WITH ADM + // ────────────────────────────────────────────── + const { errors, warnings } = await this.validateComposedObject(data); + + if (errors.length > 0) { + throw new ValidationError('ADM validation failed', { details: errors, warnings }); + } + + // Validation passed — clear any stored validation issues from a previous import + if (document.workspace?.validation) { + data.workspace = data.workspace || {}; + data.workspace.validation = undefined; + } + + // ────────────────────────────────────────────── + // 6. PERSIST (skip if dry-run) + // ────────────────────────────────────────────── + if (options.dryRun) return { ...data, warnings }; + + const newDocument = await this.repository.updateAndSave(document, data); + + if (newDocument === document) { + // If the document previously had validation issues, explicitly unset them + if (document.workspace?.validation !== undefined) { + await this.repository.unsetField(document._id, 'workspace.validation'); + } + + await this.afterUpdate(newDocument, document); + await this.emitUpdatedEvent(newDocument, document); + const result = newDocument.toObject ? newDocument.toObject() : newDocument; + result.warnings = warnings; + return result; + } else { + throw new DatabaseError({ + details: 'Document could not be saved', + document, + }); + } + } + + // TODO rename to deleteVersionByStixId and repurpose the existing name for deleting by the document's unique _id + async deleteVersionById(stixId, stixModified) { + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + if (!stixModified) { + throw new MissingParameterError('modified'); + } + + const document = await this.repository.findOneAndDelete(stixId, stixModified); + + if (!document) { + //Note: document is null if not found + return null; + } + return document; + } + + // ============================ + // Revoke Operation + // ============================ + + /** + * Revokes an object (Object A) in favor of another object (Object B). + * + * Workflow: + * 1. Validate inputs + * 2. Retrieve objects A and B + * 3. Lifecycle hook: beforeRevoke + * 4. Mark Object A as revoked (creates a new version via this.create) + * 5. Create a revoked-by relationship (A → B) + * 6. Handle relationships (transfer to B if preserveRelationships) + * 7. Lifecycle hook: afterRevoke + * 8. Emit revoked event (RelationshipsService deprecates original relationships via event listener) + * 9. Return result + * + * @param {string} stixId - The STIX ID of the object to revoke (Object A) + * @param {Object} data - Request body containing { revoking: { stixId, modified } } + * @param {Object} [options] - Options + * @param {boolean} [options.preserveRelationships] - If true, clone relationships to Object B before deleting + * @param {string} [options.userAccountId] - The authenticated user's account ID + * @returns {Object} Result with revokedObject, revokedByRelationship, relationshipsSummary + */ + async revoke(stixId, data, options = {}) { + logger.info( + `REVOKING ${stixId} in favor of ${data?.revoking?.stixId} (preserveRelationships: ${options.preserveRelationships})`, + ); + + // Lazy-load to avoid circular dependency + const relationshipsService = require('../stix/relationships-service'); + const relationshipsRepository = require('../../repository/relationships-repository'); + + // ────────────────────────────────────────────── + // 1. VALIDATE INPUTS + // ────────────────────────────────────────────── + if (!stixId) { + throw new MissingParameterError('stixId'); + } + if (!data?.revoking?.stixId) { + throw new MissingParameterError('revoking.stixId'); + } + if (!data?.revoking?.modified) { + throw new MissingParameterError('revoking.modified'); + } + if (stixId === data.revoking.stixId) { + throw new SelfRevocationError(); + } + + // ────────────────────────────────────────────── + // 2. RETRIEVE OBJECTS + // ────────────────────────────────────────────── + const objectA = await this.repository.retrieveLatestByStixId(stixId); + if (!objectA) { + throw new NotFoundError({ details: `Object A with stixId ${stixId} not found` }); + } + if (objectA.stix.revoked === true) { + throw new AlreadyRevokedError({ details: `Object ${stixId} is already revoked` }); + } + + const objectB = await this.repository.retrieveOneByVersion( + data.revoking.stixId, + data.revoking.modified, + ); + if (!objectB) { + throw new NotFoundError({ + details: `Object B with stixId ${data.revoking.stixId} and modified ${data.revoking.modified} not found`, + }); + } + if (objectB.stix.type !== this.type) { + throw new BadRequestError({ + details: `Revoking object must be of the same type (${this.type}), got ${objectB.stix.type}`, + }); + } + + // ────────────────────────────────────────────── + // 3. LIFECYCLE HOOK: beforeRevoke + // ────────────────────────────────────────────── + await this.beforeRevoke(objectA, objectB, options); + + // ────────────────────────────────────────────── + // 4. MARK OBJECT A AS REVOKED + // ────────────────────────────────────────────── + // Clone Object A and set revoked = true, then persist directly via the repository. + // We bypass this.create() because the object is already fully composed and validated — + // routing it through create() would strip the revoked flag (which is server-controlled). + const objectAData = objectA.toObject ? objectA.toObject() : { ...objectA }; + delete objectAData._id; + delete objectAData.__v; + delete objectAData.__t; + objectAData.stix.revoked = true; + objectAData.stix.modified = new Date().toISOString(); + if (options.userAccountId) { + objectAData.workspace = objectAData.workspace || {}; + objectAData.workspace.workflow = objectAData.workspace.workflow || {}; + objectAData.workspace.workflow.created_by_user_account = options.userAccountId; + } + + const revokedDocument = await this.repository.save(objectAData); + + const result = new WorkflowResult('revoke'); + result.setPrimary(revokedDocument); + + // ────────────────────────────────────────────── + // 5. CREATE REVOKED-BY RELATIONSHIP + // ────────────────────────────────────────────── + // NOTE: This is a direct cross-service write (BaseService → RelationshipsService.create). + // The revoke workflow predates the event-driven architecture and is shared by all SDO types. + // TODO: Migrate to an event-driven pattern for consistency with the conversion workflows. + const now = new Date().toISOString(); + const revokedByRelationship = await relationshipsService.create( + { + workspace: { + workflow: {}, + }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'revoked-by', + source_ref: objectA.stix.id, + target_ref: objectB.stix.id, + created: now, + modified: now, + }, + }, + { userAccountId: options.userAccountId }, + ); + result.addCreated(revokedByRelationship); + + // TODO what if relationshipsService.create fails after we've already marked Object A as revoked? + // We should have error handling to attempt to roll back the revoked status if the relationship + // creation fails, to avoid leaving the system in a broken state where Object A is revoked but + // there's no link to Object B. This could be done with a try/catch around the relationship creation, + // and in the catch block we would attempt to set revoked back to false on Object A and save it again. + // We would also need to handle potential errors in that rollback attempt and log them appropriately. + + // ────────────────────────────────────────────── + // 6. HANDLE RELATIONSHIPS (transfer if preserveRelationships is set) + // ────────────────────────────────────────────── + if (options.preserveRelationships) { + const existingRelationships = await relationshipsRepository.retrieveAllBySourceOrTarget( + objectA.stix.id, + ); + + // Exclude the revoked-by relationship we just created + const relationshipsToProcess = existingRelationships.filter( + (rel) => rel.stix.id !== revokedByRelationship.stix.id, + ); + // Build a set of relationship triples (source_ref--relationship_type--target_ref) + // that Object B already participates in, so we can skip duplicates. + const objectBRelationships = await relationshipsRepository.retrieveAllBySourceOrTarget( + objectB.stix.id, + ); + const objectBRelTriples = new Set( + objectBRelationships.map( + (r) => `${r.stix.source_ref}--${r.stix.relationship_type}--${r.stix.target_ref}`, + ), + ); + + for (const rel of relationshipsToProcess) { + try { + // Skip subtechnique-of relationships — hierarchy relationships must be managed + // separately via the conversion endpoints, not transferred during revocation. + if (rel.stix.relationship_type === 'subtechnique-of') { + logger.info( + `Skipping subtechnique-of relationship ${rel.stix.id} during preservation (hierarchy relationships are not transferred)`, + ); + result.addWarning({ + message: 'Hierarchy relationship not transferred', + reason: 'subtechnique-of', + relationship: { + id: rel.stix.id, + source_ref: rel.stix.source_ref, + target_ref: rel.stix.target_ref, + relationship_type: rel.stix.relationship_type, + }, + }); + continue; + } + + // TODO here is another use case for a more robust composition layer or a DTO pattern — we are manually cloning and modifying relationship objects, which is error-prone and may not scale well if relationships have more complex fields in the future. A composition layer could handle cloning an existing relationship and substituting references while ensuring all required fields are correctly set. + const relData = { ...rel }; + delete relData._id; + delete relData.__v; + delete relData.__t; + + // Reset timestamps + relData.stix.created = now; + relData.stix.modified = now; + + // Substitute Object B for Object A + if (relData.stix.source_ref === objectA.stix.id) { + relData.stix.source_ref = objectB.stix.id; + } + if (relData.stix.target_ref === objectA.stix.id) { + relData.stix.target_ref = objectB.stix.id; + } + + // Skip if Object B already has an equivalent relationship + const candidateTriple = `${relData.stix.source_ref}--${relData.stix.relationship_type}--${relData.stix.target_ref}`; + if (objectBRelTriples.has(candidateTriple)) { + logger.info( + `Skipping duplicate relationship transfer: ${candidateTriple} already exists on Object B`, + ); + result.addWarning({ + message: 'Duplicate relationship transfer skipped', + skipped: { + id: rel.stix.id, + source_ref: rel.stix.source_ref, + target_ref: rel.stix.target_ref, + relationship_type: rel.stix.relationship_type, + description: rel.stix.description, + }, + existing: { + id: relData.stix.id, + source_ref: relData.stix.source_ref, + target_ref: relData.stix.target_ref, + relationship_type: relData.stix.relationship_type, + description: relData.stix.description, + }, + }); + continue; + } + + // Generate a new STIX ID for the cloned relationship + relData.stix.id = `relationship--${uuid.v4()}`; + + const transferredRel = await relationshipsService.create(relData, { + userAccountId: options.userAccountId, + }); + result.addCreated(transferredRel); + + // Track the newly created triple so subsequent iterations don't create duplicates + objectBRelTriples.add(candidateTriple); + } catch (err) { + logger.warn(`Failed to transfer relationship ${rel.stix.id}: ${err.message}`); + result.addWarning({ + message: 'Relationship transfer failed', + relationship: { + id: rel.stix.id, + description: rel.stix.description, + source_ref: rel.stix.source_ref, + target_ref: rel.stix.target_ref, + relationship_type: rel.stix.relationship_type, + }, + error: err.message, + }); + } + } + } + + // ────────────────────────────────────────────── + // 7. LIFECYCLE HOOK: afterRevoke + // ────────────────────────────────────────────── + await this.afterRevoke(revokedDocument, objectB, options); + + // ────────────────────────────────────────────── + // 8. EMIT EVENT + // ────────────────────────────────────────────── + // RelationshipsService listens for revoked events and deprecates all relationships + // referencing the revoked object (except those in excludeRelationshipIds). + // EventBus.emit() awaits all listeners, so deprecation completes before we return. + // Handler results (deprecated docs, warnings) are merged into the WorkflowResult. + const excludeRelationshipIds = [revokedByRelationship.stix.id]; + const eventResults = await this.emitRevokedEvent(revokedDocument, objectB, options, { + excludeRelationshipIds, + }); + result.mergeEventResults(eventResults); + + // ────────────────────────────────────────────── + // 9. RETURN RESULT + // ────────────────────────────────────────────── + return result.toJSON(); + } + + // TODO rename to deleteManyByStixId + async deleteById(stixId) { + if (!stixId) { + throw new MissingParameterError('stixId'); + } + return await this.repository.deleteMany(stixId); + } +} + +module.exports = BaseService; diff --git a/app/services/meta-classes/hooks.service.js b/app/services/meta-classes/hooks.service.js new file mode 100644 index 00000000..d3999433 --- /dev/null +++ b/app/services/meta-classes/hooks.service.js @@ -0,0 +1,192 @@ +/* eslint-disable no-unused-vars */ +// Note there is a bug in eslint where single line comment will not work ^ +'use strict'; + +const logger = require('../../lib/logger'); +const EventBus = require('../../lib/event-bus'); +const Exceptions = require('../../exceptions'); + +class ServiceWithHooks { + /** ******************************** CREATE ******************************** */ + /** + * Lifecycle hook: Called before create() saves the document + * Subclasses can override to prepare data + * @param {object} _data - The data being created + * @param {object} _options - Creation options + */ + + async beforeCreate(_data, _options) { + // Default: no-op + } + + create(data, options) { + throw new Exceptions.NotImplementedError(this.constructor.name, 'create'); + } + + /** + * Lifecycle hook: Called after create() saves the document + * Subclasses can override to handle post-creation logic + * @param {object} _document - The created document + * @param {object} _options - Creation options + */ + + async afterCreate(_document, _options) { + // Default: no-op + } + + /** + * Emit event after document creation + * @param {object} document - The created document + * @param {object} options - Creation options + */ + async emitCreatedEvent(document, options) { + const eventName = `${this.type}::created`; + + logger.info(`Emitting event '${eventName}' for ${document.stix.id}`); + + await EventBus.emit(eventName, { + stixId: document.stix.id, + document: document.toObject ? document.toObject() : document, + type: this.type, + options, + }); + + logger.info(`Event '${eventName}' emission complete`); + } + + /** ******************************** READ ******************************** */ + /** ************ (these dont need hooks or event emitters) *************** */ + + // TODO add JSDoc + retrieveAll(options) { + throw new Exceptions.NotImplementedError(this.constructor.name, 'retrieveAll'); + } + + // TODO add JSDoc + retrieveById(stixId, options) { + throw new Exceptions.NotImplementedError(this.constructor.name, 'retrieveById'); + } + + // TODO add JSDoc + retrieveVersionById(stixId, modified) { + throw new Exceptions.NotImplementedError(this.constructor.name, 'retrieveVersionById'); + } + /** ******************************** UPDATE ******************************** */ + + /** + * Lifecycle hook: Called before updateFull() saves the document + * Subclasses can override to prepare data + * @param {string} _stixId - The STIX ID + * @param {string} _stixModified - The modified timestamp + * @param {object} _data - The update data + * @param {object} _existingDocument - The existing document + * @param {object} [_options] - Options (e.g., { dryRun: true }) + */ + async beforeUpdate(_stixId, _stixModified, _data, _existingDocument, _options) { + // Default: no-op + } + + // TODO add JSDoc + updateFull(stixId, stixModified, data) { + throw new Exceptions.NotImplementedError(this.constructor.name, 'updateFull'); + } + + /** + * Lifecycle hook: Called after updateFull() saves the document + * Subclasses can override to handle post-update logic + * @param {object} _updatedDocument - The updated document + * @param {object} _previousDocument - The previous document (before update) + */ + async afterUpdate(_updatedDocument, _previousDocument) { + // Default: no-op + } + + /** + * Emit event after document update + * @param {object} updatedDocument - The updated document + * @param {object} previousDocument - The previous document (before update) + */ + async emitUpdatedEvent(updatedDocument, previousDocument) { + const EventBus = require('../../lib/event-bus'); + const eventName = `${this.type}::updated`; + await EventBus.emit(eventName, { + stixId: updatedDocument.stix.id, + stixModified: updatedDocument.stix.modified, + document: updatedDocument.toObject ? updatedDocument.toObject() : updatedDocument, + previousDocument: previousDocument.toObject ? previousDocument.toObject() : previousDocument, + type: this.type, + }); + } + + /** ******************************** DELETE ******************************** */ + // TODO there are multiple delete methods (e.g., deleteById, deleteVersionById): it is unclear whether they can/should share lifecycle hooks or have their own; if their own, then can the delete methods be consolidated? + + /** + * Lifecycle hook: Called before deleteVersionById() saves the document + * Subclasses can override to prepare data + * @param {string} _stixId - The STIX ID + * @param {string} _stixModified - The modified timestamp + * @param {object} _data - The update data + * @param {object} _existingDocument - The existing document + */ + + async beforeDeleteVersionById(_stixId, _stixModified, _data, _existingDocument) { + // Default: no-op + } + + async afterDeleteVersionById(_deletedDocument) { + /// Default: no-op + } + + /** ******************************** REVOKE ******************************** */ + + /** + * Lifecycle hook: Called before revoke() processes the revocation + * Subclasses can override to validate or prepare data + * @param {object} _objectA - The object being revoked + * @param {object} _objectB - The revoking object + * @param {object} _options - Revocation options + */ + async beforeRevoke(_objectA, _objectB, _options) { + // Default: no-op + } + + /** + * Lifecycle hook: Called after revoke() completes + * Subclasses can override to handle post-revocation logic + * @param {object} _revokedDocument - The revoked document + * @param {object} _objectB - The revoking object + * @param {object} _options - Revocation options + */ + async afterRevoke(_revokedDocument, _objectB, _options) { + // Default: no-op + } + + /** + * Emit event after document revocation + * @param {object} revokedDocument - The revoked document + * @param {object} revokingDocument - The revoking document + * @param {object} options - Revocation options + * @param {object} [metadata] - Additional event metadata + * @param {string[]} [metadata.excludeRelationshipIds] - Relationship STIX IDs to exclude from deprecation + */ + async emitRevokedEvent(revokedDocument, revokingDocument, options, metadata = {}) { + const eventName = `${this.type}::revoked`; + + logger.info(`Emitting event '${eventName}' for ${revokedDocument.stix.id}`); + + const results = await EventBus.emit(eventName, { + stixId: revokedDocument.stix.id, + revokedDocument: revokedDocument.toObject ? revokedDocument.toObject() : revokedDocument, + revokingDocument: revokingDocument.toObject ? revokingDocument.toObject() : revokingDocument, + type: this.type, + options, + excludeRelationshipIds: metadata.excludeRelationshipIds || [], + }); + + logger.info(`Event '${eventName}' emission complete`); + return results; + } +} + +module.exports = ServiceWithHooks; diff --git a/app/services/meta-classes/index.js b/app/services/meta-classes/index.js new file mode 100644 index 00000000..aac43c9f --- /dev/null +++ b/app/services/meta-classes/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const BaseService = require('./base.service'); +const ServiceWithHooks = require('./hooks.service'); + +module.exports = { + BaseService, + ServiceWithHooks, +}; diff --git a/app/services/mitigations-service.js b/app/services/mitigations-service.js deleted file mode 100644 index 78c4b161..00000000 --- a/app/services/mitigations-service.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const mitigationsRepository = require('../repository/mitigations-repository'); -const BaseService = require('./_base.service'); -const { Mitigation: MitigationType } = require('../lib/types'); - -class MitigationsService extends BaseService {} - -module.exports = new MitigationsService(MitigationType, mitigationsRepository); diff --git a/app/services/relationships-service.js b/app/services/relationships-service.js deleted file mode 100644 index 7df71a5b..00000000 --- a/app/services/relationships-service.js +++ /dev/null @@ -1,107 +0,0 @@ -'use strict'; - -const BaseService = require('./_base.service'); -const relationshipsRepository = require('../repository/relationships-repository'); -const { Relationship: RelationshipType } = require('../lib/types'); - -// Map STIX types to ATT&CK types -const objectTypeMap = new Map([ - ['malware', 'software'], - ['tool', 'software'], - ['attack-pattern', 'technique'], - ['intrusion-set', 'group'], - ['campaign', 'campaign'], - ['x-mitre-asset', 'asset'], - ['course-of-action', 'mitigation'], - ['x-mitre-tactic', 'tactic'], - ['x-mitre-matrix', 'matrix'], - ['x-mitre-data-component', 'data-component'], - ['x-mitre-detection-strategy', 'detection-strategy'], -]); - -class RelationshipsService extends BaseService { - async retrieveAll(options) { - let results = await this.repository.retrieveAll(options); - - // Filter out relationships that don't reference the source type - if (options.sourceType) { - results = results.filter((document) => { - if (document.source_objects.length === 0) { - return false; - } else { - document.source_objects.sort((a, b) => b.stix.modified - a.stix.modified); - return objectTypeMap.get(document.source_objects[0].stix.type) === options.sourceType; - } - }); - } - - // Filter out relationships that don't reference the target type - if (options.targetType) { - results = results.filter((document) => { - if (document.target_objects.length === 0) { - return false; - } else { - document.target_objects.sort((a, b) => b.stix.modified - a.stix.modified); - return objectTypeMap.get(document.target_objects[0].stix.type) === options.targetType; - } - }); - } - - const prePaginationTotal = results.length; - - // Apply pagination parameters - if (options.offset || options.limit) { - const start = options.offset || 0; - if (options.limit) { - const end = start + options.limit; - results = results.slice(start, end); - } else { - results = results.slice(start); - } - } - - // Move latest source and target objects to a non-array property, then remove array of source and target objects - for (const document of results) { - if (Array.isArray(document.source_objects)) { - if (document.source_objects.length === 0) { - document.source_objects = undefined; - } else { - document.source_object = document.source_objects[0]; - document.source_objects = undefined; - } - } - - if (Array.isArray(document.target_objects)) { - if (document.target_objects.length === 0) { - document.target_objects = undefined; - } else { - document.target_object = document.target_objects[0]; - document.target_objects = undefined; - } - } - } - - if (options.includeIdentities) { - await this.addCreatedByAndModifiedByIdentitiesToAll(results); - } - - if (options.includePagination) { - return { - pagination: { - total: prePaginationTotal, - offset: options.offset, - limit: options.limit, - }, - data: results, - }; - } else { - return results; - } - } -} - -// Default export -module.exports.RelationshipsService = RelationshipsService; - -// Default export - export an instance of the service -module.exports = new RelationshipsService(RelationshipType, relationshipsRepository); diff --git a/app/services/release-tracks/bundle-import-service.js b/app/services/release-tracks/bundle-import-service.js new file mode 100644 index 00000000..412fcf51 --- /dev/null +++ b/app/services/release-tracks/bundle-import-service.js @@ -0,0 +1,313 @@ +'use strict'; + +// ============================================================================= +// Bundle Import Service +// +// Parses a STIX 2.1 bundle and creates a new release track from it. +// +// This is an independent implementation that does NOT depend on the legacy +// collection-bundles infrastructure. It will eventually supplant that system +// once it has been tested, validated, and shipped. +// +// The import process: +// 1. Extract collection metadata (if an x-mitre-collection object is present) +// 2. Sort non-collection objects by dependency order +// 3. Import each object into the database (skip duplicates) +// 4. Build member entries from all imported/existing objects +// 5. Create a new release track with those members +// ============================================================================= + +const types = require('../../lib/types'); +const logger = require('../../lib/logger'); +const snapshotService = require('./snapshot-service'); +const { BadRequestError, DuplicateIdError } = require('../../exceptions'); + +// --------------------------------------------------------------------------- +// Service map — lazy-loaded to avoid circular dependency issues at startup. +// +// Maps STIX type prefixes to STIX services so we can call +// `service.create(data, { import: true })` for each object. +// --------------------------------------------------------------------------- + +let _serviceMap = null; + +function getServiceMap() { + if (_serviceMap) return _serviceMap; + + _serviceMap = { + [types.Technique]: require('../stix/techniques-service'), + [types.Tactic]: require('../stix/tactics-service'), + [types.Group]: require('../stix/groups-service'), + [types.Campaign]: require('../stix/campaigns-service'), + [types.Mitigation]: require('../stix/mitigations-service'), + [types.Matrix]: require('../stix/matrices-service'), + [types.Relationship]: require('../stix/relationships-service'), + [types.MarkingDefinition]: require('../stix/marking-definitions-service'), + [types.Identity]: require('../stix/identities-service'), + [types.Note]: require('../../services/system/notes-service'), + [types.DataSource]: require('../stix/data-sources-service'), + [types.DataComponent]: require('../stix/data-components-service'), + [types.Asset]: require('../stix/assets-service'), + [types.Analytic]: require('../stix/analytics-service'), + [types.DetectionStrategy]: require('../stix/detection-strategies-service'), + }; + + // Software types share a single service + const softwareService = require('../stix/software-service'); + _serviceMap[types.Malware] = softwareService; + _serviceMap[types.Tool] = softwareService; + + return _serviceMap; +} + +// --------------------------------------------------------------------------- +// Dependency sort order — ensures referenced objects are created before +// objects that reference them. Same ordering as import-bundle.js. +// --------------------------------------------------------------------------- + +const TYPE_SORT_ORDER = { + [types.MarkingDefinition]: 0, + [types.Identity]: 1, + [types.DataSource]: 2, + [types.DataComponent]: 3, + [types.Analytic]: 4, + [types.DetectionStrategy]: 5, + [types.Technique]: 6, + [types.Tactic]: 7, + [types.Mitigation]: 8, + [types.Group]: 9, + [types.Campaign]: 10, + [types.Malware]: 11, + [types.Tool]: 12, + [types.Asset]: 13, + [types.Matrix]: 14, + [types.Relationship]: 15, + [types.Note]: 16, +}; + +function getTypeSortOrder(stixType) { + return TYPE_SORT_ORDER[stixType] ?? 99; +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Extract the x-mitre-collection object from the bundle (if present). + * Returns `{ collectionObj, otherObjects }`. + */ +function extractCollectionObject(objects) { + let collectionObj = null; + const otherObjects = []; + + for (const obj of objects) { + if (obj.type === 'x-mitre-collection') { + // Take the first collection object; ignore duplicates + if (!collectionObj) { + collectionObj = obj; + } else { + logger.warn('BundleImportService: Multiple x-mitre-collection objects found; using first'); + } + } else { + otherObjects.push(obj); + } + } + + return { collectionObj, otherObjects }; +} + +/** + * Sort objects by dependency order for safe sequential import. + */ +function sortByDependencyOrder(objects) { + return [...objects].sort((a, b) => getTypeSortOrder(a.type) - getTypeSortOrder(b.type)); +} + +/** + * Import a single STIX object into the database, skipping if it already exists. + * + * @param {Object} stixObj - A raw STIX object from the bundle + * @param {Object} serviceMap - Type → service mapping + * @returns {Promise<{imported: boolean, ref: {object_ref: string, object_modified: string}}>} + */ +async function importObject(stixObj, serviceMap) { + const service = serviceMap[stixObj.type]; + if (!service) { + logger.warn( + `BundleImportService: No service for type "${stixObj.type}", skipping "${stixObj.id}"`, + ); + return { imported: false, ref: null }; + } + + // Validate required fields + if (!stixObj.id || !stixObj.modified) { + logger.warn( + `BundleImportService: Object missing id or modified, skipping: ${JSON.stringify({ id: stixObj.id, type: stixObj.type })}`, + ); + return { imported: false, ref: null }; + } + + const ref = { + object_ref: stixObj.id, + object_modified: stixObj.modified, + }; + + // Check if this exact version already exists + try { + const existing = await service.retrieveVersionById(stixObj.id, stixObj.modified); + if (existing) { + logger.verbose( + `BundleImportService: Object "${stixObj.id}" @ ${stixObj.modified} already exists, skipping`, + ); + return { imported: false, ref }; + } + } catch (err) { + // retrieveVersionById may throw for various reasons; proceed with create attempt + logger.debug( + `BundleImportService: Could not check existence of "${stixObj.id}": ${err.message}`, + ); + } + + // Create the object + try { + const data = { + stix: stixObj, + workspace: {}, + }; + + await service.create(data, { import: true }); + + logger.verbose(`BundleImportService: Imported "${stixObj.type}" "${stixObj.id}"`); + return { imported: true, ref }; + } catch (err) { + if (err instanceof DuplicateIdError || err.name === 'DuplicateIdError') { + // Race condition or index-level duplicate — treat as already-existing + logger.verbose( + `BundleImportService: Duplicate detected for "${stixObj.id}", treating as existing`, + ); + return { imported: false, ref }; + } + + // Non-duplicate errors are logged but don't abort the entire import + logger.error(`BundleImportService: Failed to import "${stixObj.id}":`, err); + return { imported: false, ref }; + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Create a new release track from a STIX bundle. + * + * The bundle is parsed, its objects are imported into the database (skipping + * duplicates), and a new standard release track is created with the imported + * objects as members. + * + * If the bundle contains an `x-mitre-collection` object, its `name` and + * `description` are used as track metadata, and its `x_mitre_contents` (if + * present) is used as the authoritative member list. + * + * @param {Object} bundleData - Validated bundle: { type: 'bundle', id, objects } + * @returns {Promise} The created track's initial snapshot + */ +exports.createTrackFromBundle = async function createTrackFromBundle(bundleData) { + if (!bundleData || !Array.isArray(bundleData.objects) || bundleData.objects.length === 0) { + throw new BadRequestError({ + message: 'Invalid bundle: must contain at least one object', + }); + } + + logger.verbose( + `BundleImportService: Processing bundle "${bundleData.id}" with ${bundleData.objects.length} object(s)`, + ); + + // ------------------------------------------------------------------ + // Step 1: Extract collection metadata + // ------------------------------------------------------------------ + + const { collectionObj, otherObjects } = extractCollectionObject(bundleData.objects); + + const trackName = collectionObj?.name || 'Imported Track'; + const trackDescription = collectionObj?.description || `Imported from bundle ${bundleData.id}`; + + // ------------------------------------------------------------------ + // Step 2: Sort and import objects + // ------------------------------------------------------------------ + + const sorted = sortByDependencyOrder(otherObjects); + const serviceMap = getServiceMap(); + + const importedRefs = []; + let importedCount = 0; + let skippedCount = 0; + + for (const stixObj of sorted) { + const { imported, ref } = await importObject(stixObj, serviceMap); + if (ref) { + importedRefs.push(ref); + } + if (imported) importedCount++; + else if (ref) skippedCount++; + } + + // ------------------------------------------------------------------ + // Step 3: Determine member entries + // ------------------------------------------------------------------ + + let memberEntries; + + if ( + collectionObj && + Array.isArray(collectionObj.x_mitre_contents) && + collectionObj.x_mitre_contents.length > 0 + ) { + // Use the collection's x_mitre_contents as the authoritative member list + memberEntries = collectionObj.x_mitre_contents.map((entry) => ({ + object_ref: entry.object_ref, + object_modified: entry.object_modified, + })); + logger.verbose( + `BundleImportService: Using collection x_mitre_contents (${memberEntries.length} entries)`, + ); + } else { + // Fall back to using all imported object refs + memberEntries = importedRefs; + logger.verbose( + `BundleImportService: Using imported objects as members (${memberEntries.length} entries)`, + ); + } + + // ------------------------------------------------------------------ + // Step 4: Create the release track + // ------------------------------------------------------------------ + + const snapshot = await snapshotService.createTrack({ + name: trackName, + description: trackDescription, + type: 'standard', + }); + + // Add members by cloning the initial (empty) snapshot with the member entries + if (memberEntries.length > 0) { + const finalSnapshot = await snapshotService.cloneSnapshot(snapshot.id, snapshot, { + members: memberEntries, + }); + + logger.verbose( + `BundleImportService: Created track "${trackName}" (${snapshot.id}) ` + + `with ${memberEntries.length} member(s) ` + + `(${importedCount} imported, ${skippedCount} already existed)`, + ); + + return finalSnapshot; + } + + logger.verbose( + `BundleImportService: Created track "${trackName}" (${snapshot.id}) with 0 members`, + ); + + return snapshot; +}; diff --git a/app/services/release-tracks/ephemeral-service.js b/app/services/release-tracks/ephemeral-service.js new file mode 100644 index 00000000..f551cdfb --- /dev/null +++ b/app/services/release-tracks/ephemeral-service.js @@ -0,0 +1,281 @@ +'use strict'; + +// ============================================================================= +// Ephemeral Service +// +// Generates stateless, non-persisted STIX bundles for a given ATT&CK domain. +// Unlike regular release tracks (which store snapshots with object refs), +// ephemeral bundles are computed on-the-fly by querying all STIX repositories +// for objects belonging to the requested domain. +// +// This service performs cross-service READS (permitted by the event-driven +// architecture — see docs/CROSS_SERVICE_READS_PATTERN.md) by querying STIX +// repositories directly. It does NOT write to any repository. +// +// The domain query pattern mirrors stix-bundles-service.exportBundle, but +// operates independently of the legacy collection-bundles infrastructure. +// ============================================================================= + +const uuid = require('uuid'); +const logger = require('../../lib/logger'); + +// --------------------------------------------------------------------------- +// Domain mapping +// --------------------------------------------------------------------------- + +const DOMAIN_MAP = { + enterprise: 'enterprise-attack', + ics: 'ics-attack', + mobile: 'mobile-attack', +}; + +// --------------------------------------------------------------------------- +// Repository references — lazy-loaded to avoid circular dependencies. +// +// Only repos whose models have `stix.x_mitre_domains` are queried directly. +// Relationships, identities, and marking definitions are discovered through +// references in primary objects. +// --------------------------------------------------------------------------- + +let _repos = null; + +function getRepositories() { + if (_repos) return _repos; + + _repos = { + technique: require('../../repository/techniques-repository'), + tactic: require('../../repository/tactics-repository'), + mitigation: require('../../repository/mitigations-repository'), + software: require('../../repository/software-repository'), + matrix: require('../../repository/matrix-repository'), + analytic: require('../../repository/analytics-repository'), + dataComponent: require('../../repository/data-components-repository'), + dataSource: require('../../repository/data-sources-repository'), + asset: require('../../repository/assets-repository'), + relationship: require('../../repository/relationships-repository'), + identity: require('../../repository/identities-repository'), + markingDefinition: require('../../repository/marking-definitions-repository'), + }; + + return _repos; +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Collect unique identity and marking-definition STIX IDs referenced by a + * set of STIX documents so we can fetch them in a single batch. + */ +function collectReferencedIds(documents) { + const identityIds = new Set(); + const markingIds = new Set(); + + for (const doc of documents) { + const stix = doc.stix; + if (stix.created_by_ref) identityIds.add(stix.created_by_ref); + if (Array.isArray(stix.object_marking_refs)) { + for (const ref of stix.object_marking_refs) markingIds.add(ref); + } + } + + return { identityIds, markingIds }; +} + +/** + * Fetch identities and marking definitions by their STIX IDs. + */ +async function fetchSupportingObjects(identityIds, markingIds) { + const repos = getRepositories(); + const results = []; + + // Fetch identities + await Promise.all( + [...identityIds].map(async (id) => { + try { + const doc = await repos.identity.retrieveLatestByStixId(id); + if (doc) results.push(doc); + } catch (err) { + logger.warn(`EphemeralService: Could not fetch identity "${id}": ${err.message}`); + } + }), + ); + + // Fetch marking definitions + await Promise.all( + [...markingIds].map(async (id) => { + try { + const doc = await repos.markingDefinition.retrieveLatestByStixId(id); + if (doc) results.push(doc); + } catch (err) { + logger.warn(`EphemeralService: Could not fetch marking definition "${id}": ${err.message}`); + } + }), + ); + + return results; +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Generate an ephemeral STIX bundle for a domain. + * + * Queries all domain-aware repositories in parallel for the latest version + * of each object in the given domain, then discovers and includes + * relationships that connect those objects, along with referenced identities + * and marking definitions. + * + * @param {string} domain - One of: 'enterprise', 'ics', 'mobile' + * @param {string} [format='bundle'] - Output format (currently only 'bundle') + * @returns {Promise} A STIX bundle (or formatted output) + */ +exports.getEphemeralBundle = async function getEphemeralBundle(domain, format) { + const attackDomain = DOMAIN_MAP[domain]; + if (!attackDomain) { + const { BadRequestError } = require('../../exceptions'); + throw new BadRequestError({ + message: `Unknown domain: "${domain}"`, + details: `Valid domains are: ${Object.keys(DOMAIN_MAP).join(', ')}`, + }); + } + + const repos = getRepositories(); + const queryOptions = { + includeRevoked: false, + includeDeprecated: false, + }; + + logger.verbose(`EphemeralService: Generating ephemeral bundle for domain "${attackDomain}"`); + + // ------------------------------------------------------------------ + // Step 1: Query all domain-aware repositories in parallel + // ------------------------------------------------------------------ + + const [ + techniques, + tactics, + mitigations, + software, + matrices, + analytics, + dataComponents, + dataSources, + assets, + ] = await Promise.all([ + repos.technique.retrieveAllByDomain(attackDomain, queryOptions), + repos.tactic.retrieveAllByDomain(attackDomain, queryOptions), + repos.mitigation.retrieveAllByDomain(attackDomain, queryOptions), + repos.software.retrieveAllByDomain(attackDomain, queryOptions), + repos.matrix.retrieveAllByDomain(attackDomain, queryOptions), + repos.analytic.retrieveAllByDomain(attackDomain, queryOptions), + repos.dataComponent.retrieveAllByDomain(attackDomain, queryOptions), + repos.dataSource.retrieveAllByDomain(attackDomain, queryOptions), + repos.asset.retrieveAllByDomain(attackDomain, queryOptions), + ]); + + const primaryObjects = [ + ...techniques, + ...tactics, + ...mitigations, + ...software, + ...matrices, + ...analytics, + ...dataComponents, + ...dataSources, + ...assets, + ]; + + // ------------------------------------------------------------------ + // Step 2: Build a lookup of primary object IDs for relationship filtering + // ------------------------------------------------------------------ + + const primaryIdSet = new Set(primaryObjects.map((doc) => doc.stix.id)); + + // ------------------------------------------------------------------ + // Step 3: Fetch relationships that connect primary objects + // ------------------------------------------------------------------ + + const allRelationships = await repos.relationship.retrieveAll({ + versions: 'latest', + includeRevoked: false, + includeDeprecated: false, + }); + + const relevantRelationships = (allRelationships.data || allRelationships).filter( + (rel) => primaryIdSet.has(rel.stix.source_ref) || primaryIdSet.has(rel.stix.target_ref), + ); + + // ------------------------------------------------------------------ + // Step 4: Fetch supporting objects (identities, marking definitions) + // ------------------------------------------------------------------ + + const allDocs = [...primaryObjects, ...relevantRelationships]; + const { identityIds, markingIds } = collectReferencedIds(allDocs); + const supportingObjects = await fetchSupportingObjects(identityIds, markingIds); + + // ------------------------------------------------------------------ + // Step 5: Assemble the STIX bundle + // ------------------------------------------------------------------ + + // Deduplicate by stix.id (in case of overlapping supporting objects) + const seen = new Set(); + const bundleObjects = []; + + for (const doc of [...primaryObjects, ...relevantRelationships, ...supportingObjects]) { + const key = `${doc.stix.id}::${doc.stix.modified}`; + if (seen.has(key)) continue; + seen.add(key); + bundleObjects.push(doc.stix); + } + + const bundle = { + type: 'bundle', + id: `bundle--${uuid.v4()}`, + objects: bundleObjects, + }; + + logger.verbose( + `EphemeralService: Built ephemeral bundle for "${attackDomain}" ` + + `(${primaryObjects.length} primary, ${relevantRelationships.length} relationships, ` + + `${supportingObjects.length} supporting → ${bundleObjects.length} total objects)`, + ); + + // Format conversion (if not plain bundle) + if (format === 'workbench' || format === 'filesystemstore') { + // Re-use export-service formatters with a synthetic snapshot envelope + const exportService = require('./export-service'); + const syntheticDocs = [...primaryObjects, ...relevantRelationships, ...supportingObjects]; + const deduped = []; + const dedupSeen = new Set(); + for (const doc of syntheticDocs) { + const key = `${doc.stix.id}::${doc.stix.modified}`; + if (dedupSeen.has(key)) continue; + dedupSeen.add(key); + deduped.push(doc); + } + + const syntheticSnapshot = { + id: `ephemeral-${domain}`, + version: null, + name: `${domain} (ephemeral)`, + modified: new Date(), + members: deduped.map((doc) => ({ + object_ref: doc.stix.id, + object_modified: doc.stix.modified, + })), + }; + + if (format === 'workbench') { + return exportService.formatAsWorkbench(syntheticSnapshot, deduped); + } + if (format === 'filesystemstore') { + return exportService.formatAsFilesystemStore(syntheticSnapshot, deduped); + } + } + + return bundle; +}; diff --git a/app/services/release-tracks/export-service.js b/app/services/release-tracks/export-service.js new file mode 100644 index 00000000..72a2dec7 --- /dev/null +++ b/app/services/release-tracks/export-service.js @@ -0,0 +1,195 @@ +'use strict'; + +// ============================================================================= +// Export Service +// +// Hydrates STIX object refs (from snapshot members/staged/candidates tiers) +// into full STIX documents, then formats the output as one of: +// - bundle: Standard STIX 2.1 bundle +// - workbench: Custom format with workflow metadata +// - filesystemstore: Directory structure organized by STIX type +// +// This service performs cross-service READS (permitted by the event-driven +// architecture — see docs/CROSS_SERVICE_READS_PATTERN.md) by querying STIX +// repositories directly. It does NOT write to any external repository. +// +// DTO transformations are encapsulated in Zod transform schemas. See +// app/lib/release-tracks/export-schemas.js for schema definitions. +// ============================================================================= + +const types = require('../../lib/types'); +const logger = require('../../lib/logger'); +const { + bundleTransformSchema, + workbenchTransformSchema, + filesystemStoreTransformSchema, +} = require('../../lib/release-tracks/export-schemas'); + +// --------------------------------------------------------------------------- +// Repository map — lazy-loaded to avoid circular dependency issues at startup. +// +// Maps STIX type prefixes to their corresponding repositories so we can +// batch-query each repository's `findManyByIdAndModified` in parallel. +// --------------------------------------------------------------------------- + +let _repoMap = null; + +function getRepositoryMap() { + if (_repoMap) return _repoMap; + + _repoMap = { + [types.Technique]: require('../../repository/techniques-repository'), + [types.Tactic]: require('../../repository/tactics-repository'), + [types.Group]: require('../../repository/groups-repository'), + [types.Campaign]: require('../../repository/campaigns-repository'), + [types.Mitigation]: require('../../repository/mitigations-repository'), + [types.Matrix]: require('../../repository/matrix-repository'), + [types.Relationship]: require('../../repository/relationships-repository'), + [types.MarkingDefinition]: require('../../repository/marking-definitions-repository'), + [types.Identity]: require('../../repository/identities-repository'), + [types.Note]: require('../../repository/notes-repository'), + [types.DataSource]: require('../../repository/data-sources-repository'), + [types.DataComponent]: require('../../repository/data-components-repository'), + [types.Asset]: require('../../repository/assets-repository'), + [types.Analytic]: require('../../repository/analytics-repository'), + [types.DetectionStrategy]: require('../../repository/detection-strategies-repository'), + }; + + // Software types share a single repository + const softwareRepo = require('../../repository/software-repository'); + _repoMap[types.Malware] = softwareRepo; + _repoMap[types.Tool] = softwareRepo; + + return _repoMap; +} + +// ============================================================================= +// Hydration +// ============================================================================= + +/** + * Hydrate an array of tier entries into full STIX documents. + * + * Groups entries by STIX type (extracted from the `object_ref` prefix) and + * batch-queries each repository in parallel via `findManyByIdAndModified`. + * + * @param {Array<{object_ref: string, object_modified: string|Date}>} entries + * @returns {Promise>} Full Mongoose lean documents ({ stix, workspace, ... }) + */ +exports.hydrateMembers = async function hydrateMembers(entries) { + if (!entries || entries.length === 0) return []; + + // Group entries by STIX type prefix + const byType = {}; + for (const entry of entries) { + const type = entry.object_ref.split('--')[0]; + if (!byType[type]) byType[type] = []; + byType[type].push(entry); + } + + const repoMap = getRepositoryMap(); + const hydrated = []; + + await Promise.all( + Object.entries(byType).map(async ([type, refs]) => { + const repo = repoMap[type]; + if (!repo) { + logger.warn( + `ExportService: No repository for type "${type}", skipping ${refs.length} object(s)`, + ); + return; + } + try { + const docs = await repo.findManyByIdAndModified(refs); + hydrated.push(...docs); + } catch (err) { + logger.error(`ExportService: Failed to hydrate ${refs.length} "${type}" object(s):`, err); + } + }), + ); + + return hydrated; +}; + +// ============================================================================= +// Format helpers (delegating to Zod transform schemas) +// ============================================================================= + +/** + * Format as a standard STIX 2.1 bundle. + * + * Only includes `stix` properties — no workspace data or workflow metadata. + * Transformation logic is defined in export-schemas.js. + */ +exports.formatAsBundle = function formatAsBundle(snapshot, hydratedObjects) { + return bundleTransformSchema.parse({ snapshot, hydratedObjects }); +}; + +/** + * Format as a workbench-optimized response with full metadata. + * + * Includes `stix` + `workspace` properties and tier annotations. + * Transformation logic is defined in export-schemas.js. + */ +exports.formatAsWorkbench = function formatAsWorkbench(snapshot, hydratedObjects) { + return workbenchTransformSchema.parse({ snapshot, hydratedObjects }); +}; + +/** + * Format as a FileSystemStore-compatible directory structure. + * + * Objects are grouped by STIX type, each with a filename and content property. + * Transformation logic is defined in export-schemas.js. + * See docs/COLLECTIONS_V2/07_OUTPUT_FORMATS.md for specification. + */ +exports.formatAsFilesystemStore = function formatAsFilesystemStore(snapshot, hydratedObjects) { + return filesystemStoreTransformSchema.parse({ snapshot, hydratedObjects }); +}; + +// ============================================================================= +// Main export entry point +// ============================================================================= + +/** + * Export a snapshot in the specified format. + * + * This is the primary entry point called by the facade when a `format` + * query parameter is provided on snapshot retrieval endpoints. + * + * @param {Object} snapshot - The raw snapshot document from the dynamic repo + * @param {string} format - One of: 'bundle', 'workbench', 'filesystemstore' + * @param {Object} [options] - Additional options + * @param {string} [options.include] - For workbench format: 'staged', 'candidates', or 'all' + * @returns {Promise} The formatted export + */ +exports.exportSnapshot = async function exportSnapshot(snapshot, format, options = {}) { + const members = snapshot.members || []; + + if (format === 'bundle') { + const hydratedMembers = await exports.hydrateMembers(members); + return exports.formatAsBundle(snapshot, hydratedMembers); + } + + if (format === 'workbench') { + // Workbench format optionally includes staged and/or candidate objects + const allRefs = [...members]; + if (options.include === 'staged' || options.include === 'all') { + allRefs.push(...(snapshot.staged || [])); + } + if (options.include === 'candidates' || options.include === 'all') { + allRefs.push(...(snapshot.candidates || [])); + } + + const hydratedAll = await exports.hydrateMembers(allRefs); + return exports.formatAsWorkbench(snapshot, hydratedAll); + } + + if (format === 'filesystemstore') { + const hydratedMembers = await exports.hydrateMembers(members); + return exports.formatAsFilesystemStore(snapshot, hydratedMembers); + } + + // Unknown format — return raw snapshot unchanged + logger.warn(`ExportService: Unknown format "${format}", returning raw snapshot`); + return snapshot; +}; diff --git a/app/services/release-tracks/member-sync-service.js b/app/services/release-tracks/member-sync-service.js new file mode 100644 index 00000000..502ccb68 --- /dev/null +++ b/app/services/release-tracks/member-sync-service.js @@ -0,0 +1,391 @@ +'use strict'; + +// ============================================================================= +// Member Sync Service +// +// Handles automatic enrollment of new object revisions as candidates when +// the object is already a member of a release track. This service implements +// the "Member Sync Strategies" feature documented in 08_MEMBER_SYNC_STRATEGIES.md. +// +// Core functionality: +// - Listens for STIX object modification events via EventBus +// - Identifies release tracks where the modified object is a member +// - Applies the configured member sync strategy (track_latest vs manual) +// - Handles supplant behavior (replace/queue/ignore) +// - Creates new draft snapshots with auto-enrolled candidates +// +// This service is event-driven and operates independently of the main +// release track workflow. It integrates with workflow-service for +// auto-promotion after enrollment. +// +// Event Integration: +// Subscribes to BaseService CRUD events ({type}::created, {type}::updated) +// via the EventBus. When a STIX object is created or updated, this service +// checks if it's a member of any release track and auto-enrolls if configured. +// ============================================================================= + +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'); +const EventBus = require('../../lib/event-bus'); +const EventConstants = require('../../lib/event-constants'); + +// ============================================================================= +// Main entry point +// ============================================================================= + +/** + * Handle a STIX object modification event. + * + * Identifies release tracks where the object is a member and applies + * the configured member sync strategy to each. + * + * @param {Object} event - The modification event + * @param {string} event.objectRef - The STIX ID of the modified object + * @param {Date|string} event.newModified - The new modified timestamp + * @param {Date|string} [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 handleObjectModified(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} track(s) 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, trackInfo.snapshot, { + 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; +}; + +// ============================================================================= +// Track discovery +// ============================================================================= + +/** + * 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, + snapshot, + }); + } + } + + return results; +} + +// ============================================================================= +// Core sync logic +// ============================================================================= + +/** + * Process member sync for a single release track. + * + * Applies the configured strategy to determine if/how to enroll + * the new object revision as a candidate. + * + * @param {string} trackId - The release track ID + * @param {Object} snapshot - The current latest snapshot + * @param {Object} event - The modification event details + * @param {string} event.objectRef - STIX ID of the modified object + * @param {Date|string} event.newModified - New modified timestamp + * @param {string} [event.modifiedBy] - User who made the modification + * @returns {Promise} New snapshot if changes made, null otherwise + */ +async function processMemberSync(trackId, snapshot, event) { + const { objectRef, newModified, modifiedBy } = event; + + // Get member sync config with defaults + const config = getMemberSyncConfig(snapshot); + + // 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 and tier placement + const targetTier = action.targetTier || action.tier; + + if (action.type === 'replace' && config.supplant.status_policy === 'preserve') { + // Preserve status from old entry + newEntry.object_status = action.removeEntry.object_status; + if (targetTier === 'staged') { + newEntry.object_staged_at = now; + newEntry.object_staged_by = modifiedBy || 'system'; + } + } else { + // Reset status to work-in-progress + 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 && + new Date(c.object_modified).getTime() === + new Date(action.removeEntry.object_modified).getTime() + ), + ); + } else if (action.removeTier === 'staged') { + newStaged = newStaged.filter( + (s) => + !( + s.object_ref === objectRef && + new Date(s.object_modified).getTime() === + new Date(action.removeEntry.object_modified).getTime() + ), + ); + } + } + + // Add new entry to target 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; +} + +// ============================================================================= +// Configuration helpers +// ============================================================================= + +/** + * Get the member sync configuration with defaults applied. + * + * @param {Object} snapshot - The snapshot to read config from + * @returns {Object} Member sync config with defaults + */ +function getMemberSyncConfig(snapshot) { + const memberSync = snapshot.config?.member_sync || {}; + + return { + strategy: memberSync.strategy || 'track_latest', + supplant: { + behavior: memberSync.supplant?.behavior || 'replace', + status_policy: memberSync.supplant?.status_policy || 'reset', + }, + }; +} + +// ============================================================================= +// Event Subscription +// ============================================================================= + +/** + * All STIX object event names that can trigger member sync. + * We listen to both ::created and ::updated events for each type. + */ +const STIX_OBJECT_EVENTS = [ + // Core ATT&CK objects + EventConstants.ATTACK_PATTERN_CREATED, + EventConstants.ATTACK_PATTERN_UPDATED, + EventConstants.TACTIC_CREATED, + EventConstants.TACTIC_UPDATED, + EventConstants.COURSE_OF_ACTION_CREATED, + EventConstants.COURSE_OF_ACTION_UPDATED, + EventConstants.INTRUSION_SET_CREATED, + EventConstants.INTRUSION_SET_UPDATED, + EventConstants.MALWARE_CREATED, + EventConstants.MALWARE_UPDATED, + EventConstants.TOOL_CREATED, + EventConstants.TOOL_UPDATED, + EventConstants.CAMPAIGN_CREATED, + EventConstants.CAMPAIGN_UPDATED, + EventConstants.DATA_SOURCE_CREATED, + EventConstants.DATA_SOURCE_UPDATED, + EventConstants.DATA_COMPONENT_CREATED, + EventConstants.DATA_COMPONENT_UPDATED, + EventConstants.MATRIX_CREATED, + EventConstants.MATRIX_UPDATED, + EventConstants.COLLECTION_CREATED, + EventConstants.COLLECTION_UPDATED, + EventConstants.ASSET_CREATED, + EventConstants.ASSET_UPDATED, + // Detection strategies and analytics + EventConstants.DETECTION_STRATEGY_CREATED, + EventConstants.DETECTION_STRATEGY_UPDATED, + EventConstants.ANALYTIC_CREATED, + EventConstants.ANALYTIC_UPDATED, +]; + +/** + * Handle a STIX object event from BaseService. + * + * Transforms the BaseService event payload into the format expected by + * handleObjectModified and triggers member sync processing. + * + * @param {Object} payload - Event payload from BaseService + * @param {string} payload.stixId - The STIX ID + * @param {Object} payload.document - The created/updated document + * @param {Object} [payload.previousDocument] - Previous document (for updates) + * @param {string} payload.type - The STIX type + * @param {Object} [payload.options] - Creation options (for created events) + */ +async function handleStixObjectEvent(payload) { + const { stixId, document, previousDocument, options } = payload; + + // Transform to member sync event format + const event = { + objectRef: stixId, + newModified: document.stix?.modified, + oldModified: previousDocument?.stix?.modified, + // Try to get user from options (create) or from document workflow metadata + modifiedBy: + options?.userAccountId || document.workspace?.workflow?.created_by_user_account || 'system', + }; + + try { + await exports.handleObjectModified(event); + } catch (err) { + logger.error(`[member-sync] Error handling object modification: ${err.message}`, err); + } +} + +/** + * Initialize event listeners for member sync. + * + * Subscribes to all STIX object created/updated events via the EventBus. + * Called automatically when this module is loaded. + */ +function initializeEventListeners() { + for (const eventName of STIX_OBJECT_EVENTS) { + EventBus.on(eventName, handleStixObjectEvent); + } + + logger.info( + `[member-sync] Member sync service initialized, listening to ${STIX_OBJECT_EVENTS.length} event types`, + ); +} + +// Self-initialize when module is loaded (follows pattern from analytics-service.js) +initializeEventListeners(); + +// ============================================================================= +// Exports for testing +// ============================================================================= + +// Expose internal functions for unit testing +exports._internal = { + findTracksWithObjectInMembers, + processMemberSync, + getMemberSyncConfig, + handleStixObjectEvent, + STIX_OBJECT_EVENTS, +}; diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js new file mode 100644 index 00000000..51c9ff05 --- /dev/null +++ b/app/services/release-tracks/release-tracks-service.js @@ -0,0 +1,235 @@ +'use strict'; + +// ============================================================================= +// Release Tracks Service Facade +// +// Orchestrator that delegates to domain-specific sub-services. This is the +// single entry point consumed by the controller layer. +// +// Phase 1: Track management, snapshot CRUD, config → snapshot-service +// Phase 2: Candidates, staged, object versions → standard-track-service +// Phase 3: Auto-promotion, workflow → workflow-service +// Phase 4: Bump/tag, versioning → versioning-service +// Phase 5: Virtual track composition → virtual-track-service +// Phase 6: Export, ephemeral, bundle import → export-service, ephemeral-service, bundle-import-service +// ============================================================================= + +const { NotImplementedError } = require('../../exceptions'); +const snapshotService = require('./snapshot-service'); +const standardTrackService = require('./standard-track-service'); +const versioningService = require('./versioning-service'); +const virtualTrackService = require('./virtual-track-service'); +const exportService = require('./export-service'); +const ephemeralService = require('./ephemeral-service'); +const bundleImportService = require('./bundle-import-service'); +const memberSyncService = require('./member-sync-service'); + +const MODULE = 'release-tracks-service'; + +function notImplemented(methodName) { + throw new NotImplementedError(MODULE, methodName); +} + +// ----------------------------------------------------------------------------- +// Track management (Phase 1 → snapshot-service) +// ----------------------------------------------------------------------------- + +exports.listTracks = function listTracks(options) { + return snapshotService.listTracks(options); +}; + +exports.createTrack = function createTrack(data) { + return snapshotService.createTrack(data); +}; + +// Phase 6 → bundle-import-service +exports.createTrackFromBundle = function createTrackFromBundle(bundleData) { + return bundleImportService.createTrackFromBundle(bundleData); +}; + +// eslint-disable-next-line no-unused-vars +exports.importTrack = async function importTrack(_data) { + notImplemented('importTrack'); +}; + +// Phase 6: Format-aware snapshot retrieval +// - 'snapshot' format (or no format): returns raw snapshot as stored +// - 'bundle'/'workbench' formats: hydrates and transforms via export-service +// - 'filesystemstore': blocked at controller level (NotImplementedError) +exports.getLatestSnapshot = async function getLatestSnapshot(trackId, options) { + const snapshot = await snapshotService.getLatestSnapshot(trackId, options); + const format = options?.format; + if (format && format !== 'snapshot') { + return exportService.exportSnapshot(snapshot, format, options); + } + return snapshot; +}; + +exports.getSnapshotByModified = async function getSnapshotByModified(trackId, modified, options) { + const snapshot = await snapshotService.getSnapshotByModified(trackId, modified, options); + const format = options?.format; + if (format && format !== 'snapshot') { + return exportService.exportSnapshot(snapshot, format, options); + } + return snapshot; +}; + +exports.updateMetadata = function updateMetadata(trackId, updates, userId) { + return snapshotService.updateMetadata(trackId, updates, userId); +}; + +exports.updateMetadataByModified = function updateMetadataByModified( + trackId, + modified, + updates, + userId, +) { + return snapshotService.updateMetadataByModified(trackId, modified, updates, userId); +}; + +exports.updateContents = function updateContents(trackId, contents, userId) { + return snapshotService.updateContents(trackId, contents, userId); +}; + +exports.updateContentsByModified = function updateContentsByModified( + trackId, + modified, + contents, + userId, +) { + return snapshotService.updateContentsByModified(trackId, modified, contents, userId); +}; + +exports.cloneTrack = function cloneTrack(trackId, options) { + return snapshotService.cloneTrack(trackId, options); +}; + +exports.cloneFromSnapshot = function cloneFromSnapshot(trackId, modified, options) { + return snapshotService.cloneFromSnapshot(trackId, modified, options); +}; + +exports.deleteTrack = function deleteTrack(trackId) { + return snapshotService.deleteTrack(trackId); +}; + +exports.deleteSnapshot = function deleteSnapshot(trackId, modified) { + return snapshotService.deleteSnapshot(trackId, modified); +}; + +// ----------------------------------------------------------------------------- +// Ephemeral (Phase 6 → ephemeral-service) +// ----------------------------------------------------------------------------- + +exports.getEphemeralBundle = function getEphemeralBundle(domain, format) { + return ephemeralService.getEphemeralBundle(domain, format); +}; + +// ----------------------------------------------------------------------------- +// Candidates (Phase 2 → standard-track-service) +// ----------------------------------------------------------------------------- + +exports.addCandidates = function addCandidates(trackId, objectRefs, userId) { + return standardTrackService.addCandidates(trackId, objectRefs, userId); +}; + +exports.listCandidates = function listCandidates(trackId, options) { + return standardTrackService.listCandidates(trackId, options); +}; + +exports.removeCandidate = function removeCandidate(trackId, objectRef) { + return standardTrackService.removeCandidate(trackId, objectRef); +}; + +exports.reviewCandidates = function reviewCandidates(trackId, reviewData, userId) { + return standardTrackService.reviewCandidates(trackId, reviewData, userId); +}; + +exports.promoteCandidates = function promoteCandidates(trackId, objectRefs, userId) { + return standardTrackService.promoteCandidates(trackId, objectRefs, userId); +}; + +exports.updateCandidateVersion = function updateCandidateVersion(trackId, objectRef, data) { + return standardTrackService.updateCandidateVersion(trackId, objectRef, data); +}; + +// ----------------------------------------------------------------------------- +// Staged (Phase 2 → standard-track-service) +// ----------------------------------------------------------------------------- + +exports.listStaged = function listStaged(trackId) { + return standardTrackService.listStaged(trackId); +}; + +exports.demoteStaged = function demoteStaged(trackId, objectRefs, userId) { + return standardTrackService.demoteStaged(trackId, objectRefs, userId); +}; + +// ----------------------------------------------------------------------------- +// Versioning (Phase 4 → versioning-service) +// ----------------------------------------------------------------------------- + +exports.bumpLatest = function bumpLatest(trackId, options) { + return versioningService.bumpLatest(trackId, options); +}; + +exports.bumpByModified = function bumpByModified(trackId, modified, options) { + return versioningService.bumpByModified(trackId, modified, options); +}; + +exports.previewBump = function previewBump(trackId, format) { + return versioningService.previewBump(trackId, format); +}; + +// ----------------------------------------------------------------------------- +// Configuration (Phase 1 → snapshot-service) +// ----------------------------------------------------------------------------- + +exports.getConfig = function getConfig(trackId) { + return snapshotService.getConfig(trackId); +}; + +exports.updateConfig = function updateConfig(trackId, config, userId) { + return snapshotService.updateConfig(trackId, config, userId); +}; + +// ----------------------------------------------------------------------------- +// Virtual tracks (Phase 5 → virtual-track-service) +// ----------------------------------------------------------------------------- + +exports.updateComposition = function updateComposition(trackId, composition, userId) { + return virtualTrackService.updateComposition(trackId, composition, userId); +}; + +exports.createVirtualSnapshot = function createVirtualSnapshot(trackId, options) { + return virtualTrackService.createVirtualSnapshot(trackId, options); +}; + +exports.previewVirtualSnapshot = function previewVirtualSnapshot(trackId) { + return virtualTrackService.previewVirtualSnapshot(trackId); +}; + +// ----------------------------------------------------------------------------- +// Object versions (Phase 2 → standard-track-service) +// ----------------------------------------------------------------------------- + +exports.listObjectVersions = function listObjectVersions(trackId, objectRef) { + return standardTrackService.listObjectVersions(trackId, objectRef); +}; + +// ----------------------------------------------------------------------------- +// Member sync (Phase 7 → member-sync-service) +// +// Member sync auto-initializes when this module is loaded via the +// memberSyncService import. It subscribes to all STIX object created/updated +// events on the EventBus and automatically enrolls new object revisions +// as candidates when the object is a member of a release track. +// ----------------------------------------------------------------------------- + +/** + * Manually trigger member sync for a STIX object modification. + * Typically not needed since member-sync-service subscribes to EventBus events + * automatically. Useful for testing or manual re-processing. + */ +exports.handleObjectModified = function handleObjectModified(event) { + return memberSyncService.handleObjectModified(event); +}; diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js new file mode 100644 index 00000000..faa0ab4d --- /dev/null +++ b/app/services/release-tracks/snapshot-service.js @@ -0,0 +1,489 @@ +'use strict'; + +// ============================================================================= +// Snapshot Service +// +// Core snapshot lifecycle operations: track creation, retrieval, cloning, +// metadata/contents updates, configuration, and deletion. +// +// This is the foundational sub-service consumed by the facade and by other +// sub-services (standard-track, versioning, virtual-track) that need to +// clone or read snapshots. +// ============================================================================= + +const { v4: uuidv4 } = require('uuid'); + +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const modelFactory = require('../../models/release-tracks/model-factory'); +const logger = require('../../lib/logger'); +const { TrackNotFoundError, NotFoundError } = require('../../exceptions'); + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Deep-clone a snapshot document, stripping Mongoose metadata. + * + * @param {Object} snapshot - The source snapshot (lean Mongoose document) + * @returns {Object} Plain object copy safe for mutation + */ +function deepClone(snapshot) { + const clone = JSON.parse(JSON.stringify(snapshot)); + delete clone._id; + delete clone.__v; + return clone; +} + +/** + * Recompute and persist denormalized registry counters from actual snapshot data. + * + * @param {string} trackId + */ +async function syncRegistryCounters(trackId) { + const { data: snapshots } = await dynamicRepo.getAllSnapshots(trackId, { + projection: 'modified version', + }); + + const snapshotCount = snapshots.length; + const tagged = snapshots.filter((s) => s.version != null); + const taggedReleaseCount = tagged.length; + + // Latest snapshot is first (sorted desc by modified) + const latestSnapshotModified = snapshots.length > 0 ? snapshots[0].modified : null; + + // Latest tagged version: find the tagged snapshot with the highest modified + const latestTaggedVersion = tagged.length > 0 ? tagged[0].version : null; + + await registryRepo.updateByTrackId(trackId, { + snapshot_count: snapshotCount, + tagged_release_count: taggedReleaseCount, + latest_snapshot_modified: latestSnapshotModified, + latest_tagged_version: latestTaggedVersion, + updated_at: new Date(), + }); +} + +// ============================================================================= +// Track management +// ============================================================================= + +/** + * List all release tracks from the registry. + * + * @param {Object} options - { type?, search?, limit?, offset? } + * @returns {Promise<{ data: Object[], pagination: Object }>} + */ +exports.listTracks = async function listTracks(options) { + return registryRepo.findAll(options); +}; + +/** + * Create a new release track with an initial empty draft snapshot. + * + * @param {Object} data - { name, description?, type, userAccountId?, object_marking_refs?, composition?, snapshot_schedule? } + * @returns {Promise} The initial snapshot document + */ +exports.createTrack = async function createTrack(data) { + const trackId = `release-track--${uuidv4()}`; + const now = new Date(); + const trackType = data.type || 'standard'; + + const initialSnapshot = { + id: trackId, + type: trackType, + modified: now, + version: null, + name: data.name, + description: data.description || '', + created: now, + created_by_ref: data.userAccountId || undefined, + object_marking_refs: data.object_marking_refs, + members: [], + staged: trackType === 'standard' ? [] : undefined, + candidates: trackType === 'standard' ? [] : undefined, + quarantine: trackType === 'virtual' ? [] : undefined, + composition: trackType === 'virtual' ? data.composition : undefined, + config: {}, + version_history: [], + }; + + // Create collection + indexes, then persist the initial snapshot + await modelFactory.ensureIndexes(trackId); + const snapshot = await dynamicRepo.saveSnapshot(trackId, initialSnapshot); + + // Register in the central registry + await registryRepo.create({ + track_id: trackId, + type: trackType, + 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: trackType === 'virtual' ? data.snapshot_schedule : undefined, + }); + + logger.verbose(`SnapshotService: Created ${trackType} track "${data.name}" (${trackId})`); + return snapshot; +}; + +// ============================================================================= +// Snapshot retrieval +// ============================================================================= + +/** + * Retrieve the most recent snapshot for a track. + * + * @param {string} trackId + * @param {Object} [_options] - Reserved for future format/include options + * @returns {Promise} The latest snapshot document + * @throws {TrackNotFoundError} If no snapshots exist for the track + */ +// eslint-disable-next-line no-unused-vars +exports.getLatestSnapshot = async function getLatestSnapshot(trackId, _options) { + const snapshot = await dynamicRepo.getLatestSnapshot(trackId); + if (!snapshot) { + throw new TrackNotFoundError(trackId); + } + return snapshot; +}; + +/** + * Retrieve a specific snapshot by its modified timestamp. + * + * @param {string} trackId + * @param {string|Date} modified + * @param {Object} [_options] - Reserved for future format/include options + * @returns {Promise} The snapshot document + * @throws {NotFoundError} If the snapshot does not exist + */ +// eslint-disable-next-line no-unused-vars +exports.getSnapshotByModified = async function getSnapshotByModified(trackId, modified, _options) { + const snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified); + if (!snapshot) { + throw new NotFoundError({ + details: `Snapshot with modified '${modified}' not found for track '${trackId}'`, + }); + } + return snapshot; +}; + +// ============================================================================= +// Snapshot cloning (internal helper, also used by other sub-services) +// ============================================================================= + +/** + * Clone a snapshot with overrides, persisting the result as a new draft. + * + * Every mutation (metadata update, contents update, tier change) produces a + * new snapshot via this method. Clones are always drafts (version = null). + * + * @param {string} trackId - The track to save the clone into + * @param {Object} sourceSnapshot - The snapshot to clone + * @param {Object} [overrides] - Fields to merge into the clone + * @returns {Promise} The saved clone + */ +exports.cloneSnapshot = async function cloneSnapshot(trackId, sourceSnapshot, overrides) { + const clone = deepClone(sourceSnapshot); + clone.modified = new Date(); + clone.version = null; // clones are always drafts + + // Apply overrides + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + clone[key] = value; + } + } + } + + const saved = await dynamicRepo.saveSnapshot(trackId, clone); + await syncRegistryCounters(trackId); + + logger.verbose(`SnapshotService: Cloned snapshot for track "${trackId}"`); + return saved; +}; + +// ============================================================================= +// Track cloning +// ============================================================================= + +/** + * Clone a track by duplicating its latest snapshot into a new track. + * + * @param {string} trackId - Source track ID + * @param {Object} options - { name?, userAccountId? } + * @returns {Promise} The initial snapshot of the new track + */ +exports.cloneTrack = async function cloneTrack(trackId, options) { + const source = await exports.getLatestSnapshot(trackId); + return _cloneToNewTrack(source, options); +}; + +/** + * Clone a track from a specific snapshot into a new track. + * + * @param {string} trackId - Source track ID + * @param {string|Date} modified - Source snapshot timestamp + * @param {Object} options - { name?, userAccountId? } + * @returns {Promise} The initial snapshot of the new track + */ +exports.cloneFromSnapshot = async function cloneFromSnapshot(trackId, modified, options) { + const source = await exports.getSnapshotByModified(trackId, modified); + return _cloneToNewTrack(source, options); +}; + +/** + * Internal: create a new track from a source snapshot. + */ +async function _cloneToNewTrack(sourceSnapshot, options = {}) { + const newTrackId = `release-track--${uuidv4()}`; + const now = new Date(); + + const clone = deepClone(sourceSnapshot); + clone.id = newTrackId; + clone.modified = now; + clone.version = null; + clone.name = options.name || `${sourceSnapshot.name} (copy)`; + clone.created = now; + clone.created_by_ref = options.userAccountId || sourceSnapshot.created_by_ref; + clone.version_history = []; + + await modelFactory.ensureIndexes(newTrackId); + const saved = await dynamicRepo.saveSnapshot(newTrackId, clone); + + await registryRepo.create({ + track_id: newTrackId, + type: sourceSnapshot.type, + name: clone.name, + description: sourceSnapshot.description, + latest_snapshot_modified: now, + snapshot_count: 1, + tagged_release_count: 0, + created_at: now, + updated_at: now, + }); + + logger.verbose(`SnapshotService: Cloned track to new track "${clone.name}" (${newTrackId})`); + return saved; +} + +// ============================================================================= +// Metadata updates +// ============================================================================= + +/** + * Update metadata on the latest snapshot (creates a new snapshot clone). + * + * @param {string} trackId + * @param {Object} updates - { name?, description?, object_marking_refs? } + * @param {string} [_userId] + * @returns {Promise} The new snapshot + */ +// eslint-disable-next-line no-unused-vars +exports.updateMetadata = async function updateMetadata(trackId, updates, _userId) { + const source = await exports.getLatestSnapshot(trackId); + const overrides = {}; + if (updates.name !== undefined) overrides.name = updates.name; + if (updates.description !== undefined) overrides.description = updates.description; + if (updates.object_marking_refs !== undefined) + overrides.object_marking_refs = updates.object_marking_refs; + + // Also update the registry name/description if changed + const registryUpdates = {}; + if (updates.name !== undefined) registryUpdates.name = updates.name; + if (updates.description !== undefined) registryUpdates.description = updates.description; + if (Object.keys(registryUpdates).length > 0) { + registryUpdates.updated_at = new Date(); + await registryRepo.updateByTrackId(trackId, registryUpdates); + } + + return exports.cloneSnapshot(trackId, source, overrides); +}; + +/** + * Update metadata on a specific snapshot (creates a new snapshot clone). + * + * @param {string} trackId + * @param {string|Date} modified + * @param {Object} updates - { name?, description?, object_marking_refs? } + * @param {string} [_userId] + * @returns {Promise} The new snapshot + */ +exports.updateMetadataByModified = async function updateMetadataByModified( + trackId, + modified, + updates, + // eslint-disable-next-line no-unused-vars + _userId, +) { + const source = await exports.getSnapshotByModified(trackId, modified); + const overrides = {}; + if (updates.name !== undefined) overrides.name = updates.name; + if (updates.description !== undefined) overrides.description = updates.description; + if (updates.object_marking_refs !== undefined) + overrides.object_marking_refs = updates.object_marking_refs; + + const registryUpdates = {}; + if (updates.name !== undefined) registryUpdates.name = updates.name; + if (updates.description !== undefined) registryUpdates.description = updates.description; + if (Object.keys(registryUpdates).length > 0) { + registryUpdates.updated_at = new Date(); + await registryRepo.updateByTrackId(trackId, registryUpdates); + } + + return exports.cloneSnapshot(trackId, source, overrides); +}; + +// ============================================================================= +// Contents updates +// ============================================================================= + +/** + * Replace member contents on the latest snapshot (creates a new snapshot clone). + * + * @param {string} trackId + * @param {Object} contents - { x_mitre_contents: [{ obj_ref, obj_modified }] } + * @param {string} [_userId] + * @returns {Promise} The new snapshot + */ +// eslint-disable-next-line no-unused-vars +exports.updateContents = async function updateContents(trackId, contents, _userId) { + const source = await exports.getLatestSnapshot(trackId); + const members = contents.x_mitre_contents.map((c) => ({ + object_ref: c.obj_ref, + object_modified: c.obj_modified === 'latest' ? new Date() : new Date(c.obj_modified), + })); + return exports.cloneSnapshot(trackId, source, { members }); +}; + +/** + * Replace member contents on a specific snapshot (creates a new snapshot clone). + * + * @param {string} trackId + * @param {string|Date} modified + * @param {Object} contents - { x_mitre_contents: [{ obj_ref, obj_modified }] } + * @param {string} [_userId] + * @returns {Promise} The new snapshot + */ +exports.updateContentsByModified = async function updateContentsByModified( + trackId, + modified, + contents, + // eslint-disable-next-line no-unused-vars + _userId, +) { + const source = await exports.getSnapshotByModified(trackId, modified); + const members = contents.x_mitre_contents.map((c) => ({ + object_ref: c.obj_ref, + object_modified: c.obj_modified === 'latest' ? new Date() : new Date(c.obj_modified), + })); + return exports.cloneSnapshot(trackId, source, { members }); +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * Get the configuration from the latest snapshot. + * + * @param {string} trackId + * @returns {Promise} The config sub-document + */ +exports.getConfig = async function getConfig(trackId) { + const snapshot = await exports.getLatestSnapshot(trackId); + return snapshot.config || {}; +}; + +/** + * Update configuration on the latest snapshot (creates a new snapshot clone). + * + * Performs a shallow merge at the top level, and a nested merge for + * the `promotion_conflicts` sub-object. + * + * @param {string} trackId + * @param {Object} config - Partial config to merge + * @param {string} [_userId] + * @returns {Promise} The new snapshot + */ +// eslint-disable-next-line no-unused-vars +exports.updateConfig = async function updateConfig(trackId, config, _userId) { + const source = await exports.getLatestSnapshot(trackId); + const existing = source.config || {}; + + const mergedConfig = { ...existing }; + + if (config.candidacy_threshold !== undefined) + mergedConfig.candidacy_threshold = config.candidacy_threshold; + if (config.auto_promote !== undefined) mergedConfig.auto_promote = config.auto_promote; + if (config.promotion_conflicts !== undefined) { + mergedConfig.promotion_conflicts = { + ...(existing.promotion_conflicts || {}), + ...config.promotion_conflicts, + }; + } + if (config.member_sync !== undefined) { + const existingMemberSync = existing.member_sync || {}; + mergedConfig.member_sync = { + ...existingMemberSync, + ...config.member_sync, + }; + // Nested merge for supplant sub-object + if (config.member_sync.supplant !== undefined) { + mergedConfig.member_sync.supplant = { + ...(existingMemberSync.supplant || {}), + ...config.member_sync.supplant, + }; + } + } + + return exports.cloneSnapshot(trackId, source, { config: mergedConfig }); +}; + +// ============================================================================= +// Deletion +// ============================================================================= + +/** + * Delete an entire release track (registry entry + all snapshots + collection). + * + * @param {string} trackId + * @throws {TrackNotFoundError} If the track does not exist in the registry + */ +exports.deleteTrack = async function deleteTrack(trackId) { + const registry = await registryRepo.findByTrackId(trackId); + if (!registry) { + throw new TrackNotFoundError(trackId); + } + + await dynamicRepo.dropCollection(trackId); + await registryRepo.deleteByTrackId(trackId); + + logger.verbose(`SnapshotService: Deleted track "${trackId}"`); +}; + +/** + * Delete a specific snapshot from a track. + * + * @param {string} trackId + * @param {string|Date} modified + * @throws {NotFoundError} If the snapshot does not exist + */ +exports.deleteSnapshot = async function deleteSnapshot(trackId, modified) { + const snapshot = await dynamicRepo.getSnapshotByModified(trackId, modified); + if (!snapshot) { + throw new NotFoundError({ + details: `Snapshot with modified '${modified}' not found for track '${trackId}'`, + }); + } + + await dynamicRepo.deleteSnapshot(trackId, modified); + await syncRegistryCounters(trackId); + + logger.verbose(`SnapshotService: Deleted snapshot '${modified}' from track "${trackId}"`); +}; diff --git a/app/services/release-tracks/standard-track-service.js b/app/services/release-tracks/standard-track-service.js new file mode 100644 index 00000000..b5bc4787 --- /dev/null +++ b/app/services/release-tracks/standard-track-service.js @@ -0,0 +1,516 @@ +'use strict'; + +// ============================================================================= +// Standard Track Service +// +// Manages the three-tier workflow for standard release tracks: +// candidates → staged → members +// +// Operations: add/list/remove/review candidates, promote candidates to staged, +// update candidate version pins, list/demote staged, list object versions. +// +// All mutations produce a new snapshot clone (immutable snapshot model). +// ============================================================================= + +const snapshotService = require('./snapshot-service'); +const objectResolver = require('../../lib/release-tracks/object-resolver'); +const conflictResolution = require('../../lib/release-tracks/conflict-resolution'); +const logger = require('../../lib/logger'); +const { NotFoundError, BadRequestError } = require('../../exceptions'); + +// Lazy-load workflowService to avoid circular dependency +// (workflow-service imports snapshot-service, which is also imported here) +let workflowService; +function getWorkflowService() { + if (!workflowService) { + workflowService = require('./workflow-service'); + } + return workflowService; +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +const STATUS_RANK = { + 'work-in-progress': 0, + 'awaiting-review': 1, + reviewed: 2, +}; + +/** + * Validate that the snapshot belongs to a standard track. + * @param {Object} snapshot + * @throws {BadRequestError} + */ +function assertStandardTrack(snapshot) { + if (snapshot.type === 'virtual') { + throw new BadRequestError({ + message: 'This operation is only available for standard release tracks', + details: `Track ${snapshot.id} is a virtual track`, + }); + } +} + +/** + * Normalize a raw object_ref entry from the request body into a consistent + * shape: `{ id: string, modified: string|undefined }`. + * + * The controller's Zod schema allows either a bare string or an object. + */ +function normalizeObjectRef(entry) { + if (typeof entry === 'string') { + return { id: entry, modified: undefined }; + } + return { id: entry.id, modified: entry.modified }; +} + +// ============================================================================= +// Candidates +// ============================================================================= + +/** + * Add one or more objects as candidates on the latest snapshot. + * + * For each entry: + * - If `modified` is "latest" or omitted, resolve via the STIX service layer. + * - Skip duplicates (same object_ref + object_modified already in candidates). + * - New candidates start as "work-in-progress". + * + * @param {string} trackId + * @param {Array} objectRefs + * @param {string} [userId] + * @returns {Promise} The new snapshot + */ +exports.addCandidates = async function addCandidates(trackId, objectRefs, userId) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + const now = new Date(); + const existingCandidates = source.candidates || []; + const newEntries = []; + + for (const raw of objectRefs) { + const entry = normalizeObjectRef(raw); + + // Resolve modified timestamp + let modified; + if (!entry.modified || entry.modified === 'latest') { + modified = await objectResolver.resolveLatestModified(entry.id); + } else { + modified = new Date(entry.modified); + } + + // Skip if this exact (object_ref + object_modified) already exists in candidates + const isDuplicate = existingCandidates.some( + (c) => + c.object_ref === entry.id && new Date(c.object_modified).getTime() === modified.getTime(), + ); + if (isDuplicate) { + logger.verbose( + `StandardTrackService: Skipping duplicate candidate ${entry.id} @ ${modified.toISOString()}`, + ); + continue; + } + + newEntries.push({ + object_ref: entry.id, + object_modified: modified, + object_status: 'work-in-progress', + object_added_at: now, + object_added_by: userId, + }); + } + + const mergedCandidates = [...existingCandidates, ...newEntries]; + + let snapshot = await snapshotService.cloneSnapshot(trackId, source, { + candidates: mergedCandidates, + }); + + logger.verbose( + `StandardTrackService: Added ${newEntries.length} candidate(s) to track "${trackId}"`, + ); + + // Evaluate auto-promotion for newly added candidates (Phase 3) + const autoPromotedSnapshot = await getWorkflowService().evaluateAutoPromotion(trackId, snapshot); + if (autoPromotedSnapshot) { + snapshot = autoPromotedSnapshot; + } + + return snapshot; +}; + +/** + * List candidates from the latest snapshot, optionally filtered by status. + * + * @param {string} trackId + * @param {Object} [options] - { status? } + * @returns {Promise<{ candidates: Array }>} + */ +exports.listCandidates = async function listCandidates(trackId, options = {}) { + const snapshot = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(snapshot); + + let candidates = snapshot.candidates || []; + + if (options.status) { + candidates = candidates.filter((c) => c.object_status === options.status); + } + + return { candidates }; +}; + +/** + * Remove all candidate entries for a given object ref from the latest snapshot. + * + * @param {string} trackId + * @param {string} objectRef - The STIX ID of the object to remove + * @returns {Promise} The new snapshot + * @throws {NotFoundError} If no candidate with that object_ref exists + */ +exports.removeCandidate = async function removeCandidate(trackId, objectRef) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + const existingCandidates = source.candidates || []; + const remaining = existingCandidates.filter((c) => c.object_ref !== objectRef); + + if (remaining.length === existingCandidates.length) { + throw new NotFoundError({ + details: `Candidate with object_ref "${objectRef}" not found in track "${trackId}"`, + }); + } + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, { + candidates: remaining, + }); + + logger.verbose(`StandardTrackService: Removed candidate "${objectRef}" from track "${trackId}"`); + return snapshot; +}; + +/** + * Transition the workflow status of matching candidates. + * + * Status transitions are forward-only: + * work-in-progress → awaiting-review → reviewed + * + * If `reviewData.object_refs` is provided, only those candidates are affected. + * Otherwise all candidates matching `from` status are transitioned. + * + * @param {string} trackId + * @param {Object} reviewData - { from, to, object_refs? } + * @param {string} [userId] + * @returns {Promise} The new snapshot + */ +// eslint-disable-next-line no-unused-vars +exports.reviewCandidates = async function reviewCandidates(trackId, reviewData, userId) { + const { from, to, object_refs: filterRefs } = reviewData; + + // Validate forward-only transition + if (STATUS_RANK[to] <= STATUS_RANK[from]) { + throw new BadRequestError({ + message: `Invalid status transition: cannot move from "${from}" to "${to}"`, + details: + 'Status transitions must be forward-only: work-in-progress → awaiting-review → reviewed', + }); + } + + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + // Build a set of object_refs to target (if specified) + let targetRefs = null; + if (filterRefs && filterRefs.length > 0) { + targetRefs = new Set(filterRefs.map((r) => (typeof r === 'string' ? r : r.id))); + } + + const updatedCandidates = (source.candidates || []).map((candidate) => { + // Only transition candidates matching the `from` status + if (candidate.object_status !== from) return candidate; + + // If specific refs requested, skip non-matching + if (targetRefs && !targetRefs.has(candidate.object_ref)) return candidate; + + return { + ...candidate, + object_status: to, + }; + }); + + let snapshot = await snapshotService.cloneSnapshot(trackId, source, { + candidates: updatedCandidates, + }); + + logger.verbose( + `StandardTrackService: Reviewed candidates "${from}" → "${to}" in track "${trackId}"`, + ); + + // Evaluate auto-promotion after status transition (Phase 3) + const autoPromotedSnapshot = await getWorkflowService().evaluateAutoPromotion(trackId, snapshot); + if (autoPromotedSnapshot) { + snapshot = autoPromotedSnapshot; + } + + return snapshot; +}; + +/** + * Manually promote candidates to the staged tier. + * + * Applies the `config.promotion_conflicts.candidates_to_staged` policy to + * handle the case where a different version of the same object already + * exists in staged. + * + * @param {string} trackId + * @param {Array} objectRefs - STIX IDs of candidates to promote + * @param {string} [userId] + * @returns {Promise} The new snapshot + */ +exports.promoteCandidates = async function promoteCandidates(trackId, objectRefs, userId) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + const now = new Date(); + const refSet = new Set(objectRefs); + const existingCandidates = source.candidates || []; + const existingStaged = source.staged || []; + + // Partition candidates into promoted vs remaining + const toPromote = []; + const remainingCandidates = []; + for (const candidate of existingCandidates) { + if (refSet.has(candidate.object_ref)) { + toPromote.push(candidate); + } else { + remainingCandidates.push(candidate); + } + } + + if (toPromote.length === 0) { + throw new NotFoundError({ + details: 'None of the specified object_refs were found in the candidates tier', + }); + } + + // Build staged entries from promoted candidates + const newStagedEntries = toPromote.map((c) => ({ + object_ref: c.object_ref, + object_modified: c.object_modified, + object_status: c.object_status, + object_staged_at: now, + object_staged_by: userId, + })); + + // Apply conflict resolution policy + const policy = + (source.config && + source.config.promotion_conflicts && + source.config.promotion_conflicts.candidates_to_staged) || + 'prefer_latest'; + + const { merged: mergedStaged, rejected } = conflictResolution.applyConflictPolicy( + existingStaged, + newStagedEntries, + policy, + ); + + // If any were rejected, put them back in candidates + const rejectedRefs = new Set(rejected.map((r) => r.object_ref)); + const finalCandidates = [ + ...remainingCandidates, + ...toPromote.filter((c) => rejectedRefs.has(c.object_ref)), + ]; + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, { + candidates: finalCandidates, + staged: mergedStaged, + }); + + logger.verbose( + `StandardTrackService: Promoted ${toPromote.length - rejected.length} candidate(s), ` + + `rejected ${rejected.length} in track "${trackId}"`, + ); + return snapshot; +}; + +/** + * Update the version pin (object_modified) of a specific candidate. + * + * @param {string} trackId + * @param {string} objectRef - The STIX ID + * @param {Object} data - { old_modified, new_modified } + * @returns {Promise} The new snapshot + * @throws {NotFoundError} If no matching candidate is found + */ +exports.updateCandidateVersion = async function updateCandidateVersion(trackId, objectRef, data) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + const oldTime = new Date(data.old_modified).getTime(); + const existingCandidates = source.candidates || []; + + let found = false; + const updatedCandidates = existingCandidates.map((candidate) => { + if ( + candidate.object_ref === objectRef && + new Date(candidate.object_modified).getTime() === oldTime + ) { + found = true; + return { + ...candidate, + object_modified: new Date(data.new_modified), + }; + } + return candidate; + }); + + if (!found) { + throw new NotFoundError({ + details: + `Candidate "${objectRef}" with modified "${data.old_modified}" ` + + `not found in track "${trackId}"`, + }); + } + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, { + candidates: updatedCandidates, + }); + + logger.verbose( + `StandardTrackService: Updated version pin for "${objectRef}" in track "${trackId}"`, + ); + return snapshot; +}; + +// ============================================================================= +// Staged +// ============================================================================= + +/** + * List all staged entries from the latest snapshot. + * + * @param {string} trackId + * @returns {Promise<{ staged: Array }>} + */ +exports.listStaged = async function listStaged(trackId) { + const snapshot = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(snapshot); + + return { staged: snapshot.staged || [] }; +}; + +/** + * Demote staged entries back to the candidates tier. + * + * Each ref in `objectRefs` is `{ id, modified }` to uniquely identify the + * staged entry. Demoted entries preserve their workflow status. + * + * @param {string} trackId + * @param {Array<{id:string, modified:string}>} objectRefs + * @param {string} [userId] + * @returns {Promise} The new snapshot + */ +exports.demoteStaged = async function demoteStaged(trackId, objectRefs, userId) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(source); + + const now = new Date(); + const existingStaged = source.staged || []; + const existingCandidates = source.candidates || []; + + // Build a lookup key for the refs to demote + const demoteKeys = new Set(objectRefs.map((r) => `${r.id}::${new Date(r.modified).getTime()}`)); + + const remainingStaged = []; + const demotedEntries = []; + + for (const staged of existingStaged) { + const key = `${staged.object_ref}::${new Date(staged.object_modified).getTime()}`; + if (demoteKeys.has(key)) { + // Convert back to a candidate entry, preserving workflow status + demotedEntries.push({ + object_ref: staged.object_ref, + object_modified: staged.object_modified, + object_status: staged.object_status || 'work-in-progress', + object_added_at: now, + object_added_by: userId, + }); + } else { + remainingStaged.push(staged); + } + } + + if (demotedEntries.length === 0) { + throw new NotFoundError({ + details: 'None of the specified object_refs were found in the staged tier', + }); + } + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, { + staged: remainingStaged, + candidates: [...existingCandidates, ...demotedEntries], + }); + + logger.verbose( + `StandardTrackService: Demoted ${demotedEntries.length} staged entry/entries in track "${trackId}"`, + ); + return snapshot; +}; + +// ============================================================================= +// Object versions +// ============================================================================= + +/** + * List all tier occurrences of a given object across members, staged, and + * candidates in the latest snapshot. + * + * @param {string} trackId + * @param {string} objectRef - The STIX ID to search for + * @returns {Promise<{ versions: Array }>} + */ +exports.listObjectVersions = async function listObjectVersions(trackId, objectRef) { + const snapshot = await snapshotService.getLatestSnapshot(trackId); + assertStandardTrack(snapshot); + + const versions = []; + + // Search members + for (const entry of snapshot.members || []) { + if (entry.object_ref === objectRef) { + versions.push({ + tier: 'members', + object_ref: entry.object_ref, + object_modified: entry.object_modified, + }); + } + } + + // Search staged + for (const entry of snapshot.staged || []) { + if (entry.object_ref === objectRef) { + versions.push({ + tier: 'staged', + object_ref: entry.object_ref, + object_modified: entry.object_modified, + object_status: entry.object_status, + }); + } + } + + // Search candidates + for (const entry of snapshot.candidates || []) { + if (entry.object_ref === objectRef) { + versions.push({ + tier: 'candidates', + object_ref: entry.object_ref, + object_modified: entry.object_modified, + object_status: entry.object_status, + }); + } + } + + return { versions }; +}; diff --git a/app/services/release-tracks/versioning-service.js b/app/services/release-tracks/versioning-service.js new file mode 100644 index 00000000..2b015c93 --- /dev/null +++ b/app/services/release-tracks/versioning-service.js @@ -0,0 +1,257 @@ +'use strict'; + +// ============================================================================= +// Versioning Service +// +// Manages the bump/tag lifecycle for release track snapshots: +// - Calculate and assign version numbers (MAJOR.MINOR) +// - Promote staged entries to members atomically with tagging +// - Preview upcoming bumps without persisting +// +// Tagging is the ONLY in-place mutation on a snapshot. All other changes +// produce new snapshot clones via snapshot-service. +// +// See docs/COLLECTIONS_V2/03_VERSIONING.md for versioning rules. +// ============================================================================= + +const snapshotService = require('./snapshot-service'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const versionUtils = require('../../lib/release-tracks/version-utils'); +const conflictResolution = require('../../lib/release-tracks/conflict-resolution'); +const logger = require('../../lib/logger'); +const { AlreadyReleasedError } = require('../../exceptions'); + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Core bump logic shared by bumpLatest and bumpByModified. + * + * @param {string} trackId + * @param {Object} snapshot - The snapshot to tag + * @param {Object} options - { type?, version?, dry_run?, userAccountId } + * @returns {Promise} The tagged snapshot (or preview if dry_run) + */ +async function _doBump(trackId, snapshot, options) { + // Guard: cannot re-tag an already-tagged snapshot + if (snapshot.version != null) { + throw new AlreadyReleasedError(snapshot.version); + } + + const versionHistory = snapshot.version_history || []; + + // Calculate version + const version = versionUtils.calculateNextVersion(versionHistory, options.type, options.version); + + // Validate monotonic progression + versionUtils.validateVersionProgression(version, versionHistory); + + // Promote staged → members (standard tracks only) + const staged = snapshot.staged || []; + const existingMembers = snapshot.members || []; + let mergedMembers = existingMembers; + let promotedCount = 0; + + if (staged.length > 0) { + // Convert staged entries to member entries (strip staged-specific fields) + const stagedAsMembers = staged.map((s) => ({ + object_ref: s.object_ref, + object_modified: s.object_modified, + })); + + const policy = + (snapshot.config && + snapshot.config.promotion_conflicts && + snapshot.config.promotion_conflicts.staged_to_members) || + 'abort'; + + const { merged } = conflictResolution.applyConflictPolicy( + existingMembers, + stagedAsMembers, + policy, + ); + + mergedMembers = merged; + promotedCount = staged.length; + } + + const now = new Date(); + + // Build version history entry + const versionHistoryEntry = { + version, + tagged_at: now, + tagged_by: options.userAccountId || 'system', + snapshot_id: snapshot.modified, + summary: { + members_count: mergedMembers.length, + promoted_count: promotedCount, + staged_count: staged.length, + candidate_count: (snapshot.candidates || []).length, + }, + }; + + // Dry-run: return preview without persisting + if (options.dry_run) { + return { + dry_run: true, + track_id: trackId, + snapshot_modified: snapshot.modified, + version, + staged_to_promote: staged.length, + members_after: mergedMembers.length, + version_history_entry: versionHistoryEntry, + }; + } + + // Build additional atomic ops for the tag update + const additionalOps = {}; + if (staged.length > 0) { + additionalOps.members = mergedMembers; + additionalOps.staged = []; + } + + // Atomic tag + promotion + const tagged = await dynamicRepo.tagSnapshotInPlace(trackId, snapshot.modified, { + version, + versionHistoryEntry, + additionalOps: Object.keys(additionalOps).length > 0 ? additionalOps : undefined, + }); + + if (!tagged) { + // Race condition: snapshot was already tagged between our read and update + throw new AlreadyReleasedError('(concurrent tag)'); + } + + // Update registry counters + await registryRepo.updateByTrackId(trackId, { + latest_tagged_version: version, + tagged_release_count: versionHistory.length + 1, + updated_at: now, + }); + + logger.verbose( + `VersioningService: Tagged track "${trackId}" as v${version} ` + + `(promoted ${promotedCount} staged → members)`, + ); + + return tagged; +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Tag the latest snapshot of a track as a versioned release. + * + * - Calculates the next version (or uses explicit version from options) + * - Promotes all staged entries to members atomically + * - Records the version in version_history + * - Updates registry counters + * + * @param {string} trackId + * @param {Object} options - { type?: 'major'|'minor', version?: string, dry_run?: boolean, userAccountId?: string } + * @returns {Promise} The tagged snapshot (or preview object if dry_run) + */ +exports.bumpLatest = async function bumpLatest(trackId, options = {}) { + const snapshot = await snapshotService.getLatestSnapshot(trackId); + return _doBump(trackId, snapshot, options); +}; + +/** + * Tag a specific snapshot (by modified timestamp) as a versioned release. + * + * Same semantics as bumpLatest but targets a specific snapshot. + * + * @param {string} trackId + * @param {string|Date} modified - The snapshot's modified timestamp + * @param {Object} options - { type?: 'major'|'minor', version?: string, dry_run?: boolean, userAccountId?: string } + * @returns {Promise} The tagged snapshot (or preview object if dry_run) + */ +exports.bumpByModified = async function bumpByModified(trackId, modified, options = {}) { + const snapshot = await snapshotService.getSnapshotByModified(trackId, modified); + return _doBump(trackId, snapshot, options); +}; + +/** + * Preview what a bump on the latest snapshot would produce without persisting. + * + * Returns the calculated version, staged-to-members diff, and summary stats. + * + * @param {string} trackId + * @param {string} [_format] - Reserved for future export format support + * @returns {Promise} Preview object + */ +// eslint-disable-next-line no-unused-vars +exports.previewBump = async function previewBump(trackId, _format) { + const snapshot = await snapshotService.getLatestSnapshot(trackId); + + const versionHistory = snapshot.version_history || []; + const staged = snapshot.staged || []; + const existingMembers = snapshot.members || []; + + // Calculate what the next version would be (default minor bump) + const isAlreadyTagged = snapshot.version != null; + const nextMinor = isAlreadyTagged + ? null + : versionUtils.calculateNextVersion(versionHistory, 'minor'); + const nextMajor = isAlreadyTagged + ? null + : versionUtils.calculateNextVersion(versionHistory, 'major'); + + // Preview staged → members merge + let mergedMembersCount = existingMembers.length; + if (staged.length > 0 && !isAlreadyTagged) { + const stagedAsMembers = staged.map((s) => ({ + object_ref: s.object_ref, + object_modified: s.object_modified, + })); + + const policy = + (snapshot.config && + snapshot.config.promotion_conflicts && + snapshot.config.promotion_conflicts.staged_to_members) || + 'abort'; + + try { + const { merged } = conflictResolution.applyConflictPolicy( + existingMembers, + stagedAsMembers, + policy, + ); + mergedMembersCount = merged.length; + } catch (err) { + // If policy is 'abort' and conflicts exist, report it in the preview + return { + track_id: trackId, + snapshot_modified: snapshot.modified, + is_already_tagged: isAlreadyTagged, + current_version: snapshot.version, + next_version_minor: nextMinor, + next_version_major: nextMajor, + staged_count: staged.length, + members_count: existingMembers.length, + candidates_count: (snapshot.candidates || []).length, + conflicts: err.conflicts || [], // Include full conflicts array + }; + } + } + + return { + track_id: trackId, + snapshot_modified: snapshot.modified, + is_already_tagged: isAlreadyTagged, + current_version: snapshot.version, + next_version_minor: nextMinor, + next_version_major: nextMajor, + staged_count: staged.length, + staged_to_promote: isAlreadyTagged ? 0 : staged.length, + members_count: existingMembers.length, + members_after_promotion: isAlreadyTagged ? existingMembers.length : mergedMembersCount, + candidates_count: (snapshot.candidates || []).length, + version_history: versionHistory, + }; +}; diff --git a/app/services/release-tracks/virtual-track-service.js b/app/services/release-tracks/virtual-track-service.js new file mode 100644 index 00000000..3669b6db --- /dev/null +++ b/app/services/release-tracks/virtual-track-service.js @@ -0,0 +1,420 @@ +'use strict'; + +// ============================================================================= +// Virtual Track Service +// +// Manages virtual release track operations: composition configuration and +// snapshot creation via resolution of component tracks. +// +// Virtual tracks aggregate content from multiple standard tracks by: +// 1. Resolving each component track to a specific tagged snapshot +// 2. Collecting members from each resolved snapshot +// 3. Applying per-component filters (object_types) +// 4. Deduplicating across all components +// 5. Persisting the result as a new draft snapshot +// +// See docs/COLLECTIONS_V2/04_VIRTUAL_TRACKS.md for full specification. +// ============================================================================= + +const snapshotService = require('./snapshot-service'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const deduplicationStrategies = require('../../lib/release-tracks/deduplication-strategies'); +const logger = require('../../lib/logger'); +const { + BadRequestError, + TrackNotFoundError, + NoTaggedSnapshotsError, + InvalidComponentTypeError, +} = require('../../exceptions'); + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Validate that the snapshot belongs to a virtual track. + * @param {Object} snapshot + * @throws {BadRequestError} + */ +function assertVirtualTrack(snapshot) { + if (snapshot.type !== 'virtual') { + throw new BadRequestError({ + message: 'This operation is only available for virtual release tracks', + details: `Track ${snapshot.id} is a ${snapshot.type} track`, + }); + } +} + +/** + * Validate that all component tracks exist, are standard tracks, and have + * no duplicate track_ids or priority values. + * + * @param {Array} componentTracks - The composition.component_tracks array + * @returns {Promise>} Map of track_id → registry entry + */ +async function validateComponentTracks(componentTracks) { + if (!componentTracks || componentTracks.length === 0) { + throw new BadRequestError({ + message: 'Composition must include at least one component track', + }); + } + + // Check for duplicate track_ids + const trackIds = componentTracks.map((c) => c.track_id); + const uniqueTrackIds = new Set(trackIds); + if (uniqueTrackIds.size !== trackIds.length) { + throw new BadRequestError({ + message: 'Duplicate track_id values found in component_tracks', + details: 'Each component track must reference a unique track', + }); + } + + // Check for duplicate priorities + const priorities = componentTracks.map((c) => c.priority); + const uniquePriorities = new Set(priorities); + if (uniquePriorities.size !== priorities.length) { + throw new BadRequestError({ + message: 'Duplicate priority values found in component_tracks', + details: 'Each component track must have a unique priority value', + }); + } + + // Validate each component exists and is a standard track + const registryMap = new Map(); + for (const component of componentTracks) { + const registry = await registryRepo.findByTrackId(component.track_id); + if (!registry) { + throw new TrackNotFoundError(component.track_id); + } + if (registry.type === 'virtual') { + throw new InvalidComponentTypeError(component.track_id); + } + registryMap.set(component.track_id, registry); + } + + return registryMap; +} + +/** + * Resolve a component track to a specific tagged snapshot based on its + * resolution strategy. + * + * @param {Object} component - A component_tracks entry + * @returns {Promise} The resolved snapshot document + * @throws {NoTaggedSnapshotsError} If no suitable tagged snapshot is found + */ +async function resolveComponentSnapshot(component) { + let snapshot; + + switch (component.resolution_strategy) { + case 'latest_tagged': + snapshot = await dynamicRepo.getLatestTaggedSnapshot(component.track_id); + break; + + case 'specific_version': + snapshot = await dynamicRepo.getSnapshotByVersion(component.track_id, component.version); + break; + + case 'specific_snapshot': + snapshot = await dynamicRepo.getSnapshotByModified(component.track_id, component.snapshot); + break; + + default: + throw new BadRequestError({ + message: `Unknown resolution strategy: ${component.resolution_strategy}`, + }); + } + + if (!snapshot) { + throw new NoTaggedSnapshotsError(component.track_id); + } + + // For specific_snapshot strategy, the snapshot may be a draft — validate it's tagged + if (snapshot.version == null) { + throw new NoTaggedSnapshotsError(component.track_id); + } + + return snapshot; +} + +/** + * Apply object_types filter to a list of member entries. + * Filters by extracting the STIX type prefix from the object_ref + * (e.g., "attack-pattern" from "attack-pattern--uuid"). + * + * @param {Array} members - Member entries with object_ref + * @param {Object} [filters] - { object_types?: string[], domains?: string[] } + * @returns {Array} Filtered members + */ +function applyFilters(members, filters) { + if (!filters) return members; + + let filtered = members; + + if (filters.object_types && filters.object_types.length > 0) { + const allowedTypes = new Set(filters.object_types); + filtered = filtered.filter((m) => { + const stixType = m.object_ref.split('--')[0]; + return allowedTypes.has(stixType); + }); + } + + // Note: domains filtering requires fetching full STIX objects, which is + // deferred to Phase 6 (export-service). For now, domains filter is a no-op + // logged as a warning. + if (filters.domains && filters.domains.length > 0) { + logger.warn( + 'VirtualTrackService: domains filter is not yet implemented (requires Phase 6 export infrastructure)', + ); + } + + return filtered; +} + +/** + * Core composition resolution logic shared by createVirtualSnapshot and + * previewVirtualSnapshot. + * + * @param {Object} snapshot - The current virtual track snapshot + * @param {Map} registryMap - track_id → registry entry + * @returns {Promise} Resolution result with members, quarantined, and metadata + */ +async function resolveComposition(snapshot, registryMap) { + const composition = snapshot.composition; + const componentTracks = composition.component_tracks || []; + const strategy = + (composition.deduplication && composition.deduplication.strategy) || 'prioritize_latest_object'; + + const now = new Date(); + const componentSnapshotsMeta = []; + const allAnnotatedMembers = []; + + // Resolve each component track in parallel + const resolutions = await Promise.all( + componentTracks.map((component) => resolveComponentSnapshot(component)), + ); + + for (let i = 0; i < componentTracks.length; i++) { + const component = componentTracks[i]; + const resolvedSnapshot = resolutions[i]; + const registry = registryMap.get(component.track_id); + + // Extract members from the resolved snapshot + const sourceMembers = resolvedSnapshot.members || []; + const totalObjectsInSource = sourceMembers.length; + + // Apply filters + const filteredMembers = applyFilters(sourceMembers, component.filters); + const objectsAfterFilter = filteredMembers.length; + + // Annotate each member with source metadata for deduplication + for (const member of filteredMembers) { + allAnnotatedMembers.push({ + object_ref: member.object_ref, + object_modified: member.object_modified, + _source_track_id: component.track_id, + _source_track_name: registry.name, + _source_snapshot_modified: resolvedSnapshot.modified, + _source_snapshot_version: resolvedSnapshot.version, + _source_priority: component.priority, + }); + } + + // Build component resolution metadata + componentSnapshotsMeta.push({ + track_id: component.track_id, + track_name: registry.name, + track_type: registry.type, + resolved_snapshot_id: resolvedSnapshot.modified, + resolved_version: resolvedSnapshot.version, + strategy_used: component.resolution_strategy, + filters_applied: component.filters || undefined, + total_objects_in_source: totalObjectsInSource, + objects_after_filter: objectsAfterFilter, + objects_contributed: 0, // Updated after deduplication + }); + } + + // Deduplicate across all components + const { members, quarantined, report } = deduplicationStrategies.deduplicate( + allAnnotatedMembers, + strategy, + ); + + // Update objects_contributed per component by counting how many of each + // component's members survived deduplication + const survivorSources = new Map(); + for (const annotated of allAnnotatedMembers) { + // Check if this specific entry survived deduplication + const survived = members.some( + (m) => + m.object_ref === annotated.object_ref && + new Date(m.object_modified).getTime() === new Date(annotated.object_modified).getTime(), + ); + if (survived) { + const count = survivorSources.get(annotated._source_track_id) || 0; + survivorSources.set(annotated._source_track_id, count + 1); + } + } + + for (const meta of componentSnapshotsMeta) { + meta.objects_contributed = survivorSources.get(meta.track_id) || 0; + } + + // Build composition_resolution + const compositionResolution = { + resolved_at: now, + component_snapshots: componentSnapshotsMeta, + deduplication: report, + summary: { + total_objects: members.length, + quarantined_objects: quarantined.length, + }, + }; + + return { members, quarantined, compositionResolution }; +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Update the composition rules for a virtual track. + * + * Validates all component tracks exist and are standard tracks, then clones + * the latest snapshot with the updated composition. + * + * @param {string} trackId + * @param {Object} composition - The new composition configuration + * @param {string} [userId] + * @returns {Promise} The new snapshot + */ +// eslint-disable-next-line no-unused-vars +exports.updateComposition = async function updateComposition(trackId, composition, userId) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); + + // Validate all component tracks + await validateComponentTracks(composition.component_tracks); + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, { composition }); + + logger.verbose( + `VirtualTrackService: Updated composition for track "${trackId}" ` + + `(${composition.component_tracks.length} component track(s))`, + ); + return snapshot; +}; + +/** + * Create a new virtual snapshot by resolving the composition rules. + * + * For each component track: + * 1. Resolve to a tagged snapshot via the configured strategy + * 2. Extract and filter members + * Then deduplicate across all components and persist a new draft snapshot. + * + * @param {string} trackId + * @param {Object} [options] - { description?, userAccountId? } + * @returns {Promise} The new snapshot with composition_resolution metadata + */ +exports.createVirtualSnapshot = async function createVirtualSnapshot(trackId, options = {}) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); + + const composition = source.composition; + if (!composition || !composition.component_tracks || composition.component_tracks.length === 0) { + throw new BadRequestError({ + message: 'Cannot create virtual snapshot: no component tracks configured', + details: 'Update the composition before creating a snapshot', + }); + } + + // Validate component tracks + const registryMap = await validateComponentTracks(composition.component_tracks); + + // Resolve composition + const { members, quarantined, compositionResolution } = await resolveComposition( + source, + registryMap, + ); + + // Build overrides for the new snapshot + const overrides = { + members, + quarantine: quarantined, + composition_resolution: compositionResolution, + }; + + if (options.description !== undefined) { + overrides.description = options.description; + } + + const snapshot = await snapshotService.cloneSnapshot(trackId, source, overrides); + + logger.verbose( + `VirtualTrackService: Created virtual snapshot for track "${trackId}" ` + + `(${members.length} members, ${quarantined.length} quarantined)`, + ); + return snapshot; +}; + +/** + * Preview what a virtual snapshot would contain without persisting. + * + * Runs the same resolution and deduplication logic as createVirtualSnapshot + * but returns the results without saving a new snapshot. + * + * @param {string} trackId + * @returns {Promise} Preview object with resolution details + */ +exports.previewVirtualSnapshot = async function previewVirtualSnapshot(trackId) { + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); + + const composition = source.composition; + if (!composition || !composition.component_tracks || composition.component_tracks.length === 0) { + throw new BadRequestError({ + message: 'Cannot preview virtual snapshot: no component tracks configured', + details: 'Update the composition before previewing a snapshot', + }); + } + + // Validate component tracks + const registryMap = await validateComponentTracks(composition.component_tracks); + + // Resolve composition (same logic, but we don't persist) + const { members, quarantined, compositionResolution } = await resolveComposition( + source, + registryMap, + ); + + // Build comparison to the latest tagged version (if any) + const existingMembers = source.members || []; + const existingMemberRefs = new Set(existingMembers.map((m) => m.object_ref)); + const newMemberRefs = new Set(members.map((m) => m.object_ref)); + + const newObjects = members.filter((m) => !existingMemberRefs.has(m.object_ref)); + const removedObjects = existingMembers.filter((m) => !newMemberRefs.has(m.object_ref)); + const updatedObjects = members.filter((m) => { + const existing = existingMembers.find((e) => e.object_ref === m.object_ref); + if (!existing) return false; + return new Date(m.object_modified).getTime() !== new Date(existing.object_modified).getTime(); + }); + + return { + track_id: trackId, + preview: true, + composition_resolution: compositionResolution, + members_count: members.length, + quarantined_count: quarantined.length, + comparison_to_current: { + current_members_count: existingMembers.length, + new_objects: newObjects.length, + updated_objects: updatedObjects.length, + removed_objects: removedObjects.length, + }, + }; +}; diff --git a/app/services/release-tracks/workflow-service.js b/app/services/release-tracks/workflow-service.js new file mode 100644 index 00000000..8a28884b --- /dev/null +++ b/app/services/release-tracks/workflow-service.js @@ -0,0 +1,178 @@ +'use strict'; + +// ============================================================================= +// Workflow Service +// +// Manages automatic promotion of candidates to staged based on workflow +// status and candidacy threshold configuration. +// +// Core functionality: +// - Evaluates whether a candidate's workflow status meets the track's +// candidacy threshold +// - Automatically promotes qualifying candidates to the staged tier +// - Handles conflict resolution during auto-promotion +// +// This service is invoked by standard-track-service after: +// - Candidate status transitions (reviewCandidates) +// - New candidates are added (addCandidates) +// ============================================================================= + +const snapshotService = require('./snapshot-service'); +const conflictResolution = require('../../lib/release-tracks/conflict-resolution'); +const logger = require('../../lib/logger'); + +// ============================================================================= +// Status ranking and threshold evaluation +// ============================================================================= + +const STATUS_RANK = { + 'work-in-progress': 0, + 'awaiting-review': 1, + reviewed: 2, +}; + +/** + * Check if a candidate's status meets or exceeds the configured threshold. + * + * @param {string} candidateStatus - The candidate's current status + * @param {string} threshold - The configured candidacy threshold + * @returns {boolean} True if the candidate meets the threshold + */ +exports.meetsThreshold = function meetsThreshold(candidateStatus, threshold) { + const candidateRank = STATUS_RANK[candidateStatus]; + const thresholdRank = STATUS_RANK[threshold]; + + if (candidateRank === undefined || thresholdRank === undefined) { + return false; + } + + return candidateRank >= thresholdRank; +}; + +// ============================================================================= +// Auto-promotion evaluation +// ============================================================================= + +/** + * Evaluate and execute auto-promotion for qualifying candidates. + * + * This is called after candidate status changes (via reviewCandidates) or + * when new candidates are added (via addCandidates). If auto_promote is + * enabled and candidates meet the candidacy threshold, they are automatically + * promoted to the staged tier. + * + * @param {string} trackId - The release track ID + * @param {Object} snapshot - The current snapshot (with updated candidates) + * @returns {Promise} The new snapshot if promotion occurred, null otherwise + */ +exports.evaluateAutoPromotion = async function evaluateAutoPromotion(trackId, snapshot) { + // Auto-promotion only applies to standard tracks + if (snapshot.type !== 'standard') { + return null; + } + + // Check if auto-promotion is enabled + const config = snapshot.config || {}; + if (config.auto_promote !== true) { + return null; + } + + // Determine the candidacy threshold (default: 'reviewed') + const threshold = config.candidacy_threshold || 'reviewed'; + + // Find candidates that meet the threshold + const candidates = snapshot.candidates || []; + const qualifying = candidates.filter((c) => exports.meetsThreshold(c.object_status, threshold)); + + if (qualifying.length === 0) { + return null; + } + + logger.verbose( + `WorkflowService: ${qualifying.length} candidate(s) meet threshold "${threshold}" in track "${trackId}"`, + ); + + // Promote qualifying candidates to staged + return _promoteToStaged(trackId, snapshot, qualifying); +}; + +// ============================================================================= +// Internal promotion logic +// ============================================================================= + +/** + * Internal: Promote qualifying candidates to the staged tier. + * + * This performs the actual tier mutation by: + * 1. Building staged entries from qualifying candidates + * 2. Applying conflict resolution policy + * 3. Removing promoted candidates from the candidates tier + * 4. Cloning the snapshot with updated tiers + * + * @param {string} trackId + * @param {Object} snapshot - The source snapshot + * @param {Array} qualifyingCandidates - Candidates to promote + * @returns {Promise} The new snapshot + */ +async function _promoteToStaged(trackId, snapshot, qualifyingCandidates) { + const now = new Date(); + const existingCandidates = snapshot.candidates || []; + const existingStaged = snapshot.staged || []; + + // Build a set of qualifying object_refs for efficient lookup + const qualifyingRefs = new Set(qualifyingCandidates.map((c) => c.object_ref)); + + // Partition candidates into promoted vs remaining + const toPromote = []; + const remainingCandidates = []; + + for (const candidate of existingCandidates) { + if (qualifyingRefs.has(candidate.object_ref)) { + toPromote.push(candidate); + } else { + remainingCandidates.push(candidate); + } + } + + // Build staged entries from promoted candidates + const newStagedEntries = toPromote.map((c) => ({ + object_ref: c.object_ref, + object_modified: c.object_modified, + object_status: c.object_status, + object_staged_at: now, + object_staged_by: c.object_added_by, // Preserve original author + })); + + // Apply conflict resolution policy + const policy = + (snapshot.config && + snapshot.config.promotion_conflicts && + snapshot.config.promotion_conflicts.candidates_to_staged) || + 'prefer_latest'; + + const { merged: mergedStaged, rejected } = conflictResolution.applyConflictPolicy( + existingStaged, + newStagedEntries, + policy, + ); + + // If any were rejected by conflict policy, put them back in candidates + const rejectedRefs = new Set(rejected.map((r) => r.object_ref)); + const finalCandidates = [ + ...remainingCandidates, + ...toPromote.filter((c) => rejectedRefs.has(c.object_ref)), + ]; + + // Clone snapshot with updated tiers + const newSnapshot = await snapshotService.cloneSnapshot(trackId, snapshot, { + candidates: finalCandidates, + staged: mergedStaged, + }); + + logger.verbose( + `WorkflowService: Auto-promoted ${toPromote.length - rejected.length} candidate(s), ` + + `rejected ${rejected.length} in track "${trackId}"`, + ); + + return newSnapshot; +} diff --git a/app/services/reports-service.js b/app/services/reports-service.js new file mode 100644 index 00000000..3bdce9c1 --- /dev/null +++ b/app/services/reports-service.js @@ -0,0 +1,83 @@ +'use strict'; + +const attackObjectsRepository = require('../repository/attack-objects-repository'); +const relationshipsRepository = require('../repository/relationships-repository'); +const identitiesService = require('./stix/identities-service'); + +/** + * Service for generating reports on ATT&CK objects and relationships. + * These are read-only analytical queries that identify potential data quality issues. + */ +class ReportsService { + /** + * Retrieves all objects (ATT&CK objects and/or relationships) that contain + * "attack.mitre.org" in their description, indicating a likely missing LinkById reference. + * @param {Object} options - Query options + * @param {string} [options.type] - Filter by STIX type (e.g., 'relationship', 'attack-pattern') + * @returns {Promise} Array of objects with attack.mitre.org in description + */ + async getMissingLinkById(options = {}) { + const results = []; + + // If type is 'relationship' or not specified, include relationships + if (!options.type || options.type === 'relationship') { + const relationships = await relationshipsRepository.retrieveAllWithAttackURLInDescription(); + await identitiesService.addCreatedByAndModifiedByIdentitiesToAll(relationships); + results.push(...relationships); + } + + // If type is not 'relationship' or not specified, include attack objects + if (!options.type || options.type !== 'relationship') { + const attackObjects = await attackObjectsRepository.retrieveAllWithAttackURLInDescription(); + // If a specific type is requested, filter attack objects by type + const filteredObjects = options.type + ? attackObjects.filter((obj) => obj.stix?.type === options.type) + : attackObjects; + await identitiesService.addCreatedByAndModifiedByIdentitiesToAll(filteredObjects); + results.push(...filteredObjects); + } + + return results; + } + + /** + * Retrieves parallel relationships - relationships that share the same source_ref, + * target_ref, and relationship_type. + * @returns {Promise} Map of relationship keys to arrays of parallel relationships + */ + async getParallelRelationships(options = { lookupRefs: true }) { + const relationshipMap = await relationshipsRepository.retrieveParallelRelationships(); + + // Add identity information to each relationship in the map + for (const relationships of relationshipMap.values()) { + // Get source and target objects + if (options.lookupRefs) { + for (const document of relationships) { + if (Array.isArray(document.source_objects)) { + if (document.source_objects.length === 0) { + document.source_objects = undefined; + } else { + document.source_objects.sort((a, b) => b.stix.modified - a.stix.modified); + document.source_object = document.source_objects[0]; + document.source_objects = undefined; + } + } + if (Array.isArray(document.target_objects)) { + if (document.target_objects.length === 0) { + document.target_objects = undefined; + } else { + document.target_objects.sort((a, b) => b.stix.modified - a.stix.modified); + document.target_object = document.target_objects[0]; + document.target_objects = undefined; + } + } + } + } + await identitiesService.addCreatedByAndModifiedByIdentitiesToAll(relationships); + } + + return relationshipMap; + } +} + +module.exports = new ReportsService(); diff --git a/app/services/software-service.js b/app/services/software-service.js deleted file mode 100644 index 8f6f86d2..00000000 --- a/app/services/software-service.js +++ /dev/null @@ -1,78 +0,0 @@ -'use strict'; - -const uuid = require('uuid'); -const systemConfigurationService = require('./system-configuration-service'); -const config = require('../config/config'); - -const { PropertyNotAllowedError, InvalidTypeError } = require('../exceptions'); - -const BaseService = require('./_base.service'); -const softwareRepository = require('../repository/software-repository'); - -const { Malware: MalwareType, Tool: ToolType } = require('../lib/types'); - -class SoftwareService extends BaseService { - async create(data, options) { - // This function handles two use cases: - // 1. This is a completely new object. Create a new object and generate the stix.id if not already - // provided. Set both stix.created_by_ref and stix.x_mitre_modified_by_ref to the organization identity. - // 2. This is a new version of an existing object. Create a new object with the specified id. - // Set stix.x_mitre_modified_by_ref to the organization identity. - - // is_family defaults to true for malware, not allowed for tools - if (data?.stix?.type !== MalwareType && data?.stix?.type !== ToolType) { - throw new InvalidTypeError(); - } - - if (data.stix && data.stix.type === MalwareType && typeof data.stix.is_family !== 'boolean') { - data.stix.is_family = true; - } else if (data.stix && data.stix.type === ToolType && data.stix.is_family !== undefined) { - throw new PropertyNotAllowedError(); - } - - options = options || {}; - if (!options.import) { - // Set the ATT&CK Spec Version - data.stix.x_mitre_attack_spec_version = - data.stix.x_mitre_attack_spec_version ?? config.app.attackSpecVersion; - - // Record the user account that created the object - if (options.userAccountId) { - data.workspace.workflow.created_by_user_account = options.userAccountId; - } - - // Set the default marking definitions - await this.setDefaultMarkingDefinitionsForObject(data); - - // Get the organization identity - const organizationIdentityRef = - await systemConfigurationService.retrieveOrganizationIdentityRef(); - - // Check for an existing object - let existingObject; - if (data.stix.id) { - existingObject = await this.repository.retrieveOneById(data.stix.id); - } - - if (existingObject) { - // New version of an existing object - // Only set the x_mitre_modified_by_ref property - data.stix.x_mitre_modified_by_ref = organizationIdentityRef; - } else { - // New object - // Assign a new STIX id if not already provided - if (!data.stix.id) { - // const stixIdPrefix = getStixIdPrefixFromModel(this.model.modelName, data.stix.type); - data.stix.id = `${data.stix.type}--${uuid.v4()}`; - } - - // Set the created_by_ref and x_mitre_modified_by_ref properties - data.stix.created_by_ref = organizationIdentityRef; - data.stix.x_mitre_modified_by_ref = organizationIdentityRef; - } - } - return await this.repository.save(data); - } -} - -module.exports = new SoftwareService(null, softwareRepository); diff --git a/app/services/stix/analytics-service.js b/app/services/stix/analytics-service.js new file mode 100644 index 00000000..1f10fd6c --- /dev/null +++ b/app/services/stix/analytics-service.js @@ -0,0 +1,646 @@ +'use strict'; + +const analyticsRepository = require('../../repository/analytics-repository'); +const dataComponentsRepository = require('../../repository/data-components-repository'); +const { BaseService } = require('../meta-classes'); +const { Analytic: AnalyticType } = require('../../lib/types'); +const { + createAttackExternalReference, + removeAttackExternalReferences, +} = require('../../lib/external-reference-builder'); +const EventBus = require('../../lib/event-bus'); +const logger = require('../../lib/logger'); +const Exceptions = require('../../exceptions'); +const { deepFreezeStix } = require('../../lib/import-safety'); + +/** + * Service for managing analytics + * + * Lifecycle hooks: + * - beforeCreate: Builds outbound embedded_relationships for data components and validates they exist + * - afterCreate: Emits domain event to notify DataComponentsService + * - beforeUpdate: Rebuilds outbound embedded_relationships, validates data component references, and detects changes + * - afterUpdate: Emits domain events for added/removed data components + * + * Event listeners: + * - x-mitre-detection-strategy::analytics-referenced - Add inbound relationships when detection strategy references analytics + * - x-mitre-detection-strategy::analytics-removed - Remove inbound relationships when detection strategy removes analytics + * + * Events emitted (listened to by DataComponentsService): + * - x-mitre-analytic::data-components-referenced + * - x-mitre-analytic::data-components-removed + */ +class AnalyticsService extends BaseService { + /** + * Initialize event listeners + * Called once on app startup + */ + static initializeEventListeners() { + EventBus.on( + 'x-mitre-detection-strategy::analytics-referenced', + this.handleAnalyticsReferenced.bind(this), + ); + + EventBus.on( + 'x-mitre-detection-strategy::analytics-removed', + this.handleAnalyticsRemoved.bind(this), + ); + + logger.info('AnalyticsService: Event listeners initialized'); + } + + /** + * Handle analytics being referenced by a detection strategy + * Add inbound embedded_relationship and (when not importing) refresh the + * ATT&CK external_references URL on each referenced analytic. + * + * @param {Object} payload - Event payload + * @param {Object} payload.detectionStrategy - The detection strategy document that references the analytics + * @param {string[]} payload.analyticIds - Array of analytic STIX IDs being referenced + * @param {Object} [payload.options] - The originating create-options forwarded from + * DetectionStrategiesService.afterCreate. Used here to honor the + * import-fidelity contract — see app/lib/import-safety.js. When + * `options.import` is true, the workspace metadata update still runs but + * the stix.external_references rewrite is skipped. + * @returns {Promise} + */ + static async handleAnalyticsReferenced(payload) { + const { detectionStrategy, analyticIds, options } = payload; + + logger.info( + `Analytics Service heard event: 'x-mitre-detection-strategy::analytics-referenced' for ${detectionStrategy.stix.id}`, + ); + + for (const analyticId of analyticIds) { + try { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + + if (!analytic) { + logger.warn( + `AnalyticsService: Could not find analytic ${analyticId} to add inbound relationship`, + ); + continue; + } + + // Import-fidelity guard: when the triggering create came from a + // bundle import, freeze this analytic's stix so any forgotten + // import gate below crashes loudly with a TypeError instead of + // silently rewriting the persisted analytic's stix fields. + // Workspace mutations are unaffected. See app/lib/import-safety.js. + if (options?.import) deepFreezeStix(analytic); + + // Initialize embedded_relationships if needed + if (!analytic.workspace) { + analytic.workspace = {}; + } + if (!analytic.workspace.embedded_relationships) { + analytic.workspace.embedded_relationships = []; + } + + // Check if relationship already exists + const exists = analytic.workspace.embedded_relationships.some( + (rel) => rel.stix_id === detectionStrategy.stix.id && rel.direction === 'inbound', + ); + + if (!exists) { + // Add inbound embedded_relationship + analytic.workspace.embedded_relationships.push({ + stix_id: detectionStrategy.stix.id, + attack_id: detectionStrategy.workspace?.attack_id || null, + name: detectionStrategy.stix.name, + direction: 'inbound', + }); + + logger.info( + `AnalyticsService: Added inbound relationship from detection strategy ${detectionStrategy.stix.id} to analytic ${analyticId}`, + ); + } + + // Refresh the analytic's ATT&CK external_references URL so it + // points at the parent detection strategy. This rewrites + // `stix.external_references` and must therefore be skipped on the + // import path; the bundle is the source of truth for stix content. + // The framework freeze above would throw here if this gate were + // missing — that's intentional. + if (!options?.import) { + if (!analytic.stix.external_references) { + analytic.stix.external_references = []; + } + + // Remove existing ATT&CK external references + analytic.stix.external_references = removeAttackExternalReferences( + analytic.stix.external_references, + ); + + // Create new ATT&CK external reference with URL + const attackRef = createAttackExternalReference(analytic.toObject()); + if (attackRef) { + analytic.stix.external_references.unshift(attackRef); + logger.info( + `AnalyticsService: Updated external_references URL for analytic ${analyticId}`, + ); + } + } + + await analyticsRepository.saveDocument(analytic); + } catch (error) { + logger.error( + `AnalyticsService: Error handling analytics-referenced for ${analyticId}:`, + error, + ); + // Continue processing other analytics + } + } + } + + /** + * Handle analytics being removed from a detection strategy + * Remove inbound embedded_relationship and update external_references + * + * @param {Object} payload - Event payload + * @param {string} payload.detectionStrategyId - STIX ID of the detection strategy + * @param {string[]} payload.analyticIds - Array of analytic STIX IDs being removed + * @returns {Promise} + */ + static async handleAnalyticsRemoved(payload) { + const { detectionStrategyId, analyticIds } = payload; + + for (const analyticId of analyticIds) { + try { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + + if (!analytic) { + logger.warn( + `AnalyticsService: Could not find analytic ${analyticId} to remove inbound relationship`, + ); + continue; + } + + if (analytic.workspace?.embedded_relationships) { + // Remove inbound embedded_relationship + const initialLength = analytic.workspace.embedded_relationships.length; + analytic.workspace.embedded_relationships = + analytic.workspace.embedded_relationships.filter( + (rel) => !(rel.stix_id === detectionStrategyId && rel.direction === 'inbound'), + ); + + const removed = analytic.workspace.embedded_relationships.length < initialLength; + if (removed) { + logger.info( + `AnalyticsService: Removed inbound relationship from detection strategy ${detectionStrategyId} to analytic ${analyticId}`, + ); + } + } + + // Update external_references (remove URL since no parent) + if (analytic.stix?.external_references) { + // Remove existing ATT&CK external references + analytic.stix.external_references = removeAttackExternalReferences( + analytic.stix.external_references, + ); + + // Rebuild ATT&CK external reference without URL (no parent detection strategy) + const attackRef = { + source_name: 'mitre-attack', + external_id: analytic.workspace.attack_id, + }; + + analytic.stix.external_references.unshift(attackRef); + + logger.info( + `AnalyticsService: Removed external_references URL for analytic ${analyticId}`, + ); + } + + await analyticsRepository.saveDocument(analytic); + } catch (error) { + logger.error( + `AnalyticsService: Error handling analytics-removed for ${analyticId}:`, + error, + ); + // Continue processing other analytics + } + } + } + + /** + * Lifecycle hook: Prepare analytic data before database persistence + * - Sets analytic name to match ATT&CK ID + * - Builds outbound embedded_relationships for data component references + * - Validates that all referenced data components exist + * + * @param {Object} data - The analytic data to be created + * @param {Object} data.stix - STIX properties + * @param {Object} data.workspace - Workbench metadata + * @param {Object} options - Creation options + * @throws {Exceptions.NotFoundError} If a referenced data component does not exist + * @returns {Promise} + */ + async beforeCreate(data, options) { + // Import-fidelity contract: when a STIX bundle is being imported, the + // bundle's `stix` content must be persisted byte-faithful. Stamping + // `stix.name` from the ATT&CK ID is correct for user-driven POST flows, + // where the server is the authority on the analytic's display name, but + // incorrect for an import — the bundle already carries the name. The + // framework freezes `data.stix` during import-mode hooks (see + // app/lib/import-safety.js), so the assignment below would throw a + // TypeError without this gate. Workspace mutations further down still + // run unconditionally. + if (!options?.import) { + const id = data.workspace.attack_id; + data.stix.name = id.replace(/^AN(\d+)$/, 'Analytic $1'); + logger.debug(`Setting name to match ATT&CK ID: ${data.stix.name}`); + } + + // Initialize embedded_relationships if not present + if (!data.workspace) { + data.workspace = {}; + } + if (!data.workspace.embedded_relationships) { + data.workspace.embedded_relationships = []; + } + + // Check if this is a new version of an existing analytic. + let previousVersion = null; + if (data.stix?.id) { + try { + previousVersion = await this.repository.retrieveLatestByStixId(data.stix.id); + } catch { + logger.debug(`No previous version found for analytic ${data.stix.id}`); + } + } + + // Build outbound embedded_relationships for data component references + const dataComponentRefs = + data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || []; + + // Preserve non-data-component relationships from the previous persisted version when POST + // is creating a new version. Client payloads often omit server-managed workspace metadata. + const baselineEmbeddedRelationships = + previousVersion?.workspace?.embedded_relationships || + data.workspace.embedded_relationships || + []; + const existingNonDataComponentRels = baselineEmbeddedRelationships.filter( + (rel) => !rel.stix_id?.startsWith('x-mitre-data-component--'), + ); + data.workspace.embedded_relationships = [...existingNonDataComponentRels]; + + if (dataComponentRefs.length > 0) { + for (const dataComponentId of dataComponentRefs) { + const dataComponent = + await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); + if (!dataComponent) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-component', + objectId: dataComponentId, + message: `Cannot create analytic: Referenced data component ${dataComponentId} does not exist`, + }); + } + + // Add outbound embedded_relationship + data.workspace.embedded_relationships.push({ + stix_id: dataComponentId, + attack_id: dataComponent.workspace?.attack_id || null, + direction: 'outbound', + }); + } + + logger.debug( + `Built ${dataComponentRefs.length} outbound embedded relationship(s) for analytic ${data.workspace.attack_id}`, + ); + } + } + + /** + * Lifecycle hook: Handle post-creation side effects + * Emits domain event to notify DataComponentsService that data components were referenced + * + * @param {Object} createdDocument - The created analytic document + * @param {Object} [options] - Creation options forwarded from BaseService. + * Threaded into the event payload so listeners can honor the + * import-fidelity contract (no stix mutations when `options.import`). + * See app/lib/import-safety.js. + * @returns {Promise} + */ + async afterCreate(createdDocument, options) { + // Extract data component IDs from x_mitre_log_source_references + const dataComponentRefs = + createdDocument.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || []; + + if (dataComponentRefs.length > 0) { + logger.info( + `AnalyticsService: Emitting data-components-referenced event for ${dataComponentRefs.length} data component(s)`, + { stixId: createdDocument.stix.id, dataComponentIds: dataComponentRefs }, + ); + + await EventBus.emit('x-mitre-analytic::data-components-referenced', { + analyticId: createdDocument.stix.id, + analytic: createdDocument.toObject ? createdDocument.toObject() : createdDocument, + dataComponentIds: dataComponentRefs, + options, + }); + } + } + + /** + * Lifecycle hook: Prepare analytic data before update persistence + * - Rebuilds outbound embedded_relationships for data components + * - Preserves inbound relationships from detection strategies + * - Validates that all referenced data components exist + * - Detects changes in data component references for event emission + * - Updates external_references if parent detection strategy changes + * + * @param {string} stixId - STIX ID of the analytic being updated + * @param {string} stixModified - Modified timestamp of the version being updated + * @param {Object} data - Updated analytic data + * @param {Object} existingDocument - The existing analytic document + * @throws {Exceptions.NotFoundError} If a referenced data component does not exist + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(stixId, stixModified, data, existingDocument, options) { + // Initialize embedded_relationships if not present + if (!data.workspace) { + data.workspace = {}; + } + if (!data.workspace.embedded_relationships) { + data.workspace.embedded_relationships = []; + } + + // Validate that all referenced data components exist and build outbound relationships + const newDataComponentRefs = + data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || []; + + // Preserve existing non-data-component embedded relationships (e.g., inbound from detection strategies) + const existingNonDataComponentRels = (data.workspace.embedded_relationships || []).filter( + (rel) => !rel.stix_id?.startsWith('x-mitre-data-component--'), + ); + + // Build new outbound embedded relationships for data components + const dataComponentEmbeddedRels = []; + if (newDataComponentRefs.length > 0) { + for (const dataComponentId of newDataComponentRefs) { + const dataComponent = + await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); + if (!dataComponent) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-component', + objectId: dataComponentId, + message: `Cannot update analytic: Referenced data component ${dataComponentId} does not exist`, + }); + } + + // Add outbound embedded_relationship + dataComponentEmbeddedRels.push({ + stix_id: dataComponentId, + attack_id: dataComponent.workspace?.attack_id || null, + direction: 'outbound', + }); + } + } + + // Rebuild embedded_relationships: preserve inbound relationships, rebuild outbound data component relationships + data.workspace.embedded_relationships = [ + ...existingNonDataComponentRels, + ...dataComponentEmbeddedRels, + ]; + + // Detect changes in data component references for event emission + const oldDataComponentRefs = + existingDocument.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || []; + + this._addedDataComponentRefs = newDataComponentRefs.filter( + (ref) => !oldDataComponentRefs.includes(ref), + ); + this._removedDataComponentRefs = oldDataComponentRefs.filter( + (ref) => !newDataComponentRefs.includes(ref), + ); + + // Check if embedded_relationships changed (specifically inbound detection strategy relationships) + const oldEmbeddedRels = existingDocument.workspace?.embedded_relationships || []; + const newEmbeddedRels = data.workspace?.embedded_relationships || []; + + const oldParentStrategy = oldEmbeddedRels.find( + (rel) => + rel.direction === 'inbound' && rel.stix_id?.startsWith('x-mitre-detection-strategy--'), + ); + const newParentStrategy = newEmbeddedRels.find( + (rel) => + rel.direction === 'inbound' && rel.stix_id?.startsWith('x-mitre-detection-strategy--'), + ); + + const parentStrategyChanged = + oldParentStrategy?.stix_id !== newParentStrategy?.stix_id || + oldParentStrategy?.attack_id !== newParentStrategy?.attack_id; + + // If parent detection strategy changed, rebuild the ATT&CK external reference + if (parentStrategyChanged && data.stix?.external_references) { + // Remove existing ATT&CK external references + data.stix.external_references = removeAttackExternalReferences(data.stix.external_references); + + // Create new ATT&CK external reference with updated URL + const attackRef = createAttackExternalReference({ + workspace: data.workspace, + stix: data.stix, + }); + + if (attackRef) { + data.stix.external_references.unshift(attackRef); + } + } + } + + /** + * Lifecycle hook: Handle post-update side effects + * Emits domain events for added/removed data component references + * + * @param {Object} updatedDocument - The updated analytic document + * @param {Object} _previousDocument - The previous version of the analytic (unused) + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async afterUpdate(updatedDocument, _previousDocument) { + const addedRefs = this._addedDataComponentRefs || []; + const removedRefs = this._removedDataComponentRefs || []; + + // Emit event for newly referenced data components + if (addedRefs.length > 0) { + logger.info( + `AnalyticsService: Emitting data-components-referenced event for ${addedRefs.length} added data component(s)`, + { stixId: updatedDocument.stix.id, dataComponentIds: addedRefs }, + ); + + await EventBus.emit('x-mitre-analytic::data-components-referenced', { + analyticId: updatedDocument.stix.id, + analytic: updatedDocument.toObject ? updatedDocument.toObject() : updatedDocument, + dataComponentIds: addedRefs, + }); + } + + // Emit event for removed data components + if (removedRefs.length > 0) { + logger.info( + `AnalyticsService: Emitting data-components-removed event for ${removedRefs.length} removed data component(s)`, + { stixId: updatedDocument.stix.id, dataComponentIds: removedRefs }, + ); + + await EventBus.emit('x-mitre-analytic::data-components-removed', { + analyticId: updatedDocument.stix.id, + dataComponentIds: removedRefs, + }); + } + + // Clean up instance variables + delete this._addedDataComponentRefs; + delete this._removedDataComponentRefs; + } + + /** + * Retrieve all analytics with optional filtering and pagination + * Strips embedded_relationships from response unless explicitly requested + * When embedded_relationships are included, populates names for detection strategies + * + * @param {Object} options - Query options + * @param {boolean} [options.includeEmbeddedRelationships=false] - Include embedded relationships in response + * @param {boolean} [options.includePagination=false] - Include pagination metadata + * @returns {Promise} Array of analytics or paginated result object + */ + async retrieveAll(options) { + const results = await super.retrieveAll(options); + + if (options.includeEmbeddedRelationships) { + // Populate names for embedded relationships + const analytics = options.includePagination ? results.data : results; + await this.populateEmbeddedRelationshipNames(analytics); + } else { + // Strip embedded_relationships from response + if (options.includePagination) { + await this.stripEmbeddedRelationships(results.data); + } else { + await this.stripEmbeddedRelationships(results); + } + } + + return results; + } + + /** + * Retrieve analytics by STIX ID + * Strips embedded_relationships from response unless explicitly requested + * When embedded_relationships are included, populates names for detection strategies + * + * @param {string} stixId - The STIX ID of the analytic + * @param {Object} options - Query options + * @param {boolean} [options.includeEmbeddedRelationships=false] - Include embedded relationships in response + * @returns {Promise} Array of analytic versions + */ + async retrieveById(stixId, options) { + const results = await super.retrieveById(stixId, options); + + if (options.includeEmbeddedRelationships) { + // Populate names for embedded relationships + await this.populateEmbeddedRelationshipNames(results); + } else { + // Strip embedded_relationships from response + await this.stripEmbeddedRelationships(results); + } + + return results; + } + + /** + * Populate names for embedded relationships by fetching referenced documents + * This is needed because names are no longer stored in embedded_relationships (only stix_id + attack_id) + * Handles both inbound detection strategy relationships and outbound data component relationships + * + * @param {Array} analytics - Array of analytic documents + * @returns {Promise} + */ + async populateEmbeddedRelationshipNames(analytics) { + const detectionStrategiesRepository = require('../../repository/detection-strategies-repository'); + const dataComponentsRepository = require('../../repository/data-components-repository'); + + for (const analytic of analytics) { + if (!analytic.workspace?.embedded_relationships) { + continue; + } + + for (const rel of analytic.workspace.embedded_relationships) { + // Handle inbound relationships from detection strategies + if ( + rel.direction === 'inbound' && + rel.stix_id?.startsWith('x-mitre-detection-strategy--') + ) { + try { + const detectionStrategy = await detectionStrategiesRepository.retrieveLatestByStixId( + rel.stix_id, + ); + if (detectionStrategy) { + // Add name as a transient property (not persisted to DB) + rel.name = detectionStrategy.stix.name; + } else { + logger.warn( + `AnalyticsService: Could not find detection strategy ${rel.stix_id} to populate name`, + ); + rel.name = null; + } + } catch (error) { + logger.error( + `AnalyticsService: Error fetching detection strategy ${rel.stix_id} for name:`, + error, + ); + rel.name = null; + } + } + + // Handle outbound relationships to data components + if (rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-data-component--')) { + try { + const dataComponent = await dataComponentsRepository.retrieveLatestByStixId( + rel.stix_id, + ); + if (dataComponent) { + // Add name as a transient property (not persisted to DB) + rel.name = dataComponent.stix.name; + } else { + logger.warn( + `AnalyticsService: Could not find data component ${rel.stix_id} to populate name`, + ); + rel.name = null; + } + } catch (error) { + logger.error( + `AnalyticsService: Error fetching data component ${rel.stix_id} for name:`, + error, + ); + rel.name = null; + } + } + } + } + } + + /** + * Remove embedded_relationships from analytics response + * Used to hide internal relationship metadata from API consumers + * + * @param {Array} analytics - Array of analytic documents + * @returns {Promise} + */ + async stripEmbeddedRelationships(analytics) { + for (const analytic of analytics) { + if (analytic.workspace) { + // For Mongoose documents, we need to set to undefined to trigger proper deletion + analytic.workspace.embedded_relationships = undefined; + } + } + } +} + +AnalyticsService.initializeEventListeners(); + +module.exports = new AnalyticsService(AnalyticType, analyticsRepository); diff --git a/app/services/stix/assets-service.js b/app/services/stix/assets-service.js new file mode 100644 index 00000000..2eebe79a --- /dev/null +++ b/app/services/stix/assets-service.js @@ -0,0 +1,9 @@ +'use strict'; + +const assetsRepository = require('../../repository/assets-repository'); +const { BaseService } = require('../meta-classes'); +const { Asset: AssetType } = require('../../lib/types'); + +class AssetsService extends BaseService {} + +module.exports = new AssetsService(AssetType, assetsRepository); diff --git a/app/services/attack-objects-service.js b/app/services/stix/attack-objects-service.js similarity index 67% rename from app/services/attack-objects-service.js rename to app/services/stix/attack-objects-service.js index a2df0ccc..9809b635 100644 --- a/app/services/attack-objects-service.js +++ b/app/services/stix/attack-objects-service.js @@ -1,11 +1,11 @@ 'use strict'; -const attackObjectsRepository = require('../repository/attack-objects-repository'); -const BaseService = require('./_base.service'); +const attackObjectsRepository = require('../../repository/attack-objects-repository'); +const { BaseService } = require('../meta-classes'); const identitiesService = require('./identities-service'); const relationshipsService = require('./relationships-service'); -const logger = require('../lib/logger'); -const { NotImplementedError, DatabaseError } = require('../exceptions'); +const logger = require('../../lib/logger'); +const { NotImplementedError, DatabaseError } = require('../../exceptions'); class AttackObjectsService extends BaseService { /** @@ -187,6 +187,86 @@ class AttackObjectsService extends BaseService { } } + /** + * Initialize event listeners for organization identity propagation. + */ + static initializeEventListeners() { + const EventBus = require('../../lib/event-bus'); + const Events = require('../../lib/event-constants'); + + EventBus.on( + Events.SYSTEM_CONFIGURATION_IDENTITY_CHANGED, + AttackObjectsService.handleOrganizationIdentityChanged, + ); + + logger.info('AttackObjectsService: Event listeners initialized'); + } + + /** + * Handle organization identity changes by creating new versions of affected objects. + * Objects are updated based on field-specific provenance: + * - created_by_ref is updated only if it matches an identity in the provenance chain + * - x_mitre_modified_by_ref is updated only if it matches an identity in the provenance chain + * @param {Object} payload + * @param {string} payload.previousIdentityRef + * @param {string} payload.newIdentityRef + * @param {string[]} payload.organizationIdentityHistory + */ + static async handleOrganizationIdentityChanged(payload) { + const { previousIdentityRef, newIdentityRef, organizationIdentityHistory } = payload; + + // Skip propagation on first-time setup (no previous identity to propagate from) + // or if required payload fields are missing. + if (!previousIdentityRef || !organizationIdentityHistory || !newIdentityRef) { + return; + } + + const objects = await attackObjectsRepository.retrieveAllLatestByOrgIdentityRefs( + organizationIdentityHistory, + ); + + logger.info( + `AttackObjectsService: Creating new versions for ${objects.length} object(s) due to organization identity change`, + { newIdentityRef }, + ); + + for (const obj of objects) { + try { + const createdByInHistory = organizationIdentityHistory.includes(obj.stix.created_by_ref); + const modifiedByInHistory = organizationIdentityHistory.includes( + obj.stix.x_mitre_modified_by_ref, + ); + + const newVersion = { + workspace: obj.workspace, + stix: { + ...obj.stix, + modified: new Date().toISOString(), + }, + }; + + if (createdByInHistory) { + newVersion.stix.created_by_ref = newIdentityRef; + } + if (modifiedByInHistory) { + newVersion.stix.x_mitre_modified_by_ref = newIdentityRef; + } + + await attackObjectsRepository.save(newVersion); + + logger.info( + `AttackObjectsService: Created new version of ${obj.stix.id} with updated identity refs`, + { + createdByUpdated: createdByInHistory, + modifiedByUpdated: modifiedByInHistory, + }, + ); + } catch (error) { + logger.error(`AttackObjectsService: Error creating new version of ${obj.stix?.id}:`, error); + } + } + } + async retrieveOneByVersionLean(stixId, stixModified) { try { return await this.repository.retrieveOneByVersionLean(stixId, stixModified); @@ -198,5 +278,8 @@ class AttackObjectsService extends BaseService { module.exports.AttackObjectsService = AttackObjectsService; +// Initialize event listeners for identity propagation +AttackObjectsService.initializeEventListeners(); + // Export an instance of the service module.exports = new AttackObjectsService(null, attackObjectsRepository); diff --git a/app/services/stix/campaigns-service.js b/app/services/stix/campaigns-service.js new file mode 100644 index 00000000..97d0915d --- /dev/null +++ b/app/services/stix/campaigns-service.js @@ -0,0 +1,62 @@ +'use strict'; + +const campaignsRepository = require('../../repository/campaigns-repository'); +const { BaseService } = require('../meta-classes'); +const { Campaign: CampaignType } = require('../../lib/types'); + +class CampaignService extends BaseService { + /** + * Ensure aliases[0] is always the object's own name. + * + * - If no aliases exist, sets aliases to [name]. + * - If aliases exist, removes any prior occurrence of the current (and optionally + * previous) name, then prepends the current name at index 0. + * + * @param {Object} data - The campaign object data (mutated in place) + * @param {string|null} [previousName=null] - The old name to strip on rename + */ + _normalizeAliases(data, previousName = null) { + const name = data.stix?.name; + if (!name) return; + + let aliases = Array.isArray(data.stix.aliases) ? data.stix.aliases : []; + + // Remove current name (avoid duplicate) and previous name (if renamed) + aliases = aliases.filter( + (alias) => alias !== name && (previousName === null || alias !== previousName), + ); + + aliases.unshift(name); + data.stix.aliases = aliases; + } + + /** + * Ensures aliases[0] matches the object name + * + * @param {Object} data - The campaign object data + * @param {Object} [options] - Creation options + */ + async beforeCreate(data, options) { + // Import-fidelity contract: skip the alias normalization on the import + // path. The normalization rewrites `stix.aliases` (rearranging entries + // and prepending `stix.name`), which is correct for user-driven flows + // but deviates the persisted analytic from the bundle source-of-truth. + // `data.stix` is frozen during import-mode hooks (app/lib/import-safety.js), + // so a missing gate here would throw a TypeError at the assignment in + // `_normalizeAliases`. + if (options?.import) return; + this._normalizeAliases(data); + } + + /** + * Ensure aliases stays in sync on update. + * If the name changed, the old name alias is replaced by the new one. + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(_stixId, _stixModified, data, existingDocument, _options) { + const previousName = existingDocument?.stix?.name ?? null; + this._normalizeAliases(data, previousName); + } +} + +module.exports = new CampaignService(CampaignType, campaignsRepository); diff --git a/app/services/collection-bundles-service/bundle-helpers.js b/app/services/stix/collection-bundles-service/bundle-helpers.js similarity index 98% rename from app/services/collection-bundles-service/bundle-helpers.js rename to app/services/stix/collection-bundles-service/bundle-helpers.js index 25298209..adaaa353 100644 --- a/app/services/collection-bundles-service/bundle-helpers.js +++ b/app/services/stix/collection-bundles-service/bundle-helpers.js @@ -23,6 +23,7 @@ module.exports.importErrors = { notInContents: 'Not in contents', // object in bundle but not in x_mitre_contents missingObject: 'Missing object', // object in x_mitre_contents but not in bundle saveError: 'Save error', + validationError: 'Validation error', attackSpecVersionViolation: 'ATT&CK Spec version violation', }; diff --git a/app/services/collection-bundles-service/export-bundle.js b/app/services/stix/collection-bundles-service/export-bundle.js similarity index 96% rename from app/services/collection-bundles-service/export-bundle.js rename to app/services/stix/collection-bundles-service/export-bundle.js index 81fb5312..270fd19b 100644 --- a/app/services/collection-bundles-service/export-bundle.js +++ b/app/services/stix/collection-bundles-service/export-bundle.js @@ -2,11 +2,11 @@ const uuid = require('uuid'); -const systemConfigurationService = require('../../services/system-configuration-service'); -const collectionsService = require('../../services/collections-service'); -const notesService = require('../../services/notes-service'); -const linkById = require('../../lib/linkById'); -const logger = require('../../lib/logger'); +const systemConfigurationService = require('../../system/system-configuration-service'); +const collectionsService = require('../collections-service'); +const notesService = require('../../system/notes-service'); +const linkById = require('../../../lib/linkById'); +const logger = require('../../../lib/logger'); const { errors } = require('./bundle-helpers'); diff --git a/app/services/stix/collection-bundles-service/import-bundle.js b/app/services/stix/collection-bundles-service/import-bundle.js new file mode 100644 index 00000000..60924e63 --- /dev/null +++ b/app/services/stix/collection-bundles-service/import-bundle.js @@ -0,0 +1,785 @@ +'use strict'; + +const semver = require('semver'); + +const { + errors, + importErrors, + forceImportParameters, + makeKey, + makeKeyFromObject, + defaultAttackSpecVersion, + toEpoch, +} = require('./bundle-helpers'); + +const logger = require('../../../lib/logger'); +const config = require('../../../config/config'); +const types = require('../../../lib/types'); +const { deepFreezeStix } = require('../../../lib/import-safety'); + +// Bounded concurrency for the compose-and-validate phase. Each task runs Zod +// validation and a small amount of synchronous work, so we cap concurrency +// to avoid pinning the event loop on extremely large bundles. +const COMPOSE_CONCURRENCY = 25; + +/** + * Run `task` against every item in `items` with at most `limit` in flight. + * Inline replacement for p-limit so we don't pull a new dependency (and + * avoid the ESM-only issue in recent p-limit versions). + */ +async function runWithConcurrency(items, limit, task) { + let next = 0; + async function worker() { + while (true) { + const i = next++; + if (i >= items.length) return; + await task(items[i], i); + } + } + const workerCount = Math.min(limit, items.length); + const workers = []; + for (let i = 0; i < workerCount; i++) workers.push(worker()); + await Promise.all(workers); +} + +const collectionsService = require('../collections-service'); +const referencesService = require('../../system/references-service'); + +const Collection = require('../../../models/collection-model'); + +// Service mapping object using the type constants +const serviceMap = { + [types.Technique]: require('../techniques-service'), + [types.Tactic]: require('../tactics-service'), + [types.Group]: require('../groups-service'), + [types.Campaign]: require('../campaigns-service'), + [types.Mitigation]: require('../mitigations-service'), + [types.Matrix]: require('../matrices-service'), + [types.Relationship]: require('../relationships-service'), + [types.MarkingDefinition]: require('../marking-definitions-service'), + [types.Identity]: require('../identities-service'), + [types.Note]: require('../../system/notes-service'), + [types.DataSource]: require('../data-sources-service'), + [types.DataComponent]: require('../data-components-service'), + [types.Asset]: require('../assets-service'), + [types.Analytic]: require('../analytics-service'), + [types.DetectionStrategy]: require('../detection-strategies-service'), +}; + +// Handle special cases that share a service +const softwareTypes = [types.Malware, types.Tool]; +softwareTypes.forEach((type) => { + serviceMap[type] = require('../software-service'); +}); + +/** + * Maps STIX object types to their corresponding services + * @param {string} type - STIX object type + * @returns {Object|null} Service for the given type or null if not found + */ +const getServiceForType = (type) => serviceMap[type] || null; + +/** + * Checks if a STIX object is a duplicate of existing objects + * @param {Object} importObject - Object being imported + * @param {Array} existingObjects - Array of existing objects + * @returns {boolean} True if object is a duplicate + */ +function checkForDuplicate(importObject, existingObjects) { + if (importObject.type === 'marking-definition') { + return existingObjects.some( + (object) => toEpoch(object.stix.created) === toEpoch(importObject.created), + ); + } + return existingObjects.some( + (object) => toEpoch(object.stix.modified) === toEpoch(importObject.modified), + ); +} + +/** + * Categorizes an object as addition, change, revocation, etc. + * @param {Object} importObject - Object being imported + * @param {Array} existingObjects - Array of existing objects + * @param {Object} importedCollection - Collection being imported + */ +function categorizeObject(importObject, existingObjects, importedCollection) { + if (existingObjects.length === 0) { + importedCollection.workspace.import_categories.additions.push(importObject.id); + return; + } + + const latestExistingObject = existingObjects[0]; + + if (importObject.revoked && !latestExistingObject.stix.revoked) { + importedCollection.workspace.import_categories.revocations.push(importObject.id); + } else if (importObject.x_mitre_deprecated && !latestExistingObject.stix.x_mitre_deprecated) { + importedCollection.workspace.import_categories.deprecations.push(importObject.id); + } else if (toEpoch(latestExistingObject.stix.modified) < toEpoch(importObject.modified)) { + if (latestExistingObject.stix.x_mitre_version < importObject.x_mitre_version) { + importedCollection.workspace.import_categories.changes.push(importObject.id); + } else if (latestExistingObject.stix.x_mitre_version === importObject.x_mitre_version) { + importedCollection.workspace.import_categories.minor_changes.push(importObject.id); + } + } else { + importedCollection.workspace.import_categories.out_of_date.push(importObject.id); + } +} + +/** + * Processes external references from a STIX object + * @param {Object} importObject - Object being imported + * @param {Map} importReferences - Map of references being imported + * @param {Object} referenceImportResults - Reference import statistics + */ +function processExternalReferences(importObject, importReferences, referenceImportResults) { + if (!importObject.external_references?.length) return; + + for (const externalReference of importObject.external_references) { + if ( + !externalReference.source_name || + !externalReference.description || + externalReference.external_id + ) { + continue; + } + + // Check if reference is an alias + const isAlias = checkIfAlias(importObject, externalReference.source_name); + if (isAlias) { + referenceImportResults.aliasReferences++; + continue; + } + + if (importReferences.has(externalReference.source_name)) { + referenceImportResults.duplicateReferences++; + } else { + referenceImportResults.uniqueReferences++; + importReferences.set(externalReference.source_name, externalReference); + } + } +} + +/** + * Checks if a source name is an alias for the object + * @param {Object} importObject - STIX object + * @param {string} sourceName - Source name to check + * @returns {boolean} True if source name is an alias + */ +function checkIfAlias(importObject, sourceName) { + if (importObject.type === 'intrusion-set') { + return importObject.aliases?.includes(sourceName); + } + if (importObject.type === 'malware' || importObject.type === 'tool') { + return importObject.x_mitre_aliases?.includes(sourceName); + } + return false; +} + +/** + * Records an unknown-object-type error against the imported collection. + * @param {Object} importObject - The unknown STIX object + * @param {Object} importedCollection - Collection being imported + */ +function recordUnknownTypeError(importObject, importedCollection) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.unknownObjectType, + error_message: `Unknown object type: ${importObject.type}`, + }; + logger.verbose( + `Import Bundle Error: Unknown object type. id=${importObject.id}, modified=${importObject.modified}, type=${importObject.type}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); +} + +/** + * Process one tier of same-type STIX objects: contents-map check, spec-version + * gate, bulk pre-fetch of existing versions, parallel compose-and-validate, + * then a single bulk insert. + * + * Tier-based grouping is sound because `sortObjectsByDependencies` returns a + * stable sort that keeps every type together — and types appear in dependency + * order (data-source before data-component, etc.). Each tier persists fully + * before the next tier begins. + * + * @param {string} type - STIX type for this tier + * @param {Array} objects - STIX objects of this type + * @param {Object} ctx - Shared import context + */ +async function processTier(type, objects, ctx) { + const { + options, + importedCollection, + contentsMap, + collectionReference, + importReferences, + referenceImportResults, + } = ctx; + + // Filter the tier: drain contents-map, gate on ATT&CK spec version, and + // record per-object errors. The result is the set of objects eligible for + // compose-and-insert. + const eligible = []; + for (const importObject of objects) { + // The contents-map check verifies that every imported object is also + // listed in the collection's `x_mitre_contents`. Two types are exempt + // because the export side (stix-bundles-service) deliberately omits them + // from `x_mitre_contents`: + // - `x-mitre-collection`: the collection is the container; it doesn't + // list itself. + // - `marking-definition`: marking-defs are referenced by the + // collection via `object_marking_refs` instead. Treating their + // absence from x_mitre_contents as a warning produces a false + // positive on every well-formed bundle (one per marking-def). + if ( + !contentsMap.delete(makeKeyFromObject(importObject)) && + importObject.type !== types.Collection && + importObject.type !== types.MarkingDefinition + ) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.notInContents, + error_message: + 'Warning: Object in bundle but not in x_mitre_contents. Object will be saved in database.', + }; + logger.verbose( + `Import Bundle Warning: Object not in x_mitre_contents. id=${importObject.id}, modified=${importObject.modified}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + } + + if (importObject.type !== 'marking-definition') { + const objectAttackSpecVersion = + importObject.x_mitre_attack_spec_version ?? defaultAttackSpecVersion; + if (semver.gt(objectAttackSpecVersion, config.app.attackSpecVersion)) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.attackSpecVersionViolation, + error_message: 'Error: Object x_mitre_attack_spec_version later than system.', + }; + logger.verbose( + `Import Bundle Error: Object's x_mitre_attack_spec_version later than system. id=${importObject.id}, modified=${importObject.modified}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + + if ( + !options.forceImportParameters?.includes( + forceImportParameters.attackSpecVersionViolations, + ) + ) { + throw new Error(errors.attackSpecVersionViolation); + } + continue; + } + } + eligible.push(importObject); + } + + const service = getServiceForType(type); + + // Unknown / unsupported types: record per-object errors but continue the import. + // Collection objects (the bundle itself) are deliberately skipped. + if (!service) { + if (type === types.Collection) return; + for (const importObject of eligible) { + recordUnknownTypeError(importObject, importedCollection); + } + return; + } + + // Pre-fetch every existing version of every stixId in this tier in ONE query. + // Replaces N calls to service.retrieveById from the old per-object loop. + let existingByStixId; + try { + const ids = eligible.map((o) => o.id); + existingByStixId = await service.repository.retrieveAllByStixIds(ids); + } catch (err) { + logger.error(err); + for (const importObject of eligible) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.retrievalError, + }; + logger.verbose( + `Import Bundle Error: Unable to retrieve objects with matching STIX id. id=${importObject.id}, modified=${importObject.modified}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + } + return; + } + + // Compose-and-validate in parallel with bounded concurrency. Each task runs + // the duplicate check, categorization, external-references collection, + // Zod validation via `composeForImport`, and the service's `beforeCreate` + // hook (which populates outbound `workspace.embedded_relationships` on the + // doc being saved). Composed docs are accumulated into a single array for + // bulk insert. + const composedToInsert = []; + const composeOptions = { + import: true, + validateContents: options.validateContents, + }; + + await runWithConcurrency(eligible, COMPOSE_CONCURRENCY, async (importObject) => { + const existing = existingByStixId.get(importObject.id) || []; + + if (checkForDuplicate(importObject, existing)) { + importedCollection.workspace.import_categories.duplicates.push(importObject.id); + return; + } + + categorizeObject(importObject, existing, importedCollection); + processExternalReferences(importObject, importReferences, referenceImportResults); + + if (options.previewOnly) return; + + const stagingDoc = { + workspace: { + collections: [collectionReference], + }, + stix: importObject, + }; + + try { + const { + data: composed, + throwIfValidating, + validationErrors, + } = await service.composeForImport(stagingDoc, composeOptions); + + // Strict-mode validation failure (`validateContents=true`). Surface the + // full ADM error list, not just the wrapper message — without `details` + // the caller has no way to act on the failure other than re-running the + // import with logs at debug level. Drop the doc from the bulk insert. + if (throwIfValidating) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.validationError, + error_message: `${validationErrors.length} ADM validation error(s)`, + details: validationErrors.map((e) => ({ + message: e.message, + path: e.path, + code: e.code, + })), + }; + logger.verbose( + `Import Bundle Error: Validation failed. id=${importObject.id}, ${throwIfValidating.message}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + return; + } + + // Fail-open validation failures (`validateContents=false`, the default). + // The object IS persisted with the error list attached to its own + // `workspace.validation`, but a clean import response would otherwise + // give the caller no signal that anything was wrong. We mirror the + // per-object errors into `import_categories.errors` so the response + // surfaces them up front. One taxonomy entry per object regardless of + // how many issues that object had — the full per-issue list lives in + // `details` so the caller can drill down without querying each doc. + if (validationErrors.length > 0) { + const firstFew = validationErrors + .slice(0, 3) + .map((e) => e.message) + .join('; '); + const summary = + validationErrors.length > 3 + ? `${firstFew}; ...and ${validationErrors.length - 3} more` + : firstFew; + importedCollection.workspace.import_categories.errors.push({ + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.validationError, + error_message: `${validationErrors.length} ADM validation error(s): ${summary}`, + details: validationErrors.map((e) => ({ + message: e.message, + path: e.path, + code: e.code, + })), + }); + } + + // Run the service's beforeCreate hook so outbound embedded_relationships + // and any other pre-persist data shaping are present on the doc when + // saveMany writes it. Failures here are recorded as save errors and the + // doc is dropped from the bulk insert. + // + // Import-fidelity guard: freeze stix before invoking the hook so any + // forgotten `if (!options.import)` gate inside the service crashes + // loudly with a TypeError instead of silently mutating bundle content. + // See app/lib/import-safety.js for the full contract. + deepFreezeStix(composed); + try { + await service.beforeCreate(composed, composeOptions); + } catch (hookErr) { + const importError = { + object_ref: importObject.id, + object_modified: importObject.modified, + error_type: importErrors.saveError, + error_message: hookErr.message, + }; + logger.verbose( + `Import Bundle Error: beforeCreate hook failed. id=${importObject.id}, ${hookErr.message}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + return; + } + + composedToInsert.push(composed); + } catch (err) { + 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 compose object. id=${importObject.id}, modified=${importObject.modified}, ${err.message}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + } + }); + + if (composedToInsert.length === 0) return; + + // Bulk insert. `saveMany` uses MongoDB `insertMany` with `ordered:false`, + // so individual document failures (e.g., duplicate-id races) are returned + // per-doc and folded into the import errors below — they don't abort the + // remaining inserts. + const { inserted, errors: insertErrors } = await service.repository.saveMany(composedToInsert); + for (const wErr of insertErrors) { + const failedDoc = typeof wErr.index === 'number' ? composedToInsert[wErr.index] : undefined; + const importError = { + object_ref: failedDoc?.stix?.id, + object_modified: failedDoc?.stix?.modified, + error_type: importErrors.saveError, + error_message: wErr.message, + }; + logger.verbose( + `Import Bundle Error: Unable to save object. id=${importError.object_ref}, modified=${importError.object_modified}, ${wErr.message}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + } + + // Post-insert lifecycle: run `afterCreate` and emit the `{type}::created` + // event for each successfully inserted doc. These fire cross-service domain + // events that maintain INBOUND `workspace.embedded_relationships` on + // referenced documents (e.g., DetectionStrategy → Analytic, Analytic → + // DataComponent, DataComponent → DataSource). Skipping them would leave + // the frontend unable to navigate inbound relationships. + // + // Run in parallel with bounded concurrency; per-doc hook failures are + // logged but never abort the import. + await runWithConcurrency(inserted, COMPOSE_CONCURRENCY, async (doc) => { + // Import-fidelity guard for the post-insert lifecycle. afterCreate and + // the listeners that subscribe to the emitted `{type}::created` event + // are allowed to populate workspace metadata on referenced documents + // but must not deviate this freshly saved document's stix from the + // bundle. Freezing forces violations to crash here rather than + // silently corrupting the imported content. See app/lib/import-safety.js. + deepFreezeStix(doc); + try { + await service.afterCreate(doc, composeOptions); + } catch (err) { + logger.warn(`Import Bundle: afterCreate failed for ${doc?.stix?.id}: ${err.message}`); + } + try { + await service.emitCreatedEvent(doc, composeOptions); + } catch (err) { + logger.warn(`Import Bundle: emitCreatedEvent failed for ${doc?.stix?.id}: ${err.message}`); + } + }); +} + +/** + * Sort objects to ensure dependencies are created before objects that reference them + * Dependency order: + * 1. Data sources must be created before data components + * 2. Data components must be created before analytics + * 3. Analytics must be created before detection strategies + * @param {Array} objects - Array of STIX objects to sort + * @returns {Array} Sorted array of STIX objects + */ +function sortObjectsByDependencies(objects) { + // Define dependency order (lower numbers are created first) + const typeOrder = { + [types.MarkingDefinition]: 0, + [types.Identity]: 1, + [types.DataSource]: 2, // Must come before data components + [types.DataComponent]: 3, // Must come before analytics + [types.Analytic]: 4, // Must come before detection strategies + [types.DetectionStrategy]: 5, + [types.Technique]: 6, + [types.Tactic]: 7, + [types.Mitigation]: 8, + [types.Group]: 9, + [types.Campaign]: 10, + [types.Malware]: 11, + [types.Tool]: 12, + [types.Asset]: 13, + [types.Matrix]: 14, + [types.Relationship]: 15, // Relationships last + [types.Note]: 16, + [types.Collection]: 17, + }; + + return objects.slice().sort((a, b) => { + const orderA = typeOrder[a.type] ?? 100; // Unknown types go last + const orderB = typeOrder[b.type] ?? 100; + return orderA - orderB; + }); +} + +/** + * Process all objects in the bundle, batched by STIX type in dependency order. + * + * Each type tier runs sequentially (so e.g. data-sources finish before + * data-components start), but objects within a tier are composed in parallel + * and persisted with a single bulk `insertMany`. This replaces the previous + * per-object sequential loop that did a DB read + DB write + lifecycle hooks + * + event emission per imported object — the dominant cost in large bundles. + * + * @param {Array} objects - Array of STIX objects to process + * @param {Object} options - Import options + * @param {Object} importedCollection - Collection being imported + * @param {Map} contentsMap - Map of objects in x_mitre_contents + * @param {Object} collectionReference - Reference to the collection + * @param {Map} importReferences - Map of references being imported + * @param {Object} referenceImportResults - Tracking of reference import stats + */ +async function processObjects( + objects, + options, + importedCollection, + contentsMap, + collectionReference, + importReferences, + referenceImportResults, +) { + const sortedObjects = sortObjectsByDependencies(objects); + + // Group consecutive same-type objects into tiers. The sort above places + // every type contiguously and in dependency order, so a single pass over + // the sorted list is enough. + const tiers = []; + let currentTier = null; + for (const obj of sortedObjects) { + if (!currentTier || currentTier.type !== obj.type) { + currentTier = { type: obj.type, objects: [] }; + tiers.push(currentTier); + } + currentTier.objects.push(obj); + } + + const ctx = { + options, + importedCollection, + contentsMap, + collectionReference, + importReferences, + referenceImportResults, + }; + + for (const tier of tiers) { + await processTier(tier.type, tier.objects, ctx); + } + + // Check for objects in x_mitre_contents but not in bundle + for (const entry of contentsMap.values()) { + const importError = { + object_ref: entry.object_ref, + object_modified: entry.object_modified, + error_type: importErrors.missingObject, + error_message: 'Object listed in x_mitre_contents, but not in bundle', + }; + logger.verbose( + `Import Bundle Error: Object in x_mitre_contents but not in bundle. id=${entry.object_ref}, modified=${entry.object_modified}`, + ); + importedCollection.workspace.import_categories.errors.push(importError); + } +} + +/** + * Import references found in the bundle + * @param {Map} importReferences - Map of references to import + * @param {Object} options - Import options + * @param {Object} importedCollection - Collection being imported + */ +async function importReferences(importReferences, options, importedCollection) { + const references = await referencesService.retrieveAll({}); + const existingReferences = new Map(references.map((item) => [item.source_name, item])); + + for (const importReference of importReferences.values()) { + if (existingReferences.has(importReference.source_name)) { + // Update existing reference + importedCollection.workspace.import_references.changes.push(importReference.source_name); + if (!options.previewOnly) { + await referencesService.update(importReference); + } + } else { + // Create new reference + importedCollection.workspace.import_references.additions.push(importReference.source_name); + if (!options.previewOnly) { + await referencesService.create(importReference); + } + } + } +} + +/** + * Save the collection after import + * @param {Object} importedCollection - Collection to save + * @param {Object} duplicateCollection - Existing duplicate collection if any + * @param {Object} options - Import options + * @returns {Promise} Saved collection + */ +async function saveCollection(importedCollection, duplicateCollection, options) { + if (duplicateCollection) { + // Add reimport results to existing collection + const reimport = { + imported: new Date().toISOString(), + import_categories: importedCollection.workspace.import_categories, + import_references: importedCollection.workspace.import_references, + }; + + if (!duplicateCollection.workspace.reimports) { + duplicateCollection.workspace.reimports = []; + } + duplicateCollection.workspace.reimports.push(reimport); + + if (!options.previewOnly) { + return Collection.findByIdAndUpdate(duplicateCollection._id, duplicateCollection, { + new: true, + lean: true, + }); + } + return importedCollection; + } + + // Create new collection + if (!options.previewOnly) { + try { + const result = await collectionsService.create(importedCollection, { + addObjectsToCollection: false, + import: true, + }); + return result.savedCollection; + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new Error(errors.duplicateCollection); + } + throw err; + } + } + return importedCollection; +} + +/** + * Checks for a duplicate collection + * @param {Object} importedCollection - Collection being imported + * @param {Object} options - Import options + * @returns {Promise} Duplicate collection if found + */ +async function checkDuplicateCollection(importedCollection, options) { + const collections = await collectionsService.retrieveById(importedCollection.stix.id, { + versions: 'all', + }); + + const duplicateCollection = collections.find( + (collection) => toEpoch(collection.stix.modified) === toEpoch(importedCollection.stix.modified), + ); + + if (duplicateCollection) { + if (options.forceImportParameters?.includes(forceImportParameters.duplicateCollection)) { + const importError = { + object_ref: importedCollection.stix.id, + object_modified: importedCollection.stix.modified, + error_type: importErrors.duplicateCollection, + error_message: 'Warning: Duplicate x-mitre-collection object.', + }; + logger.verbose( + 'Import Bundle Warning: Duplicate x-mitre-collection object. Continuing import due to forceImport parameter.', + ); + importedCollection.workspace.import_categories.errors.push(importError); + return duplicateCollection; + } + throw new Error(errors.duplicateCollection); + } + return null; +} + +/** + * Import a STIX bundle into the system + * @param {Object} collection - The collection to import + * @param {Object} data - The bundle data containing STIX objects + * @param {Object} options - Import options + * @returns {Promise} The imported collection + */ +module.exports = async function importBundle(collection, data, options) { + const referenceImportResults = { + uniqueReferences: 0, + duplicateReferences: 0, + aliasReferences: 0, + }; + + const collectionReference = { + collection_ref: collection.id, + collection_modified: collection.modified, + }; + + const importedCollection = { + workspace: { + imported: new Date().toISOString(), + exported: [], + import_categories: { + additions: [], + changes: [], + minor_changes: [], + revocations: [], + deprecations: [], + supersedes_user_edits: [], + supersedes_collection_changes: [], + duplicates: [], + out_of_date: [], + errors: [], + }, + import_references: { + additions: [], + changes: [], + duplicates: [], + }, + }, + stix: collection, + }; + + const contentsMap = new Map(); + for (const entry of collection.x_mitre_contents) { + contentsMap.set(makeKey(entry.object_ref, entry.object_modified), entry); + } + + const referenceMap = new Map(); + // Check for duplicate collection + const duplicateCollection = await checkDuplicateCollection(importedCollection, options); + + // Process all objects in bundle + await processObjects( + data.objects, + options, + importedCollection, + contentsMap, + collectionReference, + referenceMap, + referenceImportResults, + ); + + // Import references + await importReferences(referenceMap, options, importedCollection); + + // Save collection + return await saveCollection(importedCollection, duplicateCollection, options); +}; diff --git a/app/services/collection-bundles-service/index.js b/app/services/stix/collection-bundles-service/index.js similarity index 100% rename from app/services/collection-bundles-service/index.js rename to app/services/stix/collection-bundles-service/index.js diff --git a/app/services/collection-bundles-service/validate-bundle.js b/app/services/stix/collection-bundles-service/validate-bundle.js similarity index 97% rename from app/services/collection-bundles-service/validate-bundle.js rename to app/services/stix/collection-bundles-service/validate-bundle.js index 793fb360..d8cb83f7 100644 --- a/app/services/collection-bundles-service/validate-bundle.js +++ b/app/services/stix/collection-bundles-service/validate-bundle.js @@ -1,7 +1,7 @@ 'use strict'; const semver = require('semver'); -const config = require('../../config/config'); +const config = require('../../../config/config'); // Constants for validation error types const { validationErrors, defaultAttackSpecVersion, makeKey } = require('./bundle-helpers'); diff --git a/app/services/collection-indexes-service.js b/app/services/stix/collection-indexes-service.js similarity index 83% rename from app/services/collection-indexes-service.js rename to app/services/stix/collection-indexes-service.js index 01009adf..69f5637a 100644 --- a/app/services/collection-indexes-service.js +++ b/app/services/stix/collection-indexes-service.js @@ -1,9 +1,9 @@ 'use strict'; -const CollectionIndexesRepository = require('../repository/collection-indexes-repository'); -const BaseService = require('./_base.service'); -const { MissingParameterError, DatabaseError } = require('../exceptions'); -const config = require('../config/config'); +const CollectionIndexesRepository = require('../../repository/collection-indexes-repository'); +const { BaseService } = require('../meta-classes'); +const { MissingParameterError, DatabaseError } = require('../../exceptions'); +const config = require('../../config/config'); class CollectionIndexesService extends BaseService { async retrieveAll(options) { diff --git a/app/services/collections-service.js b/app/services/stix/collections-service.js similarity index 90% rename from app/services/collections-service.js rename to app/services/stix/collections-service.js index 16c60c6b..4551d14f 100644 --- a/app/services/collections-service.js +++ b/app/services/stix/collections-service.js @@ -1,8 +1,8 @@ 'use strict'; -const BaseService = require('./_base.service'); -const collectionRepository = require('../repository/collections-repository'); -const { Collection: CollectionType } = require('../lib/types'); +const { BaseService } = require('../meta-classes'); +const collectionRepository = require('../../repository/collections-repository'); +const { Collection: CollectionType } = require('../../lib/types'); const attackObjectsService = require('./attack-objects-service'); @@ -10,7 +10,7 @@ const { MissingParameterError, BadlyFormattedParameterError, InvalidQueryStringParameterError, -} = require('../exceptions'); +} = require('../../exceptions'); class CollectionsService extends BaseService { /** @@ -19,6 +19,11 @@ class CollectionsService extends BaseService { * @returns {Promise>} Array of attack objects in the same order as objectList */ async getContents(xMitreContents) { + // Handle empty or undefined contents + if (!xMitreContents || xMitreContents.length === 0) { + return []; + } + const objects = await attackObjectsService.getBulkByIdAndModified(xMitreContents); // Create lookup map for ordering @@ -48,10 +53,15 @@ class CollectionsService extends BaseService { const collection = await this.repository.retrieveLatestByStixId(stixId); if (collection) { + // Convert mongoose document to plain object for consistency with versions=all + // which uses .lean() and returns plain objects + const collectionObj = collection.toObject ? collection.toObject() : collection; + if (options.retrieveContents) { - collection.contents = await this.getContents(collection.stix.x_mitre_contents); + collectionObj.contents = await this.getContents(collectionObj.stix.x_mitre_contents); } - collections = [collection]; + + collections = [collectionObj]; } else { collections = []; } @@ -91,6 +101,11 @@ class CollectionsService extends BaseService { * @yields {Object} Attack objects in order with position metadata */ async *streamContents(xMitreContents) { + // Handle empty or undefined contents + if (!xMitreContents || xMitreContents.length === 0) { + return; + } + // Create a map to track original positions const positionMap = new Map(); xMitreContents.forEach((ref, index) => { @@ -248,7 +263,7 @@ class CollectionsService extends BaseService { const collections = await this.repository.retrieveOneByIdLean(stixId); if (!collections) { - throw new BadlyFormattedParameterError('stixId'); + throw new BadlyFormattedParameterError({ parameterName: 'stixId' }); } if (deleteAllContents) { @@ -272,7 +287,7 @@ class CollectionsService extends BaseService { const collection = await this.repository.retrieveOneByVersionLean(stixId, modified); if (!collection) { - throw new BadlyFormattedParameterError('stixId'); + throw new BadlyFormattedParameterError({ parameterName: 'stixId' }); } if (deleteAllContents) { diff --git a/app/services/stix/data-components-service.js b/app/services/stix/data-components-service.js new file mode 100644 index 00000000..7d2a454a --- /dev/null +++ b/app/services/stix/data-components-service.js @@ -0,0 +1,426 @@ +'use strict'; + +const dataComponentsRepository = require('../../repository/data-components-repository'); +const dataSourcesRepository = require('../../repository/data-sources-repository'); +const { BaseService } = require('../meta-classes'); +const { DataComponent: DataComponentType } = require('../../lib/types'); +const EventBus = require('../../lib/event-bus'); +const logger = require('../../lib/logger'); +const Exceptions = require('../../exceptions'); + +/** + * Service for managing data components + * + * Lifecycle hooks: + * - beforeCreate: Builds outbound embedded_relationship for x_mitre_data_source_ref and validates it exists + * - afterCreate: Emits domain event to notify DataSourcesService + * - beforeUpdate: Rebuilds outbound embedded_relationship, validates data source reference, and detects changes + * - afterUpdate: Emits domain events for added/removed data source references + * + * Event listeners: + * - x-mitre-analytic::data-components-referenced - Add inbound relationships when analytic references data components + * - x-mitre-analytic::data-components-removed - Remove inbound relationships when analytic removes data components + * + * Events emitted (listened to by DataSourcesService): + * - x-mitre-data-component::data-source-referenced + * - x-mitre-data-component::data-source-removed + */ +class DataComponentsService extends BaseService { + /** + * Initialize event listeners + * Called once on app startup + */ + static initializeEventListeners() { + EventBus.on( + 'x-mitre-analytic::data-components-referenced', + this.handleDataComponentsReferenced.bind(this), + ); + + EventBus.on( + 'x-mitre-analytic::data-components-removed', + this.handleDataComponentsRemoved.bind(this), + ); + + logger.info('DataComponentsService: Event listeners initialized'); + } + + /** + * Handle data components being referenced by an analytic + * Add inbound embedded_relationship + */ + static async handleDataComponentsReferenced(payload) { + const { analytic, dataComponentIds } = payload; + + logger.info( + `DataComponentsService heard event: 'x-mitre-analytic::data-components-referenced' for ${analytic.stix.id}`, + ); + + for (const dataComponentId of dataComponentIds) { + try { + const dataComponent = + await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); + + if (!dataComponent) { + logger.warn( + `DataComponentsService: Could not find data component ${dataComponentId} to add inbound relationship`, + ); + continue; + } + + // Initialize embedded_relationships if needed + if (!dataComponent.workspace) { + dataComponent.workspace = {}; + } + if (!dataComponent.workspace.embedded_relationships) { + dataComponent.workspace.embedded_relationships = []; + } + + // Check if relationship already exists + const exists = dataComponent.workspace.embedded_relationships.some( + (rel) => rel.stix_id === analytic.stix.id && rel.direction === 'inbound', + ); + + if (!exists) { + // Add inbound embedded_relationship + dataComponent.workspace.embedded_relationships.push({ + stix_id: analytic.stix.id, + attack_id: analytic.workspace?.attack_id || null, + direction: 'inbound', + }); + + logger.info( + `DataComponentsService: Added inbound relationship from analytic ${analytic.stix.id} to data component ${dataComponentId}`, + ); + } + + await dataComponentsRepository.saveDocument(dataComponent); + } catch (error) { + logger.error( + `DataComponentsService: Error handling data-components-referenced for ${dataComponentId}:`, + error, + ); + // Continue processing other data components + } + } + } + + /** + * Handle data components being removed from an analytic + * Remove inbound embedded_relationship + */ + static async handleDataComponentsRemoved(payload) { + const { analyticId, dataComponentIds } = payload; + + logger.info( + `DataComponentsService heard event: 'x-mitre-analytic::data-components-removed' for ${analyticId}`, + ); + + for (const dataComponentId of dataComponentIds) { + try { + const dataComponent = + await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); + + if (!dataComponent) { + logger.warn( + `DataComponentsService: Could not find data component ${dataComponentId} to remove inbound relationship`, + ); + continue; + } + + if (dataComponent.workspace?.embedded_relationships) { + // Remove inbound embedded_relationship + const initialLength = dataComponent.workspace.embedded_relationships.length; + dataComponent.workspace.embedded_relationships = + dataComponent.workspace.embedded_relationships.filter( + (rel) => !(rel.stix_id === analyticId && rel.direction === 'inbound'), + ); + + const removed = dataComponent.workspace.embedded_relationships.length < initialLength; + if (removed) { + logger.info( + `DataComponentsService: Removed inbound relationship from analytic ${analyticId} to data component ${dataComponentId}`, + ); + } + } + + await dataComponentsRepository.saveDocument(dataComponent); + } catch (error) { + logger.error( + `DataComponentsService: Error handling data-components-removed for ${dataComponentId}:`, + error, + ); + // Continue processing other data components + } + } + } + + /** + * Lifecycle hook: Prepare data component data before database persistence + * - Builds outbound embedded_relationship for data source reference + * - Validates that the referenced data source exists + * - Detects if this is a new version and tracks removed relationships + * + * @param {Object} data - The data component data to be created + * @param {Object} data.stix - STIX properties + * @param {string} data.stix.x_mitre_data_source_ref - Data source STIX ID reference + * @param {Object} data.workspace - Workbench metadata + * @param {Object} options - Creation options + * @throws {Exceptions.NotFoundError} If the referenced data source does not exist + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async beforeCreate(data, options) { + // Initialize embedded_relationships if not present + if (!data.workspace) { + data.workspace = {}; + } + if (!data.workspace.embedded_relationships) { + data.workspace.embedded_relationships = []; + } + + // Check if this is a new version of an existing data component + // (same stix.id, but creating a new version with different modified date) + let previousVersion = null; + if (data.stix?.id) { + try { + previousVersion = await dataComponentsRepository.retrieveLatestByStixId(data.stix.id); + } catch { + // It's okay if there's no previous version - this might be the first version + logger.debug(`No previous version found for data component ${data.stix.id}`); + } + } + + // Build outbound embedded_relationship for data source reference + // Cross-repository READS are allowed for denormalization (see EVENT_BUS_ARCHITECTURE.md) + const newDataSourceRef = data.stix?.x_mitre_data_source_ref; + const oldDataSourceRef = previousVersion?.stix?.x_mitre_data_source_ref; + + // Detect changes for event emission + if (previousVersion) { + if (oldDataSourceRef && !newDataSourceRef) { + // Data source reference was removed in this version + this._removedDataSourceRef = oldDataSourceRef; + } else if (!oldDataSourceRef && newDataSourceRef) { + // Data source reference was added in this version + this._addedDataSourceRef = newDataSourceRef; + } else if (oldDataSourceRef && newDataSourceRef && oldDataSourceRef !== newDataSourceRef) { + // Data source reference changed + this._removedDataSourceRef = oldDataSourceRef; + this._addedDataSourceRef = newDataSourceRef; + } + } + + if (newDataSourceRef) { + const dataSource = await dataSourcesRepository.retrieveLatestByStixId(newDataSourceRef); + if (!dataSource) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-source', + objectId: newDataSourceRef, + message: `Cannot create data component: Referenced data source ${newDataSourceRef} does not exist`, + }); + } + + // Add outbound embedded_relationship + data.workspace.embedded_relationships.push({ + stix_id: newDataSourceRef, + attack_id: dataSource.workspace?.attack_id || null, + direction: 'outbound', + }); + + logger.debug( + `Built outbound embedded relationship for data component to data source ${newDataSourceRef}`, + ); + } + } + + /** + * Lifecycle hook: Handle post-creation side effects + * Emits domain events to notify DataSourcesService about referenced/removed data sources + * This handles both first-time creation and new version creation (versioning) + * + * @param {Object} createdDocument - The created data component document + * @param {Object} [options] - Creation options forwarded from BaseService. + * Threaded into the event payload so listeners can honor the + * import-fidelity contract (no stix mutations when `options.import`). + * See app/lib/import-safety.js. + * @returns {Promise} + */ + async afterCreate(createdDocument, options) { + const addedRef = this._addedDataSourceRef; + const removedRef = this._removedDataSourceRef; + + // Emit event for newly referenced data source + if (addedRef) { + logger.info( + `DataComponentsService: Emitting data-source-referenced event for data source ${addedRef}`, + { stixId: createdDocument.stix.id, dataSourceId: addedRef }, + ); + + await EventBus.emit('x-mitre-data-component::data-source-referenced', { + dataComponentId: createdDocument.stix.id, + dataComponent: createdDocument.toObject ? createdDocument.toObject() : createdDocument, + dataSourceId: addedRef, + options, + }); + } + + // Emit event for removed data source (when creating a new version without the reference) + if (removedRef) { + logger.info( + `DataComponentsService: Emitting data-source-removed event for data source ${removedRef}`, + { stixId: createdDocument.stix.id, dataSourceId: removedRef }, + ); + + await EventBus.emit('x-mitre-data-component::data-source-removed', { + dataComponentId: createdDocument.stix.id, + dataSourceId: removedRef, + options, + }); + } + + // If no changes detected but there is a current reference, emit referenced event + // (this handles the case where this is the first version being created) + const currentDataSourceRef = createdDocument.stix?.x_mitre_data_source_ref; + if (!addedRef && !removedRef && currentDataSourceRef) { + logger.info( + `DataComponentsService: Emitting data-source-referenced event for data source ${currentDataSourceRef}`, + { stixId: createdDocument.stix.id, dataSourceId: currentDataSourceRef }, + ); + + await EventBus.emit('x-mitre-data-component::data-source-referenced', { + dataComponentId: createdDocument.stix.id, + dataComponent: createdDocument.toObject ? createdDocument.toObject() : createdDocument, + dataSourceId: currentDataSourceRef, + options, + }); + } + + // Clean up instance variables + delete this._addedDataSourceRef; + delete this._removedDataSourceRef; + } + + /** + * Lifecycle hook: Prepare data component data before update persistence + * - Rebuilds outbound embedded_relationship for data source + * - Preserves inbound relationships from analytics + * - Validates that the referenced data source exists + * - Detects changes in data source reference for event emission + * + * @param {string} stixId - STIX ID of the data component being updated + * @param {string} stixModified - Modified timestamp of the version being updated + * @param {Object} data - Updated data component data + * @param {Object} existingDocument - The existing data component document + * @throws {Exceptions.NotFoundError} If the referenced data source does not exist + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(stixId, stixModified, data, existingDocument, options) { + // Initialize embedded_relationships if not present + if (!data.workspace) { + data.workspace = {}; + } + if (!data.workspace.embedded_relationships) { + data.workspace.embedded_relationships = []; + } + + // Validate that the referenced data source exists and build outbound relationship + const newDataSourceRef = data.stix?.x_mitre_data_source_ref; + + // Preserve existing non-data-source embedded relationships (e.g., inbound from analytics) + const existingNonDataSourceRels = (data.workspace.embedded_relationships || []).filter( + (rel) => !rel.stix_id?.startsWith('x-mitre-data-source--'), + ); + + // Build new outbound embedded relationship for data source + const dataSourceEmbeddedRel = []; + if (newDataSourceRef) { + const dataSource = await dataSourcesRepository.retrieveLatestByStixId(newDataSourceRef); + if (!dataSource) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-source', + objectId: newDataSourceRef, + message: `Cannot update data component: Referenced data source ${newDataSourceRef} does not exist`, + }); + } + + // Add outbound embedded_relationship + dataSourceEmbeddedRel.push({ + stix_id: newDataSourceRef, + attack_id: dataSource.workspace?.attack_id || null, + direction: 'outbound', + }); + } + + // Rebuild embedded_relationships: preserve inbound relationships, rebuild outbound data source relationship + data.workspace.embedded_relationships = [ + ...existingNonDataSourceRels, + ...dataSourceEmbeddedRel, + ]; + + // Detect changes in data source reference for event emission + const oldDataSourceRef = existingDocument.stix?.x_mitre_data_source_ref; + + // Determine what changed + if (oldDataSourceRef && !newDataSourceRef) { + // Data source removed (set to null/undefined) + this._removedDataSourceRef = oldDataSourceRef; + } else if (!oldDataSourceRef && newDataSourceRef) { + // Data source added + this._addedDataSourceRef = newDataSourceRef; + } else if (oldDataSourceRef && newDataSourceRef && oldDataSourceRef !== newDataSourceRef) { + // Data source changed + this._removedDataSourceRef = oldDataSourceRef; + this._addedDataSourceRef = newDataSourceRef; + } + } + + /** + * Lifecycle hook: Handle post-update side effects + * Emits domain events for added/removed data source references + * + * @param {Object} updatedDocument - The updated data component document + * @param {Object} _previousDocument - The previous version of the data component (unused) + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async afterUpdate(updatedDocument, _previousDocument) { + const addedRef = this._addedDataSourceRef; + const removedRef = this._removedDataSourceRef; + + // Emit event for newly referenced data source + if (addedRef) { + logger.info( + `DataComponentsService: Emitting data-source-referenced event for data source ${addedRef}`, + { stixId: updatedDocument.stix.id, dataSourceId: addedRef }, + ); + + await EventBus.emit('x-mitre-data-component::data-source-referenced', { + dataComponentId: updatedDocument.stix.id, + dataComponent: updatedDocument.toObject ? updatedDocument.toObject() : updatedDocument, + dataSourceId: addedRef, + }); + } + + // Emit event for removed data source + if (removedRef) { + logger.info( + `DataComponentsService: Emitting data-source-removed event for data source ${removedRef}`, + { stixId: updatedDocument.stix.id, dataSourceId: removedRef }, + ); + + await EventBus.emit('x-mitre-data-component::data-source-removed', { + dataComponentId: updatedDocument.stix.id, + dataSourceId: removedRef, + }); + } + + // Clean up instance variables + delete this._addedDataSourceRef; + delete this._removedDataSourceRef; + } +} + +DataComponentsService.initializeEventListeners(); + +module.exports = new DataComponentsService(DataComponentType, dataComponentsRepository); diff --git a/app/services/stix/data-sources-service.js b/app/services/stix/data-sources-service.js new file mode 100644 index 00000000..df8a8d5c --- /dev/null +++ b/app/services/stix/data-sources-service.js @@ -0,0 +1,248 @@ +'use strict'; + +const dataSourcesRepository = require('../../repository/data-sources-repository'); +const dataComponentsRepository = require('../../repository/data-components-repository'); +const { BaseService } = require('../meta-classes'); +const { DataSource: DataSourceType } = require('../../lib/types'); +const EventBus = require('../../lib/event-bus'); +const logger = require('../../lib/logger'); + +/** + * Service for managing data sources + * + * Event listeners: + * - x-mitre-data-component::data-source-referenced - Add inbound relationships when data component references data source + * - x-mitre-data-component::data-source-removed - Remove inbound relationships when data component removes data source reference + * + * The retrieveDataComponents query parameter is handled by building the relationship + * from workspace.embedded_relationships which are maintained via the event-driven architecture. + */ +class DataSourcesService extends BaseService { + /** + * Initialize event listeners + * Called once on app startup + */ + static initializeEventListeners() { + EventBus.on( + 'x-mitre-data-component::data-source-referenced', + this.handleDataSourceReferenced.bind(this), + ); + + EventBus.on( + 'x-mitre-data-component::data-source-removed', + this.handleDataSourceRemoved.bind(this), + ); + + logger.info('DataSourcesService: Event listeners initialized'); + } + + /** + * Handle data source being referenced by a data component + * Add inbound embedded_relationship + * + * @param {Object} payload - Event payload + * @param {Object} payload.dataComponent - The data component document that references the data source + * @param {string} payload.dataSourceId - Data source STIX ID being referenced + * @returns {Promise} + */ + static async handleDataSourceReferenced(payload) { + const { dataComponent, dataSourceId } = payload; + + logger.info( + `DataSourcesService heard event: 'x-mitre-data-component::data-source-referenced' for ${dataComponent.stix.id}`, + ); + + try { + const dataSource = await dataSourcesRepository.retrieveLatestByStixId(dataSourceId); + + if (!dataSource) { + logger.warn( + `DataSourcesService: Could not find data source ${dataSourceId} to add inbound relationship`, + ); + return; + } + + // Initialize embedded_relationships if needed + if (!dataSource.workspace) { + dataSource.workspace = {}; + } + if (!dataSource.workspace.embedded_relationships) { + dataSource.workspace.embedded_relationships = []; + } + + // Check if relationship already exists + const exists = dataSource.workspace.embedded_relationships.some( + (rel) => rel.stix_id === dataComponent.stix.id && rel.direction === 'inbound', + ); + + if (!exists) { + // Add inbound embedded_relationship + dataSource.workspace.embedded_relationships.push({ + stix_id: dataComponent.stix.id, + attack_id: dataComponent.workspace?.attack_id || null, + direction: 'inbound', + }); + + logger.info( + `DataSourcesService: Added inbound relationship from data component ${dataComponent.stix.id} to data source ${dataSourceId}`, + ); + } + + await dataSourcesRepository.saveDocument(dataSource); + } catch (error) { + logger.error( + `DataSourcesService: Error handling data-source-referenced for ${dataSourceId}:`, + error, + ); + } + } + + /** + * Handle data source being removed from a data component + * Remove inbound embedded_relationship + * + * @param {Object} payload - Event payload + * @param {string} payload.dataComponentId - STIX ID of the data component + * @param {string} payload.dataSourceId - Data source STIX ID being removed + * @returns {Promise} + */ + static async handleDataSourceRemoved(payload) { + const { dataComponentId, dataSourceId } = payload; + + logger.info( + `DataSourcesService heard event: 'x-mitre-data-component::data-source-removed' for data component ${dataComponentId}`, + ); + + try { + const dataSource = await dataSourcesRepository.retrieveLatestByStixId(dataSourceId); + + if (!dataSource) { + logger.warn( + `DataSourcesService: Could not find data source ${dataSourceId} to remove inbound relationship`, + ); + return; + } + + if (dataSource.workspace?.embedded_relationships) { + // Remove inbound embedded_relationship + const initialLength = dataSource.workspace.embedded_relationships.length; + dataSource.workspace.embedded_relationships = + dataSource.workspace.embedded_relationships.filter( + (rel) => !(rel.stix_id === dataComponentId && rel.direction === 'inbound'), + ); + + const removed = dataSource.workspace.embedded_relationships.length < initialLength; + if (removed) { + logger.info( + `DataSourcesService: Removed inbound relationship from data component ${dataComponentId} to data source ${dataSourceId}`, + ); + } + } + + await dataSourcesRepository.saveDocument(dataSource); + } catch (error) { + logger.error( + `DataSourcesService: Error handling data-source-removed for ${dataSourceId}:`, + error, + ); + } + } + + /** + * Retrieve data sources by STIX ID + * If retrieveDataComponents is true, populate dataComponents array from embedded_relationships + * + * @param {string} stixId - The STIX ID of the data source + * @param {Object} options - Query options + * @param {string} [options.versions='latest'] - Which versions to retrieve + * @param {boolean} [options.retrieveDataComponents=false] - Include related data components + * @returns {Promise} Array of data source versions + */ + async retrieveById(stixId, options) { + const dataSources = await super.retrieveById(stixId, options); + + // If retrieveDataComponents is requested, build the dataComponents array from embedded_relationships + if (options.retrieveDataComponents && dataSources.length > 0) { + for (const dataSource of dataSources) { + await this.populateDataComponents(dataSource); + } + } + + return dataSources; + } + + /** + * Retrieve a specific version of a data source + * If retrieveDataComponents is true, populate dataComponents array from embedded_relationships + * + * @param {string} stixId - The STIX ID of the data source + * @param {string} modified - The modified timestamp of the version + * @param {Object} options - Query options + * @param {boolean} [options.retrieveDataComponents=false] - Include related data components + * @returns {Promise} The data source document or null + */ + async retrieveVersionById(stixId, modified, options) { + const dataSource = await super.retrieveVersionById(stixId, modified); + + // If retrieveDataComponents is requested, build the dataComponents array from embedded_relationships + if (options.retrieveDataComponents && dataSource) { + await this.populateDataComponents(dataSource); + } + + return dataSource; + } + + /** + * Populate the dataComponents array on a data source from its embedded_relationships + * This retrieves the full data component documents for each inbound relationship + * + * @param {Object} dataSource - The data source document + * @returns {Promise} + */ + async populateDataComponents(dataSource) { + // Get inbound data component relationships from embedded_relationships + const dataComponentRels = + dataSource.workspace?.embedded_relationships?.filter( + (rel) => rel.direction === 'inbound' && rel.stix_id?.startsWith('x-mitre-data-component--'), + ) || []; + + // Fetch the full data component documents + const dataComponents = []; + for (const rel of dataComponentRels) { + try { + const dataComponent = await dataComponentsRepository.retrieveLatestByStixId(rel.stix_id); + if (dataComponent) { + // Add identity information to data component + await this.addCreatedByAndModifiedByIdentities(dataComponent); + dataComponents.push(dataComponent.toObject ? dataComponent.toObject() : dataComponent); + } else { + logger.warn( + `DataSourcesService: Could not find data component ${rel.stix_id} referenced in embedded_relationships`, + ); + } + } catch (error) { + logger.error(`DataSourcesService: Error fetching data component ${rel.stix_id}:`, error); + } + } + + // Attach the populated dataComponents array to the data source + // This is a transient property, not persisted to the database + if (dataSource.toObject) { + // For Mongoose documents + const obj = dataSource.toObject(); + obj.dataComponents = dataComponents; + Object.assign(dataSource, obj); + } else { + // For plain objects + dataSource.dataComponents = dataComponents; + } + + logger.debug( + `Populated ${dataComponents.length} data component(s) for data source ${dataSource.stix.id}`, + ); + } +} + +DataSourcesService.initializeEventListeners(); + +module.exports = new DataSourcesService(DataSourceType, dataSourcesRepository); diff --git a/app/services/stix/detection-strategies-service.js b/app/services/stix/detection-strategies-service.js new file mode 100644 index 00000000..d8ddb92f --- /dev/null +++ b/app/services/stix/detection-strategies-service.js @@ -0,0 +1,276 @@ +'use strict'; + +const detectionStrategiesRepository = require('../../repository/detection-strategies-repository'); +const analyticsRepository = require('../../repository/analytics-repository'); +const { BaseService } = require('../meta-classes'); +const { DetectionStrategy: DetectionStrategyType } = require('../../lib/types'); +const logger = require('../../lib/logger'); +const EventBus = require('../../lib/event-bus'); +const { NotFoundError } = require('../../exceptions'); +const assertions = require('../../lib/assertions'); + +/** + * Service for managing detection strategies + * + * Lifecycle hooks: + * - beforeCreate: Builds outbound embedded_relationships for x_mitre_analytic_refs + * - afterCreate: Emits domain event to notify AnalyticsService + * - beforeUpdate: Updates outbound embedded_relationships when refs change + * - afterUpdate: Emits domain events for added/removed analytics + * + * Events emitted (listened to by AnalyticsService): + * - x-mitre-detection-strategy::analytics-referenced + * - x-mitre-detection-strategy::analytics-removed + */ +class DetectionStrategiesService extends BaseService { + /** + * Assertion: Verify x_mitre_analytic_refs contains only unique values + * (This should never actually throw in practice. We have (or will have) validation middleware + * that checks the request body before the service layer runs. That middleware is powered by + * the `@mitre-attack/attack-data-model` library which checks for this condition implicitly. + * This assertion thus serves as a fail-safe in case the middleware is ever somehow bypassed. + * It will throw, causing a 500 exception, but it will block "bad data" from entering the + * database.) + */ + async _assertAnalyticRefsAreUnique(data) { + assertions.assertUnique(data.stix?.x_mitre_analytic_refs, 'x_mitre_analytic_refs', { + stixId: data.stix?.id || 'unknown', + }); + } + + /** + * Prepare detection strategy data before creation + * Build outbound embedded_relationships for x_mitre_analytic_refs + * Detects if this is a new version and tracks removed relationships + */ + async beforeCreate(data) { + this._assertAnalyticRefsAreUnique(data); + + // Initialize workspace if not present + if (!data.workspace) { + data.workspace = {}; + } + + // Check if this is a new version of an existing detection strategy + // (same stix.id, but creating a new version with different modified date) + let previousVersion = null; + if (data.stix?.id) { + try { + previousVersion = await this.repository.retrieveLatestByStixId(data.stix.id); + } catch { + // It's okay if there's no previous version - this might be the first version + logger.debug(`No previous version found for detection strategy ${data.stix.id}`); + } + } + + // Build outbound embedded_relationships for x_mitre_analytic_refs + // Cross-repository READS are allowed for denormalization (see CROSS_SERVICE_READS_PATTERN.md) + // We emit events in afterCreate/afterUpdate for cross-service WRITES + const newAnalyticRefs = data.stix?.x_mitre_analytic_refs || []; + const oldAnalyticRefs = previousVersion?.stix?.x_mitre_analytic_refs || []; + + // Detect changes for event emission + if (previousVersion) { + this._addedAnalyticRefs = newAnalyticRefs.filter((ref) => !oldAnalyticRefs.includes(ref)); + this._removedAnalyticRefs = oldAnalyticRefs.filter((ref) => !newAnalyticRefs.includes(ref)); + } + + // Preserve non-analytic embedded_relationships from the previous version when POST is + // creating a new version. Client payloads often omit server-managed workspace metadata, + // so using the request body here would drop unrelated relationships on version creation. + const baselineEmbeddedRelationships = + previousVersion?.workspace?.embedded_relationships || + data.workspace.embedded_relationships || + []; + + // Rebuild only the analytic outbound relationships for the new version. + const existingNonAnalyticRels = baselineEmbeddedRelationships.filter( + (rel) => !rel.stix_id?.startsWith('x-mitre-analytic--'), + ); + + const analyticEmbeddedRels = []; + for (const analyticId of newAnalyticRefs) { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + + if (!analytic) { + logger.warn(`DetectionStrategiesService: Analytic ${analyticId} does not exist`); + throw new NotFoundError({ + analyticId: analyticId, + message: 'The detection strategy cannot reference an analytic that does not exist', + }); + } + + analyticEmbeddedRels.push({ + stix_id: analyticId, + attack_id: analytic?.workspace?.attack_id || null, + direction: 'outbound', + }); + } + + data.workspace.embedded_relationships = [...existingNonAnalyticRels, ...analyticEmbeddedRels]; + } + + /** + * Handle post-creation logic + * Emit domain events to notify AnalyticsService about referenced/removed analytics + * This handles both first-time creation and new version creation (versioning) + * + * @param {Object} document - The persisted detection strategy + * @param {Object} [options] - Create options forwarded from BaseService. + * Threaded into the event payload so listeners can honor the + * import-fidelity contract (no stix mutations when `options.import`). + * See app/lib/import-safety.js for the contract. + */ + async afterCreate(document, options) { + const addedRefs = this._addedAnalyticRefs || []; + const removedRefs = this._removedAnalyticRefs || []; + + // Emit event for newly referenced analytics + if (addedRefs.length > 0) { + logger.info( + `DetectionStrategiesService: Emitting analytics-referenced event for ${addedRefs.length} added analytic(s)`, + { stixId: document.stix.id, analyticIds: addedRefs }, + ); + + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: document.stix.id, + detectionStrategy: document.toObject ? document.toObject() : document, + analyticIds: addedRefs, + options, + }); + } + + // Emit event for removed analytics (when creating a new version without the analytics) + if (removedRefs.length > 0) { + logger.info( + `DetectionStrategiesService: Emitting analytics-removed event for ${removedRefs.length} removed analytic(s)`, + { stixId: document.stix.id, analyticIds: removedRefs }, + ); + + await EventBus.emit('x-mitre-detection-strategy::analytics-removed', { + detectionStrategyId: document.stix.id, + analyticIds: removedRefs, + options, + }); + } + + // If no changes detected but there are current analytics, emit referenced event + // (this handles the case where this is the first version being created) + const currentAnalyticRefs = document.stix?.x_mitre_analytic_refs || []; + if (!addedRefs.length && !removedRefs.length && currentAnalyticRefs.length > 0) { + logger.info( + `DetectionStrategiesService: Emitting analytics-referenced event for ${currentAnalyticRefs.length} analytic(s)`, + { stixId: document.stix.id, analyticIds: currentAnalyticRefs }, + ); + + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: document.stix.id, + detectionStrategy: document.toObject ? document.toObject() : document, + analyticIds: currentAnalyticRefs, + options, + }); + } + + // Clean up instance variables + delete this._addedAnalyticRefs; + delete this._removedAnalyticRefs; + } + + /** + * Prepare detection strategy data before update + * Detect changes in x_mitre_analytic_refs and update outbound embedded_relationships + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(stixId, stixModified, data, existingDocument, options) { + this._assertAnalyticRefsAreUnique(data); + + const oldAnalyticRefs = existingDocument.stix?.x_mitre_analytic_refs || []; + const newAnalyticRefs = data.stix?.x_mitre_analytic_refs || []; + + // Store change detection for afterUpdate + this._addedAnalyticRefs = newAnalyticRefs.filter((ref) => !oldAnalyticRefs.includes(ref)); + this._removedAnalyticRefs = oldAnalyticRefs.filter((ref) => !newAnalyticRefs.includes(ref)); + + // Update embedded_relationships in the data being saved + if (!data.workspace) { + data.workspace = {}; + } + if (!data.workspace.embedded_relationships) { + data.workspace.embedded_relationships = []; + } + + // Rebuild the analytic portion of embedded_relationships + const existingNonAnalyticRels = (data.workspace.embedded_relationships || []).filter( + (rel) => !rel.stix_id?.startsWith('x-mitre-analytic--'), + ); + + const analyticEmbeddedRels = []; + for (const analyticId of newAnalyticRefs) { + try { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + analyticEmbeddedRels.push({ + stix_id: analyticId, + attack_id: analytic?.workspace?.attack_id || null, + direction: 'outbound', + }); + } catch (error) { + logger.warn( + `DetectionStrategiesService: Could not fetch analytic ${analyticId} for outbound relationship`, + error, + ); + analyticEmbeddedRels.push({ + stix_id: analyticId, + attack_id: null, + direction: 'outbound', + }); + } + } + + data.workspace.embedded_relationships = [...existingNonAnalyticRels, ...analyticEmbeddedRels]; + } + + /** + * Handle post-update logic + * Emit domain events for analytics that were added or removed + */ + async afterUpdate(updatedDocument) { + const addedRefs = this._addedAnalyticRefs || []; + const removedRefs = this._removedAnalyticRefs || []; + + // Emit event for newly referenced analytics + if (addedRefs.length > 0) { + logger.info( + `DetectionStrategiesService: Emitting analytics-referenced event for ${addedRefs.length} added analytic(s)`, + { stixId: updatedDocument.stix.id, analyticIds: addedRefs }, + ); + + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: updatedDocument.stix.id, + detectionStrategy: updatedDocument.toObject ? updatedDocument.toObject() : updatedDocument, + analyticIds: addedRefs, + }); + } + + // Emit event for removed analytics + if (removedRefs.length > 0) { + logger.info( + `DetectionStrategiesService: Emitting analytics-removed event for ${removedRefs.length} removed analytic(s)`, + { stixId: updatedDocument.stix.id, analyticIds: removedRefs }, + ); + + await EventBus.emit('x-mitre-detection-strategy::analytics-removed', { + detectionStrategyId: updatedDocument.stix.id, + analyticIds: removedRefs, + }); + } + + // Clean up instance variables + delete this._addedAnalyticRefs; + delete this._removedAnalyticRefs; + } +} + +module.exports = new DetectionStrategiesService( + DetectionStrategyType, + detectionStrategiesRepository, +); diff --git a/app/services/stix/groups-service.js b/app/services/stix/groups-service.js new file mode 100644 index 00000000..552444d8 --- /dev/null +++ b/app/services/stix/groups-service.js @@ -0,0 +1,61 @@ +'use strict'; + +const { BaseService } = require('../meta-classes'); +const groupsRepository = require('../../repository/groups-repository'); +const { Group: GroupType } = require('../../lib/types'); + +class GroupsService extends BaseService { + /** + * Ensure aliases[0] is always the object's own name. + * + * - If no aliases exist, sets aliases to [name]. + * - If aliases exist, removes any prior occurrence of the current (and optionally + * previous) name, then prepends the current name at index 0. + * + * @param {Object} data - The group object data (mutated in place) + * @param {string|null} [previousName=null] - The old name to strip on rename + */ + _normalizeAliases(data, previousName = null) { + const name = data.stix?.name; + if (!name) return; + + let aliases = Array.isArray(data.stix.aliases) ? data.stix.aliases : []; + + // Remove current name (avoid duplicate) and previous name (if renamed) + aliases = aliases.filter( + (alias) => alias !== name && (previousName === null || alias !== previousName), + ); + + aliases.unshift(name); + data.stix.aliases = aliases; + } + + /** + * Ensures aliases[0] matches the object name + * + * @param {Object} data - The group object data + * @param {Object} [options] - Creation options + */ + async beforeCreate(data, options) { + // Import-fidelity contract: skip the alias normalization on the import + // path. The normalization rewrites `stix.aliases`, which is correct for + // user-driven flows but deviates the persisted group from the bundle + // source-of-truth. `data.stix` is frozen during import-mode hooks + // (app/lib/import-safety.js), so a missing gate here would throw a + // TypeError at the assignment in `_normalizeAliases`. + if (options?.import) return; + this._normalizeAliases(data); + } + + /** + * Ensure aliases stays in sync on update. + * If the name changed, the old name alias is replaced by the new one. + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(_stixId, _stixModified, data, existingDocument, _options) { + const previousName = existingDocument?.stix?.name ?? null; + this._normalizeAliases(data, previousName); + } +} + +module.exports = new GroupsService(GroupType, groupsRepository); diff --git a/app/services/identities-service.js b/app/services/stix/identities-service.js similarity index 82% rename from app/services/identities-service.js rename to app/services/stix/identities-service.js index a9df91a1..d2a02a00 100644 --- a/app/services/identities-service.js +++ b/app/services/stix/identities-service.js @@ -1,11 +1,11 @@ 'use strict'; const uuid = require('uuid'); -const config = require('../config/config'); -const identitiesRepository = require('../repository/identities-repository'); -const BaseService = require('./_base.service'); -const { InvalidTypeError } = require('../exceptions'); -const { Identity: IdentityType } = require('../lib/types'); +const config = require('../../config/config'); +const identitiesRepository = require('../../repository/identities-repository'); +const { BaseService } = require('../meta-classes'); +const { InvalidTypeError } = require('../../exceptions'); +const { Identity: IdentityType } = require('../../lib/types'); class IdentitiesService extends BaseService { /** diff --git a/app/services/marking-definitions-service.js b/app/services/stix/marking-definitions-service.js similarity index 86% rename from app/services/marking-definitions-service.js rename to app/services/stix/marking-definitions-service.js index be54a642..b6205d56 100644 --- a/app/services/marking-definitions-service.js +++ b/app/services/stix/marking-definitions-service.js @@ -1,16 +1,16 @@ 'use strict'; const uuid = require('uuid'); -const BaseService = require('./_base.service'); -const markingDefinitionsRepository = require('../repository/marking-definitions-repository'); -const { MarkingDefinition: MarkingDefinitionType } = require('../lib/types'); +const { BaseService } = require('../meta-classes'); +const markingDefinitionsRepository = require('../../repository/marking-definitions-repository'); +const { MarkingDefinition: MarkingDefinitionType } = require('../../lib/types'); const { MissingParameterError, BadlyFormattedParameterError, CannotUpdateStaticObjectError, DuplicateIdError, -} = require('../exceptions'); +} = require('../../exceptions'); // NOTE: A marking definition does not support the modified or revoked properties!! @@ -71,7 +71,14 @@ class MarkingDefinitionsService extends BaseService { throw new CannotUpdateStaticObjectError(); } - const newDoc = await super.updateFull(stixId, data); + // Marking definitions don't support versioning (no modified property) + // So we retrieve by stixId only and update directly + const document = await this.repository.retrieveOneById(stixId); + if (!document) { + return null; + } + + const newDoc = await this.repository.updateAndSave(document, data); return newDoc; } diff --git a/app/services/matrices-service.js b/app/services/stix/matrices-service.js similarity index 90% rename from app/services/matrices-service.js rename to app/services/stix/matrices-service.js index 5487f2e0..61024bd6 100644 --- a/app/services/matrices-service.js +++ b/app/services/stix/matrices-service.js @@ -1,11 +1,11 @@ 'use strict'; -const BaseService = require('./_base.service'); -const matrixRepository = require('../repository/matrix-repository'); -const { Matrix: MatrixType } = require('../lib/types'); +const { BaseService } = require('../meta-classes'); +const matrixRepository = require('../../repository/matrix-repository'); +const { Matrix: MatrixType } = require('../../lib/types'); const tacticsService = require('./tactics-service'); -const logger = require('../lib/logger'); -const { TacticsServiceError, MissingParameterError } = require('../exceptions'); +const logger = require('../../lib/logger'); +const { TacticsServiceError, MissingParameterError } = require('../../exceptions'); class MatrixService extends BaseService { constructor(type, repository) { diff --git a/app/services/stix/mitigations-service.js b/app/services/stix/mitigations-service.js new file mode 100644 index 00000000..dc1ce7de --- /dev/null +++ b/app/services/stix/mitigations-service.js @@ -0,0 +1,9 @@ +'use strict'; + +const mitigationsRepository = require('../../repository/mitigations-repository'); +const { BaseService } = require('../meta-classes'); +const { Mitigation: MitigationType } = require('../../lib/types'); + +class MitigationsService extends BaseService {} + +module.exports = new MitigationsService(MitigationType, mitigationsRepository); diff --git a/app/services/stix/relationships-service.js b/app/services/stix/relationships-service.js new file mode 100644 index 00000000..1d275186 --- /dev/null +++ b/app/services/stix/relationships-service.js @@ -0,0 +1,342 @@ +'use strict'; + +const { BaseService } = require('../meta-classes'); +const relationshipsRepository = require('../../repository/relationships-repository'); +const { Relationship: RelationshipType } = require('../../lib/types'); +const EventBus = require('../../lib/event-bus'); +const EventConstants = require('../../lib/event-constants'); +const logger = require('../../lib/logger'); + +// Map STIX types to ATT&CK types +const objectTypeMap = new Map([ + ['malware', 'software'], + ['tool', 'software'], + ['attack-pattern', 'technique'], + ['intrusion-set', 'group'], + ['campaign', 'campaign'], + ['x-mitre-asset', 'asset'], + ['course-of-action', 'mitigation'], + ['x-mitre-tactic', 'tactic'], + ['x-mitre-matrix', 'matrix'], + ['x-mitre-data-component', 'data-component'], + ['x-mitre-detection-strategy', 'detection-strategy'], +]); + +class RelationshipsService extends BaseService { + /** + * Initialize event listeners. + * Called once on module load. + */ + static initializeEventListeners() { + const revokedEvents = [ + EventConstants.ATTACK_PATTERN_REVOKED, + EventConstants.TACTIC_REVOKED, + EventConstants.COURSE_OF_ACTION_REVOKED, + EventConstants.INTRUSION_SET_REVOKED, + EventConstants.MALWARE_REVOKED, + EventConstants.TOOL_REVOKED, + EventConstants.CAMPAIGN_REVOKED, + EventConstants.DATA_SOURCE_REVOKED, + EventConstants.DATA_COMPONENT_REVOKED, + EventConstants.MATRIX_REVOKED, + EventConstants.ASSET_REVOKED, + ]; + + for (const event of revokedEvents) { + EventBus.on(event, this.handleObjectRevoked.bind(this)); + } + + EventBus.on( + EventConstants.TECHNIQUE_CONVERTED_TO_SUBTECHNIQUE, + this.handleTechniqueConvertedToSubtechnique.bind(this), + ); + + EventBus.on( + EventConstants.SUBTECHNIQUE_CONVERTED_TO_TECHNIQUE, + this.handleSubtechniqueConvertedToTechnique.bind(this), + ); + + logger.info('RelationshipsService: Event listeners initialized'); + } + + /** + * Create a subtechnique-of SRO when a technique is converted to a subtechnique. + * + * Uses the service instance's create() method (via module.exports singleton) + * so that the relationship gets a proper stix.id, server-controlled fields, + * and ADM validation — the same path as any user-created relationship. + * + * @param {Object} payload - Event payload + * @param {string} payload.stixId - STIX ID of the converted subtechnique + * @param {string} payload.parentStixId - STIX ID of the parent technique + * @param {string} [payload.userAccountId] - Authenticated user's account ID + */ + static async handleTechniqueConvertedToSubtechnique(payload) { + const { stixId, parentStixId, userAccountId } = payload; + + logger.info( + `RelationshipsService: Creating subtechnique-of relationship for ${stixId} -> ${parentStixId}`, + ); + + try { + // Use the singleton instance exported by this module + const relationshipsService = module.exports; + const now = new Date().toISOString(); + const createdRelationship = await relationshipsService.create( + { + workspace: { + workflow: { state: 'reviewed' }, // TODO introduce a new workflow state for entities that are never reviewed by users; for now, set to 'reviewed' to ensure they undergo full ADM validation + }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'subtechnique-of', + source_ref: stixId, + target_ref: parentStixId, + created: now, + modified: now, + }, + }, + { userAccountId }, + ); + + logger.info( + `RelationshipsService: Created subtechnique-of relationship for ${stixId} -> ${parentStixId}`, + ); + + return { created: [createdRelationship] }; + } catch (error) { + logger.error( + `RelationshipsService: Error creating subtechnique-of relationship for ${stixId}: ${error.message}`, + ); + return { + warnings: [ + { + message: 'Failed to create subtechnique-of relationship', + stixId, + error: error.message, + }, + ], + }; + } + } + + /** + * Deprecate subtechnique-of SROs when a subtechnique is converted to a technique. + * + * Creates a new version of each active subtechnique-of relationship where + * source_ref = the converted object, setting x_mitre_deprecated = true. + * + * @param {Object} payload - Event payload + * @param {string} payload.stixId - STIX ID of the converted subtechnique + */ + static async handleSubtechniqueConvertedToTechnique(payload) { + const { stixId } = payload; + + logger.info(`RelationshipsService: Deprecating subtechnique-of relationships for ${stixId}`); + + const deprecatedDocs = []; + const warnings = []; + + try { + const subtechniqueOfRels = await relationshipsRepository.retrieveAll({ + sourceRef: stixId, + relationshipType: 'subtechnique-of', + versions: 'latest', + includeRevoked: false, + includeDeprecated: false, + }); + + for (const rel of subtechniqueOfRels) { + try { + const deprecatedVersion = rel.toObject ? rel.toObject() : { ...rel }; + delete deprecatedVersion._id; + delete deprecatedVersion.__v; + delete deprecatedVersion.__t; + + deprecatedVersion.stix.x_mitre_deprecated = true; + deprecatedVersion.stix.modified = new Date().toISOString(); + + const saved = await relationshipsRepository.save(deprecatedVersion); + deprecatedDocs.push(saved); + } catch (error) { + logger.error( + `RelationshipsService: Error deprecating relationship ${rel.stix?.id}: ${error.message}`, + ); + warnings.push({ + message: 'Failed to deprecate relationship', + relationshipId: rel.stix?.id, + error: error.message, + }); + } + } + + logger.info( + `RelationshipsService: Deprecated ${deprecatedDocs.length}/${subtechniqueOfRels.length} subtechnique-of relationship(s) for ${stixId}`, + ); + } catch (error) { + logger.error( + `RelationshipsService: Error handling subtechnique-to-technique conversion for ${stixId}:`, + error, + ); + warnings.push({ + message: 'Failed to deprecate subtechnique-of relationships', + stixId, + error: error.message, + }); + } + + return { deprecated: deprecatedDocs, warnings }; + } + + /** + * Handle an object being revoked by deprecating all relationships that reference it. + * Creates a new version of each relationship with x_mitre_deprecated = true and bumped modified, + * preserving the original version in history. + * @param {object} payload - Event payload + * @param {string} payload.stixId - STIX ID of the revoked object + * @param {string[]} [payload.excludeRelationshipIds] - Relationship STIX IDs to skip (e.g. the revoked-by relationship) + */ + static async handleObjectRevoked(payload) { + const { stixId, excludeRelationshipIds = [] } = payload; + + logger.info(`RelationshipsService heard event: object revoked for ${stixId}`); + + const deprecatedDocs = []; + const warnings = []; + + try { + const relationships = await relationshipsRepository.retrieveAllBySourceOrTarget(stixId); + + const toDeprecate = relationships.filter( + (rel) => !excludeRelationshipIds.includes(rel.stix.id), + ); + + for (const rel of toDeprecate) { + try { + const relData = rel.toObject ? rel.toObject() : { ...rel }; + delete relData._id; + delete relData.__v; + delete relData.__t; + + relData.stix.x_mitre_deprecated = true; + relData.stix.modified = new Date().toISOString(); + + const saved = await relationshipsRepository.save(relData); + deprecatedDocs.push(saved); + + logger.info( + `Deprecated relationship ${rel.stix.id} (was referencing revoked object ${stixId})`, + ); + } catch (error) { + logger.error(`Failed to deprecate relationship ${rel.stix.id}: ${error.message}`); + warnings.push({ + message: 'Failed to deprecate relationship', + relationshipId: rel.stix.id, + error: error.message, + }); + } + } + + logger.info( + `RelationshipsService: deprecated ${deprecatedDocs.length}/${toDeprecate.length} relationships for revoked object ${stixId}`, + ); + } catch (error) { + logger.error(`RelationshipsService: Error handling object revoked for ${stixId}:`, error); + warnings.push({ + message: 'Failed to deprecate relationships for revoked object', + stixId, + error: error.message, + }); + } + + return { deprecated: deprecatedDocs, warnings }; + } + + async retrieveAll(options) { + let results = await this.repository.retrieveAll(options); + + // Filter out relationships that don't reference the source type + if (options.sourceType) { + results = results.filter((document) => { + if (document.source_objects.length === 0) { + return false; + } else { + document.source_objects.sort((a, b) => b.stix.modified - a.stix.modified); + return objectTypeMap.get(document.source_objects[0].stix.type) === options.sourceType; + } + }); + } + + // Filter out relationships that don't reference the target type + if (options.targetType) { + results = results.filter((document) => { + if (document.target_objects.length === 0) { + return false; + } else { + document.target_objects.sort((a, b) => b.stix.modified - a.stix.modified); + return objectTypeMap.get(document.target_objects[0].stix.type) === options.targetType; + } + }); + } + + const prePaginationTotal = results.length; + + // Apply pagination parameters + if (options.offset || options.limit) { + const start = options.offset || 0; + if (options.limit) { + const end = start + options.limit; + results = results.slice(start, end); + } else { + results = results.slice(start); + } + } + + // Move latest source and target objects to a non-array property, then remove array of source and target objects + for (const document of results) { + if (Array.isArray(document.source_objects)) { + if (document.source_objects.length === 0) { + document.source_objects = undefined; + } else { + document.source_object = document.source_objects[0]; + document.source_objects = undefined; + } + } + + if (Array.isArray(document.target_objects)) { + if (document.target_objects.length === 0) { + document.target_objects = undefined; + } else { + document.target_object = document.target_objects[0]; + document.target_objects = undefined; + } + } + } + + if (options.includeIdentities) { + await this.addCreatedByAndModifiedByIdentitiesToAll(results); + } + + if (options.includePagination) { + return { + pagination: { + total: prePaginationTotal, + offset: options.offset, + limit: options.limit, + }, + data: results, + }; + } else { + return results; + } + } +} + +RelationshipsService.initializeEventListeners(); + +// Default export +module.exports.RelationshipsService = RelationshipsService; + +// Default export - export an instance of the service +module.exports = new RelationshipsService(RelationshipType, relationshipsRepository); diff --git a/app/services/stix/software-service.js b/app/services/stix/software-service.js new file mode 100644 index 00000000..fb7651f8 --- /dev/null +++ b/app/services/stix/software-service.js @@ -0,0 +1,104 @@ +'use strict'; + +const { PropertyNotAllowedError } = require('../../exceptions'); + +const { BaseService } = require('../meta-classes'); +const softwareRepository = require('../../repository/software-repository'); + +const { Malware: MalwareType, Tool: ToolType } = require('../../lib/types'); + +class SoftwareService extends BaseService { + /** + * Ensure x_mitre_aliases[0] is always the object's own name. + * + * - If no aliases exist, sets aliases to [name]. + * - If aliases exist, removes any prior occurrence of the current (and optionally + * previous) name, then prepends the current name at index 0. + * + * @param {Object} data - The software object data (mutated in place) + * @param {string|null} [previousName=null] - The old name to strip on rename + */ + _normalizeAliases(data, previousName = null) { + const name = data.stix?.name; + if (!name) return; + + let aliases = Array.isArray(data.stix.x_mitre_aliases) ? data.stix.x_mitre_aliases : []; + + // Remove current name (avoid duplicate) and previous name (if renamed) + aliases = aliases.filter( + (alias) => alias !== name && (previousName === null || alias !== previousName), + ); + + aliases.unshift(name); + data.stix.x_mitre_aliases = aliases; + } + + /** + * Set domain-specific defaults before creating a software object. + * - For malware: `is_family` defaults to true + * - For tools: `is_family` is not allowed + * - Ensures x_mitre_aliases[0] matches the object name + * + * @param {Object} data - The software object data + * @param {Object} [options] - Creation options + */ + async beforeCreate(data, options) { + // Import-fidelity contract: defaulting `stix.is_family` and rewriting + // `stix.x_mitre_aliases` is correct for user-driven flows where the + // server is the authority on these fields, but incorrect on the import + // path — the bundle carries authoritative values (including a deliberate + // omission of `is_family` for malware that doesn't represent a family, + // which must NOT be defaulted to `true`). `data.stix` is frozen during + // import-mode hooks (app/lib/import-safety.js), so a missing gate would + // throw a TypeError at the first attempted stix write below. + if (options?.import) return; + + // Set is_family default for malware + if (data.stix && data.stix.type === MalwareType && typeof data.stix.is_family !== 'boolean') { + data.stix.is_family = true; + } + // Validate that is_family is not set for tools + else if (data.stix && data.stix.type === ToolType && data.stix.is_family !== undefined) { + throw new PropertyNotAllowedError('is_family is not allowed for tool objects'); + } + + this._normalizeAliases(data); + } + + /** + * Ensure x_mitre_aliases stays in sync on update. + * If the name changed, the old name alias is replaced by the new one. + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(_stixId, _stixModified, data, existingDocument, _options) { + const previousName = existingDocument?.stix?.name ?? null; + this._normalizeAliases(data, previousName); + } + + /** + * Override create to handle type validation for multiple types (malware and tool). + * SoftwareService handles both 'malware' and 'tool' types, so we need custom validation. + * We temporarily set this.type to match the incoming data type so BaseService validation passes. + */ + async create(data, options) { + // Validate that the type is either malware or tool + if (data?.stix?.type !== MalwareType && data?.stix?.type !== ToolType) { + const { InvalidTypeError } = require('../../exceptions'); + throw new InvalidTypeError(); + } + + // Temporarily set this.type to the incoming type for BaseService validation + const originalType = this.type; + this.type = data.stix.type; + + try { + // Call parent create which will trigger beforeCreate hook + return await super.create(data, options); + } finally { + // Restore original type + this.type = originalType; + } + } +} + +module.exports = new SoftwareService(null, softwareRepository); diff --git a/app/services/stix-bundles-service-old.js b/app/services/stix/stix-bundles-service-old.js similarity index 97% rename from app/services/stix-bundles-service-old.js rename to app/services/stix/stix-bundles-service-old.js index 23a2d152..9acae2b3 100644 --- a/app/services/stix-bundles-service-old.js +++ b/app/services/stix/stix-bundles-service-old.js @@ -1,23 +1,23 @@ 'use strict'; const uuid = require('uuid'); -const config = require('../config/config'); -const BaseService = require('./_base.service'); -const linkById = require('../lib/linkById'); -const logger = require('../lib/logger'); +const config = require('../../config/config'); +const { BaseService } = require('../meta-classes'); +const linkById = require('../../lib/linkById'); +const logger = require('../../lib/logger'); // Import repositories -const attackObjectsRepository = require('../repository/attack-objects-repository'); -const matrixRepository = require('../repository/matrix-repository'); -const mitigationsRepository = require('../repository/mitigations-repository'); -const notesRepository = require('../repository/notes-repository'); -const relationshipsRepository = require('../repository/relationships-repository'); -const softwareRepository = require('../repository/software-repository'); -const tacticsRepository = require('../repository/tactics-repository'); -const techniquesRepository = require('../repository/techniques-repository'); +const attackObjectsRepository = require('../../repository/attack-objects-repository'); +const matrixRepository = require('../../repository/matrix-repository'); +const mitigationsRepository = require('../../repository/mitigations-repository'); +const notesRepository = require('../../repository/notes-repository'); +const relationshipsRepository = require('../../repository/relationships-repository'); +const softwareRepository = require('../../repository/software-repository'); +const tacticsRepository = require('../../repository/tactics-repository'); +const techniquesRepository = require('../../repository/techniques-repository'); // Import services -const notesService = require('./notes-service'); +const notesService = require('../system/notes-service'); /** * Service for generating STIX bundles from the ATT&CK database. diff --git a/app/services/stix-bundles-service.js b/app/services/stix/stix-bundles-service.js similarity index 95% rename from app/services/stix-bundles-service.js rename to app/services/stix/stix-bundles-service.js index 8cdf587c..50908869 100644 --- a/app/services/stix-bundles-service.js +++ b/app/services/stix/stix-bundles-service.js @@ -1,27 +1,28 @@ 'use strict'; const uuid = require('uuid'); -const config = require('../config/config'); -const BaseService = require('./_base.service'); -const linkById = require('../lib/linkById'); -const logger = require('../lib/logger'); +const config = require('../../config/config'); +const { BaseService } = require('../meta-classes'); +const linkById = require('../../lib/linkById'); +const logger = require('../../lib/logger'); +const { requiresAttackId } = require('../../lib/attack-id-generator'); // Import repositories -const analyticsRepository = require('../repository/analytics-repository'); -const attackObjectsRepository = require('../repository/attack-objects-repository'); -const matrixRepository = require('../repository/matrix-repository'); -const mitigationsRepository = require('../repository/mitigations-repository'); -const notesRepository = require('../repository/notes-repository'); -const relationshipsRepository = require('../repository/relationships-repository'); -const softwareRepository = require('../repository/software-repository'); -const tacticsRepository = require('../repository/tactics-repository'); -const techniquesRepository = require('../repository/techniques-repository'); -const dataComponentsRepository = require('../repository/data-components-repository'); -const dataSourcesRepository = require('../repository/data-sources-repository'); -const detectionStrategiesRepository = require('../repository/detection-strategies-repository'); +const analyticsRepository = require('../../repository/analytics-repository'); +const attackObjectsRepository = require('../../repository/attack-objects-repository'); +const matrixRepository = require('../../repository/matrix-repository'); +const mitigationsRepository = require('../../repository/mitigations-repository'); +const notesRepository = require('../../repository/notes-repository'); +const relationshipsRepository = require('../../repository/relationships-repository'); +const softwareRepository = require('../../repository/software-repository'); +const tacticsRepository = require('../../repository/tactics-repository'); +const techniquesRepository = require('../../repository/techniques-repository'); +const dataComponentsRepository = require('../../repository/data-components-repository'); +const dataSourcesRepository = require('../../repository/data-sources-repository'); +const detectionStrategiesRepository = require('../../repository/detection-strategies-repository'); // Import services -const notesService = require('./notes-service'); +const notesService = require('../system/notes-service'); /** * Service for generating STIX bundles from the ATT&CK database. @@ -213,27 +214,6 @@ class StixBundlesService extends BaseService { return false; } - /** - * Determines if a given attack object type requires an ATT&CK ID. - * @param {Object} attackObject - The attack object to check - * @returns {boolean} True if the object type requires an ATT&CK ID - */ - static requiresAttackId(attackObject) { - const attackIdObjectTypes = [ - 'intrusion-set', - 'campaign', - 'malware', - 'tool', - 'attack-pattern', - 'course-of-action', - 'x-mitre-data-source', - 'x-mitre-data-component', - 'x-mitre-detection-strategy', - 'x-mitre-analytic', - ]; - return attackIdObjectTypes.includes(attackObject?.stix?.type); - } - // ============================ // STIX Version Management // ============================ @@ -370,7 +350,7 @@ class StixBundlesService extends BaseService { secondaryObject && // Check if ATT&CK ID is required (options.includeMissingAttackId || - !StixBundlesService.requiresAttackId(secondaryObject) || + !requiresAttackId(secondaryObject?.stix?.type) || StixBundlesService.hasAttackId(secondaryObject)) && // Check deprecation status (options.includeDeprecated || !secondaryObject.stix.x_mitre_deprecated) && @@ -895,7 +875,7 @@ class StixBundlesService extends BaseService { } // Use the existing repository method that exactly matches the original logic - const attackObject = await this.repositories.attackObject.retrieveLatestByStixId(stixId); + const attackObject = await this.repositories.attackObject.retrieveLatestByStixIdLean(stixId); if (attackObject) { this.attackObjectCache.set(cacheKey, attackObject); diff --git a/app/services/stix/tactics-service.js b/app/services/stix/tactics-service.js new file mode 100644 index 00000000..6bef2c81 --- /dev/null +++ b/app/services/stix/tactics-service.js @@ -0,0 +1,192 @@ +'use strict'; + +const config = require('../../config/config'); +const { BaseService } = require('../meta-classes'); +const tacticsRepository = require('../../repository/tactics-repository'); +const { Tactic: TacticType } = require('../../lib/types'); +const techniquesService = require('./techniques-service'); +const { BadlyFormattedParameterError, MissingParameterError } = require('../../exceptions'); +const EventBus = require('../../lib/event-bus'); +const EventConstants = require('../../lib/event-constants'); +const logger = require('../../lib/logger'); + +/** + * Service for managing tactics + * + * Lifecycle hooks: + * - beforeUpdate: Detects changes to x_mitre_shortname and stores old/new values + * - afterUpdate: Emits domain event when shortname changes so TechniquesService can + * update kill_chain_phases.phase_name on all connected techniques + * + * Events emitted (listened to by TechniquesService): + * - x-mitre-tactic::shortname-changed + */ +class TacticsService extends BaseService { + static techniquesService = null; + + static techniqueMatchesTactic(tactic) { + return function (technique) { + // A tactic matches if the technique has a kill chain phase such that: + // 1. The phase's kill_chain_name matches one of the tactic's kill chain names (which are derived from the tactic's x_mitre_domains) + // 2. The phase's phase_name matches the tactic's x_mitre_shortname + // Convert the tactic's domain names to kill chain names + if (!tactic.stix.x_mitre_domains?.length) { + return false; + } + const tacticKillChainNames = tactic.stix.x_mitre_domains.map( + (domain) => config.domainToKillChainMap[domain], + ); + return technique.stix.kill_chain_phases?.some( + (phase) => + phase.phase_name === tactic.stix.x_mitre_shortname && + tacticKillChainNames.includes(phase.kill_chain_name), + ); + }; + } + + /** + * Detect shortname changes when creating a new version of an existing tactic. + * Compares against the current latest version; stores the change so afterCreate + * can emit the domain event. + */ + // eslint-disable-next-line no-unused-vars + async beforeCreate(data, options) { + if (!data.stix?.id) { + return; // Brand-new tactic — no previous version to compare against + } + + try { + const previousVersion = await tacticsRepository.retrieveLatestByStixId(data.stix.id); + if (!previousVersion) return; + + const oldShortname = previousVersion.stix?.x_mitre_shortname; + const newShortname = data.stix?.x_mitre_shortname; + + if (oldShortname && newShortname && oldShortname !== newShortname) { + this._shortnameChangeViaCreate = { oldShortname, newShortname }; + } + } catch { + logger.debug(`TacticsService: No previous version found for tactic ${data.stix.id}`); + } + } + + /** + * Emit a domain event when a new tactic version has a changed x_mitre_shortname. + * TechniquesService will create new technique versions to propagate the change. + */ + // eslint-disable-next-line no-unused-vars + async afterCreate(document, options) { + if (this._shortnameChangeViaCreate) { + const { oldShortname, newShortname } = this._shortnameChangeViaCreate; + + logger.info( + `TacticsService: New tactic version with x_mitre_shortname change '${oldShortname}' -> '${newShortname}', emitting event`, + { tacticId: document.stix.id }, + ); + + await EventBus.emit(EventConstants.TACTIC_SHORTNAME_CHANGED, { + tacticId: document.stix.id, + oldShortname, + newShortname, + domains: document.stix.x_mitre_domains || [], + createNewVersion: true, + }); + + delete this._shortnameChangeViaCreate; + } + } + + /** + * Detect changes to x_mitre_shortname before the update is persisted. + * Stores the old/new values so afterUpdate can emit the domain event. + */ + // eslint-disable-next-line no-unused-vars + async beforeUpdate(stixId, stixModified, data, existingDocument, options) { + const oldShortname = existingDocument.stix?.x_mitre_shortname; + const newShortname = data.stix?.x_mitre_shortname; + + if (oldShortname && newShortname && oldShortname !== newShortname) { + this._shortnameChange = { oldShortname, newShortname }; + } + } + + /** + * Emit a domain event when x_mitre_shortname changed so TechniquesService can + * update kill_chain_phases.phase_name on all connected techniques. + */ + async afterUpdate(updatedDocument) { + if (this._shortnameChange) { + const { oldShortname, newShortname } = this._shortnameChange; + + logger.info( + `TacticsService: x_mitre_shortname changed '${oldShortname}' -> '${newShortname}', emitting event`, + { tacticId: updatedDocument.stix.id }, + ); + + await EventBus.emit(EventConstants.TACTIC_SHORTNAME_CHANGED, { + tacticId: updatedDocument.stix.id, + oldShortname, + newShortname, + domains: updatedDocument.stix.x_mitre_domains || [], + }); + + delete this._shortnameChange; + } + } + + static getPageOfData(data, options) { + const startPos = options.offset; + const endPos = + options.limit === 0 ? data.length : Math.min(options.offset + options.limit, data.length); + + return data.slice(startPos, endPos); + } + + async retrieveTechniquesForTactic(stixId, modified, options) { + // Retrieve the techniques associated with the tactic (the tactic identified by stixId and modified date) + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + if (!modified) { + throw new MissingParameterError('modified'); + } + + try { + const tactic = await this.repository.retrieveOneByVersion(stixId, modified); + + // Note: document is null if not found + if (!tactic) { + return null; + } else { + const allTechniques = await techniquesService.retrieveAll({}); + const filteredTechniques = allTechniques.filter( + TacticsService.techniqueMatchesTactic(tactic), + ); + const pagedResults = TacticsService.getPageOfData(filteredTechniques, options); + + if (options.includePagination) { + const returnValue = { + pagination: { + total: pagedResults.length, + offset: options.offset, + limit: options.limit, + }, + data: pagedResults, + }; + return returnValue; + } else { + return pagedResults; + } + } + } catch (err) { + if (err.name === 'CastError') { + throw new BadlyFormattedParameterError({ parameterName: 'stixId' }); + } else { + throw err; + } + } + } +} + +module.exports = new TacticsService(TacticType, tacticsRepository); diff --git a/app/services/stix/techniques-service.js b/app/services/stix/techniques-service.js new file mode 100644 index 00000000..73735e1c --- /dev/null +++ b/app/services/stix/techniques-service.js @@ -0,0 +1,504 @@ +'use strict'; + +const config = require('../../config/config'); +const { BaseService } = require('../meta-classes'); +const techniquesRepository = require('../../repository/techniques-repository'); + +const { Technique: TechniqueType } = require('../../lib/types'); +const { + BadlyFormattedParameterError, + BadRequestError, + MissingParameterError, + NotFoundError, +} = require('../../exceptions'); +const attackIdGenerator = require('../../lib/attack-id-generator'); +const { + buildAttackExternalReference, + removeAttackExternalReferences, +} = require('../../lib/external-reference-builder'); +const EventBus = require('../../lib/event-bus'); +const EventConstants = require('../../lib/event-constants'); +const WorkflowResult = require('../../lib/workflow-result'); +const logger = require('../../lib/logger'); + +/** + * Service for managing techniques and sub-techniques + * + * Event listeners: + * - x-mitre-tactic::shortname-changed - Updates kill_chain_phases.phase_name on all + * technique documents when a tactic's x_mitre_shortname changes + */ +class TechniquesService extends BaseService { + /** + * Initialize event listeners. + * Called once on module load. + */ + static initializeEventListeners() { + EventBus.on( + EventConstants.TACTIC_SHORTNAME_CHANGED, + TechniquesService.handleTacticShortnameChanged.bind(TechniquesService), + ); + + logger.info('TechniquesService: Event listeners initialized'); + } + + /** + * Handle a tactic x_mitre_shortname change by updating all technique documents + * whose kill_chain_phases contain the old phase_name. + * + * @param {Object} payload - Event payload + * @param {string} payload.tacticId - STIX ID of the updated tactic + * @param {string} payload.oldShortname - Previous x_mitre_shortname value + * @param {string} payload.newShortname - New x_mitre_shortname value + * @param {string[]} payload.domains - The tactic's x_mitre_domains (e.g. ['enterprise-attack']) + */ + static async handleTacticShortnameChanged(payload) { + const { tacticId, oldShortname, newShortname, domains = [], createNewVersion } = payload; + + // Convert the tactic's domains to kill chain names so we only update + // techniques whose kill_chain_phases match both the phase_name AND the + // kill_chain_name. This prevents cross-domain propagation (e.g. an + // enterprise tactic rename bleeding into mobile techniques). + const killChainNames = domains + .map((domain) => config.domainToKillChainMap[domain]) + .filter(Boolean); + + logger.info( + `TechniquesService: Propagating tactic shortname change '${oldShortname}' -> '${newShortname}' via ${createNewVersion ? 'new technique versions' : 'in-place update'}`, + { tacticId, killChainNames }, + ); + + if (createNewVersion) { + await TechniquesService._propagateShortnameViaNewVersions( + tacticId, + oldShortname, + newShortname, + killChainNames, + ); + } else { + await TechniquesService._propagateShortnameInPlace( + tacticId, + oldShortname, + newShortname, + killChainNames, + ); + } + } + + /** + * Propagate a shortname change by creating a new version of each connected technique. + * Used when the tactic shortname changed via a create (POST) operation, keeping the + * technique history intact by appending rather than editing in-place. + * + * @param {string} tacticId - STIX ID of the updated tactic (for logging) + * @param {string} oldShortname - Previous x_mitre_shortname value + * @param {string} newShortname - New x_mitre_shortname value + * @param {string[]} killChainNames - Kill chain names derived from the tactic's domains + */ + static async _propagateShortnameViaNewVersions( + tacticId, + oldShortname, + newShortname, + killChainNames, + ) { + const techniques = await techniquesRepository.retrieveAllLatestByPhaseName( + oldShortname, + killChainNames, + ); + + logger.info( + `TechniquesService: Creating new versions for ${techniques.length} technique(s) due to tactic shortname change`, + { tacticId, oldShortname, newShortname, killChainNames }, + ); + + for (const technique of techniques) { + try { + // Clone stix shallowly — only kill_chain_phases needs to change. + // Only rename phases that match BOTH the old phase_name AND one of the + // tactic's kill chain names, so cross-domain phases are left untouched. + const newVersion = { + ...technique, + stix: { + ...technique.stix, + modified: new Date().toISOString(), + kill_chain_phases: (technique.stix.kill_chain_phases || []).map((phase) => + phase.phase_name === oldShortname && + (killChainNames.length === 0 || killChainNames.includes(phase.kill_chain_name)) + ? { ...phase, phase_name: newShortname } + : { ...phase }, + ), + }, + }; + + await techniquesRepository.save(newVersion); + + logger.info( + `TechniquesService: Created new version of technique ${technique.stix.id} with updated phase_name`, + { tacticId, oldShortname, newShortname }, + ); + } catch (error) { + logger.error( + `TechniquesService: Error creating new version of technique ${technique.stix?.id}:`, + error, + ); + } + } + } + + /** + * Propagate a shortname change by updating all technique documents in-place. + * Used when the tactic shortname changed via an update (PUT) operation. + * + * @param {string} tacticId - STIX ID of the updated tactic (for logging) + * @param {string} oldShortname - Previous x_mitre_shortname value + * @param {string} newShortname - New x_mitre_shortname value + * @param {string[]} killChainNames - Kill chain names derived from the tactic's domains + */ + static async _propagateShortnameInPlace(tacticId, oldShortname, newShortname, killChainNames) { + try { + const result = await techniquesRepository.updatePhaseName( + oldShortname, + newShortname, + killChainNames, + ); + logger.info( + `TechniquesService: Updated ${result.modifiedCount} technique document(s) in-place for tactic shortname change`, + { tacticId, oldShortname, newShortname, killChainNames }, + ); + } catch (error) { + logger.error( + `TechniquesService: Error updating techniques in-place for tactic shortname change '${oldShortname}' -> '${newShortname}':`, + error, + ); + } + } + + static tacticsService = null; + + static tacticMatchesTechnique(technique) { + return function (tactic) { + // A tactic matches if the technique has a kill chain phase such that: + // 1. The phase's kill_chain_name matches one of the tactic's kill chain names (which are derived from the tactic's x_mitre_domains) + // 2. The phase's phase_name matches the tactic's x_mitre_shortname + + // Convert the tactic's domain names to kill chain names + const tacticKillChainNames = tactic.stix.x_mitre_domains.map( + (domain) => config.domainToKillChainMap[domain], + ); + return technique.stix.kill_chain_phases.some( + (phase) => + phase.phase_name === tactic.stix.x_mitre_shortname && + tacticKillChainNames.includes(phase.kill_chain_name), + ); + }; + } + + static getPageOfData(data, options) { + const startPos = options.offset; + const endPos = + options.limit === 0 ? data.length : Math.min(options.offset + options.limit, data.length); + + return data.slice(startPos, endPos); + } + + // ============================ + // Revoke Lifecycle Hooks + // ============================ + + /** + * Validate subtechnique/parent constraints before allowing a revoke operation. + * + * Rules: + * - Sub revoking parent (parent has children) → blocked (would orphan children) + * - All other combinations are allowed; subtechnique-of relationships are excluded + * from preservation in the base revoke loop. + */ + async beforeRevoke(objectA, objectB) { + const relationshipsRepository = require('../../repository/relationships-repository'); + + const aIsSub = objectA.stix.x_mitre_is_subtechnique === true; + const bIsSub = objectB.stix.x_mitre_is_subtechnique === true; + + if (bIsSub && !aIsSub) { + // Sub revoking parent — verify the parent has no child subtechniques + const childRels = await relationshipsRepository.retrieveAll({ + targetRef: objectA.stix.id, + relationshipType: 'subtechnique-of', + versions: 'latest', + includeRevoked: false, + includeDeprecated: false, + }); + + if (childRels.length > 0) { + throw new BadRequestError({ + details: + `Cannot revoke parent technique ${objectA.stix.id} with subtechnique ${objectB.stix.id}: ` + + `the parent has ${childRels.length} subtechnique(s). ` + + `Convert the revoking subtechnique to a parent technique first, or rehome the subtechniques.`, + }); + } + } + } + + // ============================ + // Subtechnique Conversion + // ============================ + + /** + * Convert a technique to a subtechnique. + * + * Generates a new subtechnique-format ATT&CK ID (e.g., T1234.001) under the + * specified parent, updates x_mitre_is_subtechnique, rebuilds the ATT&CK + * external reference, and persists the result as a new version. + * + * @param {string} stixId - STIX ID of the technique to convert + * @param {Object} data - Request body + * @param {string} data.parentTechniqueAttackId - Parent technique ATT&CK ID (e.g., T1234) + * @param {Object} [options] - Options + * @param {string} [options.userAccountId] - Authenticated user's account ID + * @returns {Object} The newly created subtechnique version + */ + async convertToSubtechnique(stixId, data, options = {}) { + // Lazy-load to avoid circular dependency + const relationshipsRepository = require('../../repository/relationships-repository'); + + if (!stixId) { + throw new MissingParameterError('stixId'); + } + if (!data?.parentTechniqueAttackId) { + throw new MissingParameterError('parentTechniqueAttackId'); + } + if (!/^T\d{4}$/.test(data.parentTechniqueAttackId)) { + throw new BadRequestError({ + details: `Invalid parent technique ATT&CK ID format: ${data.parentTechniqueAttackId}. Must be T####.`, + }); + } + + const technique = await this.repository.retrieveLatestByStixId(stixId); + if (!technique) { + throw new NotFoundError({ details: `Technique with stixId ${stixId} not found` }); + } + if (technique.stix.x_mitre_is_subtechnique === true) { + throw new BadRequestError({ + details: `Technique ${stixId} is already a subtechnique`, + }); + } + if (technique.stix.revoked === true) { + throw new BadRequestError({ + details: `Cannot convert a revoked technique`, + }); + } + + // Validate that the parent technique exists + const parentTechnique = await this.repository.retrieveLatestByAttackId( + data.parentTechniqueAttackId, + ); + if (!parentTechnique) { + throw new BadRequestError({ + details: `Parent technique with ATT&CK ID ${data.parentTechniqueAttackId} not found`, + }); + } + + // Check if this technique has child subtechniques (via subtechnique-of SROs). + // Cross-service READ is permitted per architecture guidelines. + // If children exist, block the conversion — the user must rehome them first. + const childRelationships = await relationshipsRepository.retrieveAll({ + targetRef: stixId, + relationshipType: 'subtechnique-of', + versions: 'latest', + includeRevoked: false, + includeDeprecated: false, + }); + if (childRelationships.length > 0) { + throw new BadRequestError({ + details: + `Technique ${stixId} has ${childRelationships.length} subtechnique(s). ` + + `Rehome or remove the subtechnique-of relationships before converting this technique to a subtechnique.`, + }); + } + + // Generate new subtechnique ATT&CK ID + const newAttackId = await attackIdGenerator.generateAttackId( + 'attack-pattern', + this.repository, + true, + data.parentTechniqueAttackId, + ); + + // Build new version + const newVersion = technique.toObject ? technique.toObject() : { ...technique }; + delete newVersion._id; + delete newVersion.__v; + delete newVersion.__t; + + newVersion.stix.x_mitre_is_subtechnique = true; + newVersion.stix.modified = new Date().toISOString(); + newVersion.workspace = newVersion.workspace || {}; + newVersion.workspace.attack_id = newAttackId; + + // Rebuild external references: replace ATT&CK ref with the new one + const userRefs = removeAttackExternalReferences(newVersion.stix.external_references); + const newAttackRef = buildAttackExternalReference(newAttackId, 'attack-pattern', { + isSubtechnique: true, + }); + newVersion.stix.external_references = newAttackRef ? [newAttackRef, ...userRefs] : userRefs; + + if (options.userAccountId) { + newVersion.workspace.workflow = newVersion.workspace.workflow || {}; + newVersion.workspace.workflow.created_by_user_account = options.userAccountId; + } + + const savedDocument = await this.repository.save(newVersion); + + logger.info( + `Converted technique ${stixId} to subtechnique: ${technique.workspace?.attack_id} -> ${newAttackId}`, + ); + + const result = new WorkflowResult('convert-to-subtechnique'); + result.setPrimary(savedDocument); + + // Emit domain event — RelationshipsService listens to create the subtechnique-of SRO + const eventResults = await EventBus.emit(EventConstants.TECHNIQUE_CONVERTED_TO_SUBTECHNIQUE, { + stixId /** STIX ID of the converted subtechnique */, + parentStixId: parentTechnique.stix.id /** STIX ID of the parent technique */, + userAccountId: options.userAccountId, + }); + result.mergeEventResults(eventResults); + + return result.toJSON(); + } + + /** + * Convert a subtechnique to a technique. + * + * Generates a new technique-format ATT&CK ID (e.g., T1235), updates + * x_mitre_is_subtechnique, rebuilds the ATT&CK external reference, and + * persists the result as a new version. + * + * @param {string} stixId - STIX ID of the subtechnique to convert + * @param {Object} [options] - Options + * @param {string} [options.userAccountId] - Authenticated user's account ID + * @returns {Object} The newly created technique version + */ + async convertToTechnique(stixId, options = {}) { + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + const technique = await this.repository.retrieveLatestByStixId(stixId); + if (!technique) { + throw new NotFoundError({ details: `Technique with stixId ${stixId} not found` }); + } + if (technique.stix.x_mitre_is_subtechnique !== true) { + throw new BadRequestError({ + details: `Technique ${stixId} is not a subtechnique`, + }); + } + if (technique.stix.revoked === true) { + throw new BadRequestError({ + details: `Cannot convert a revoked technique`, + }); + } + + // Generate new technique ATT&CK ID + const newAttackId = await attackIdGenerator.generateAttackId( + 'attack-pattern', + this.repository, + false, + ); + + // Build new version + const newVersion = technique.toObject ? technique.toObject() : { ...technique }; + delete newVersion._id; + delete newVersion.__v; + delete newVersion.__t; + + newVersion.stix.x_mitre_is_subtechnique = false; + newVersion.stix.modified = new Date().toISOString(); + newVersion.workspace = newVersion.workspace || {}; + newVersion.workspace.attack_id = newAttackId; + + // Rebuild external references: replace ATT&CK ref with the new one + const userRefs = removeAttackExternalReferences(newVersion.stix.external_references); + const newAttackRef = buildAttackExternalReference(newAttackId, 'attack-pattern', { + isSubtechnique: false, + }); + newVersion.stix.external_references = newAttackRef ? [newAttackRef, ...userRefs] : userRefs; + + if (options.userAccountId) { + newVersion.workspace.workflow = newVersion.workspace.workflow || {}; + newVersion.workspace.workflow.created_by_user_account = options.userAccountId; + } + + const savedDocument = await this.repository.save(newVersion); + + logger.info( + `Converted subtechnique ${stixId} to technique: ${technique.workspace?.attack_id} -> ${newAttackId}`, + ); + + const result = new WorkflowResult('convert-to-technique'); + result.setPrimary(savedDocument); + + // Emit domain event — RelationshipsService listens to deprecate subtechnique-of SROs + const eventResults = await EventBus.emit(EventConstants.SUBTECHNIQUE_CONVERTED_TO_TECHNIQUE, { + stixId /** STIX ID of the converted subtechnique */, + }); + result.mergeEventResults(eventResults); + + return result.toJSON(); + } + + async retrieveTacticsForTechnique(stixId, modified, options) { + // Late binding to avoid circular dependency between modules + if (!TechniquesService.tacticsService) { + TechniquesService.tacticsService = require('./tactics-service'); + } + + // Retrieve the tactics associated with the technique (the technique identified by stixId and modified date) + if (!stixId) { + throw new MissingParameterError('stixId'); + } + + if (!modified) { + throw new MissingParameterError('modified'); + } + + try { + const technique = await this.repository.retrieveOneByVersion(stixId, modified); + if (!technique) { + // Note: document is null if not found + return null; + } else { + const allTactics = await TechniquesService.tacticsService.retrieveAll({}); + const filteredTactics = allTactics.filter( + TechniquesService.tacticMatchesTechnique(technique), + ); + const pagedResults = TechniquesService.getPageOfData(filteredTactics, options); + + if (options.includePagination) { + const returnValue = { + pagination: { + total: pagedResults.length, + offset: options.offset, + limit: options.limit, + }, + data: pagedResults, + }; + return returnValue; + } else { + return pagedResults; + } + } + } catch (err) { + if (err.name === 'CastError') { + throw new BadlyFormattedParameterError(); + } else { + throw err; + } + } + } +} + +TechniquesService.initializeEventListeners(); + +module.exports = new TechniquesService(TechniqueType, techniquesRepository); diff --git a/app/services/authentication-service.js b/app/services/system/authentication-service.js similarity index 98% rename from app/services/authentication-service.js rename to app/services/system/authentication-service.js index aceec2b6..8e5ee057 100644 --- a/app/services/authentication-service.js +++ b/app/services/system/authentication-service.js @@ -4,8 +4,8 @@ const request = require('superagent'); const crypto = require('crypto'); const jwtDecoder = require('jwt-decode'); -const logger = require('../lib/logger'); -const config = require('../config/config'); +const logger = require('../../lib/logger'); +const config = require('../../config/config'); const errors = { serviceNotFound: 'Service not found', diff --git a/app/services/notes-service.js b/app/services/system/notes-service.js similarity index 91% rename from app/services/notes-service.js rename to app/services/system/notes-service.js index ce629d2a..c2c74c00 100644 --- a/app/services/notes-service.js +++ b/app/services/system/notes-service.js @@ -1,15 +1,15 @@ 'use strict'; const _ = require('lodash'); -const notesRepository = require('../repository/notes-repository'); -const BaseService = require('./_base.service'); -const { Note: NoteType } = require('../lib/types'); +const notesRepository = require('../../repository/notes-repository'); +const { BaseService } = require('../meta-classes'); +const { Note: NoteType } = require('../../lib/types'); const { BadlyFormattedParameterError, DuplicateIdError, MissingParameterError, -} = require('../exceptions'); +} = require('../../exceptions'); class NotesService extends BaseService { async updateVersion(stixId, stixModified, data) { diff --git a/app/services/recent-activity-service.js b/app/services/system/recent-activity-service.js similarity index 92% rename from app/services/recent-activity-service.js rename to app/services/system/recent-activity-service.js index 7dc92647..41d96053 100644 --- a/app/services/recent-activity-service.js +++ b/app/services/system/recent-activity-service.js @@ -1,7 +1,7 @@ 'use strict'; -const recentActivityRepository = require('../repository/recent-activity-repository'); -const identitiesService = require('./identities-service'); +const recentActivityRepository = require('../../repository/recent-activity-repository'); +const identitiesService = require('../stix/identities-service'); class RecentActivityService { constructor(repository) { diff --git a/app/services/references-service.js b/app/services/system/references-service.js similarity index 81% rename from app/services/references-service.js rename to app/services/system/references-service.js index 2b169744..a6e1f2ea 100644 --- a/app/services/references-service.js +++ b/app/services/system/references-service.js @@ -1,8 +1,8 @@ 'use strict'; -const BaseService = require('./_base.service'); -const referencesRepository = require('../repository/references-repository'); -const { MissingParameterError } = require('../exceptions'); +const { BaseService } = require('../meta-classes'); +const referencesRepository = require('../../repository/references-repository'); +const { MissingParameterError } = require('../../exceptions'); class ReferencesService { constructor(repository) { diff --git a/app/services/system-configuration-service.js b/app/services/system/system-configuration-service.js similarity index 58% rename from app/services/system-configuration-service.js rename to app/services/system/system-configuration-service.js index d66bf3bd..f52ebc7c 100644 --- a/app/services/system-configuration-service.js +++ b/app/services/system/system-configuration-service.js @@ -1,12 +1,14 @@ 'use strict'; const fs = require('fs'); -const config = require('../config/config'); -const systemConfigurationRepository = require('../repository/system-configurations-repository'); +const config = require('../../config/config'); +const systemConfigurationRepository = require('../../repository/system-configurations-repository'); const userAccountsService = require('./user-accounts-service'); -const identitiesService = require('./identities-service'); -const markingDefinitionsService = require('./marking-definitions-service'); -const BaseService = require('./_base.service'); +const identitiesService = require('../stix/identities-service'); +const markingDefinitionsService = require('../stix/marking-definitions-service'); +const { BaseService } = require('../meta-classes'); +const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); const { SystemConfigurationNotFound, OrganizationIdentityNotSetError, @@ -15,7 +17,7 @@ const { AnonymousUserAccountNotSetError, AnonymousUserAccountNotFoundError, NotImplementedError, -} = require('../exceptions'); +} = require('../../exceptions'); class SystemConfigurationService extends BaseService { constructor() { @@ -105,18 +107,52 @@ class SystemConfigurationService extends BaseService { /** * @public * CRUD Operation: Update - * Sets the organization identity + * Sets the organization identity. + * Validates that the identity exists, creates a new config document (preserving history), + * and emits an event to trigger downstream propagation. */ async setOrganizationIdentity(stixId) { - const systemConfig = await this.repository.retrieveOne(); + // Validate that the identity exists + const identities = await identitiesService.retrieveById(stixId, { versions: 'latest' }); + if (identities.length === 0) { + throw new OrganizationIdentityNotFoundError(stixId); + } + + const currentConfig = await this.repository.retrieveOne({ lean: true }); + + if (currentConfig) { + // No-op if already set to this identity + if (currentConfig.organization_identity_ref === stixId) return; + + const previousIdentityRef = currentConfig.organization_identity_ref; + + // Create a new config document with updated identity ref + await this._createNewConfigVersion(currentConfig, { + organization_identity_ref: stixId, + }); + + // Determine the full provenance chain + const organizationIdentityHistory = await this.repository.retrieveAllDistinctIdentityRefs(); - if (systemConfig) { - systemConfig.organization_identity_ref = stixId; - await this.repository.constructor.saveDocument(systemConfig); + // Emit event for downstream propagation + await EventBus.emit(Events.SYSTEM_CONFIGURATION_IDENTITY_CHANGED, { + previousIdentityRef, + newIdentityRef: stixId, + organizationIdentityHistory, + }); } else { - const systemConfigData = { organization_identity_ref: stixId }; - const newConfig = this.repository.createNewDocument(systemConfigData); + // First-time setup: create initial config document + const newConfig = this.repository.createNewDocument({ + organization_identity_ref: stixId, + }); await this.repository.constructor.saveDocument(newConfig); + + // Emit event so validation bypass rules are created at startup + await EventBus.emit(Events.SYSTEM_CONFIGURATION_IDENTITY_CHANGED, { + previousIdentityRef: null, + newIdentityRef: stixId, + organizationIdentityHistory: [stixId], + }); } } @@ -157,14 +193,16 @@ class SystemConfigurationService extends BaseService { * Sets the default marking definitions */ async setDefaultMarkingDefinitions(stixIds) { - const systemConfig = await this.repository.retrieveOne(); + const currentConfig = await this.repository.retrieveOne({ lean: true }); - if (systemConfig) { - systemConfig.default_marking_definitions = stixIds; - await this.repository.constructor.saveDocument(systemConfig); + if (currentConfig) { + await this._createNewConfigVersion(currentConfig, { + default_marking_definitions: stixIds, + }); } else { - const systemConfigData = { default_marking_definitions: stixIds }; - const newConfig = this.repository.createNewDocument(systemConfigData); + const newConfig = this.repository.createNewDocument({ + default_marking_definitions: stixIds, + }); await this.repository.constructor.saveDocument(newConfig); } } @@ -196,14 +234,15 @@ class SystemConfigurationService extends BaseService { * Internal method for user account management */ async setAnonymousUserAccountId(userAccountId) { - const systemConfig = await this.repository.retrieveOne(); + const currentConfig = await this.repository.retrieveOne({ lean: true }); - if (!systemConfig) { + if (!currentConfig) { throw new SystemConfigurationNotFound(); } - systemConfig.anonymous_user_account_id = userAccountId; - await this.repository.constructor.saveDocument(systemConfig); + await this._createNewConfigVersion(currentConfig, { + anonymous_user_account_id: userAccountId, + }); } /** @@ -238,14 +277,52 @@ class SystemConfigurationService extends BaseService { * Sets the organization namespace */ async setOrganizationNamespace(namespace) { - const systemConfig = await this.repository.retrieveOne(); + const currentConfig = await this.repository.retrieveOne({ lean: true }); - if (!systemConfig) { + if (!currentConfig) { throw new SystemConfigurationNotFound(); } - systemConfig.organization_namespace = namespace; - await this.repository.constructor.saveDocument(systemConfig); + await this._createNewConfigVersion(currentConfig, { + organization_namespace: namespace, + }); + + // Emit event so ValidationBypassesService can manage its own bypass rules + await EventBus.emit(Events.SYSTEM_CONFIGURATION_NAMESPACE_CHANGED, { + namespace, + }); + } + + /** + * @public + * CRUD Operation: Read + * Returns all distinct organization identity refs from all config documents. + * This represents the full provenance chain of organization identities. + * @returns {Promise} + */ + async retrieveOrganizationIdentityHistory() { + return await this.repository.retrieveAllDistinctIdentityRefs(); + } + + /** + * @private + * Creates a new system configuration document by copying the latest config + * and applying the given field overrides. This preserves history by leaving + * the previous document intact. + * @param {Object} currentConfig - The current config document (lean) + * @param {Object} overrides - Fields to update in the new document + * @returns {Promise} The saved new config document + */ + async _createNewConfigVersion(currentConfig, overrides) { + const configData = { + organization_identity_ref: currentConfig.organization_identity_ref, + anonymous_user_account_id: currentConfig.anonymous_user_account_id, + default_marking_definitions: currentConfig.default_marking_definitions, + organization_namespace: currentConfig.organization_namespace, + ...overrides, + }; + const newConfig = this.repository.createNewDocument(configData); + return await this.repository.constructor.saveDocument(newConfig); } /** diff --git a/app/services/teams-service.js b/app/services/system/teams-service.js similarity index 92% rename from app/services/teams-service.js rename to app/services/system/teams-service.js index e1c183bb..c9909606 100644 --- a/app/services/teams-service.js +++ b/app/services/system/teams-service.js @@ -1,15 +1,15 @@ 'use strict'; -const regexValidator = require('../lib/regex'); -const UserAccount = require('../models/user-account-model'); +const regexValidator = require('../../lib/regex'); +const UserAccount = require('../../models/user-account-model'); const userAccountsService = require('./user-accounts-service'); const { MissingParameterError, BadlyFormattedParameterError, NotFoundError, DuplicateIdError, -} = require('../exceptions'); -const teamsRepository = require('../repository/teams-repository'); +} = require('../../exceptions'); +const teamsRepository = require('../../repository/teams-repository'); const uuid = require('uuid'); class TeamsService { @@ -166,14 +166,14 @@ class TeamsService { } } catch (err) { if (err.name === 'CastError') { - throw new BadlyFormattedParameterError('teamId'); + throw new BadlyFormattedParameterError({ parameterName: 'teamId' }); } else { throw err; } } } catch (err) { if (err.name === 'CastError') { - throw new BadlyFormattedParameterError('teamId'); + throw new BadlyFormattedParameterError({ parameterName: 'teamId' }); } else { throw err; } diff --git a/app/services/user-accounts-service.js b/app/services/system/user-accounts-service.js similarity index 86% rename from app/services/user-accounts-service.js rename to app/services/system/user-accounts-service.js index 5108cc85..e42f0b70 100644 --- a/app/services/user-accounts-service.js +++ b/app/services/system/user-accounts-service.js @@ -1,15 +1,10 @@ 'use strict'; const uuid = require('uuid'); -const logger = require('../lib/logger'); -const TeamsRepository = require('../repository/teams-repository'); -const UserAccountsRepository = require('../repository/user-accounts-repository'); -const { - MissingParameterError, - BadlyFormattedParameterError, - DuplicateIdError, - DuplicateEmailError, -} = require('../exceptions'); +const logger = require('../../lib/logger'); +const teamsRepository = require('../../repository/teams-repository'); +const userAccountsRepository = require('../../repository/user-accounts-repository'); +const Exceptions = require('../../exceptions'); class UserAccountsService { constructor(type, repository) { @@ -70,7 +65,7 @@ class UserAccountsService { async retrieveById(userAccountId, options) { try { if (!userAccountId) { - throw new MissingParameterError('userAccountId'); + throw new Exceptions.MissingParameterError('userAccountId'); } const userAccount = await this.repository.retrieveOneById(userAccountId); @@ -87,7 +82,7 @@ class UserAccountsService { return userAccount; } catch (err) { if (err.name === 'CastError') { - throw new BadlyFormattedParameterError('userId'); + throw new Exceptions.BadlyFormattedParameterError({ parameterName: 'userId' }); } else { throw err; } @@ -96,7 +91,7 @@ class UserAccountsService { async retrieveByEmail(email) { if (!email) { - throw new MissingParameterError('email'); + throw new Exceptions.MissingParameterError('email'); } try { @@ -106,7 +101,7 @@ class UserAccountsService { return userAccount; } catch (err) { if (err.name === 'CastError') { - throw new BadlyFormattedParameterError('email'); + throw new Exceptions.BadlyFormattedParameterError({ parameterName: 'email' }); } else { throw err; } @@ -123,7 +118,7 @@ class UserAccountsService { // error if it occurs. const userAccount = await this.repository.retrieveOneByEmail(data.email); if (userAccount) { - throw new DuplicateEmailError(); + throw new Exceptions.DuplicateIdError(); } } @@ -155,7 +150,7 @@ class UserAccountsService { return savedUserAccount; } catch (err) { if (err.name === 'MongoServerError' && err.code === 11000) { - throw new DuplicateIdError(); + throw new Exceptions.DuplicateIdError(); } else { throw err; } @@ -164,7 +159,7 @@ class UserAccountsService { async updateFull(userAccountId, data) { if (!userAccountId) { - throw new MissingParameterError('userAccountId'); + throw new Exceptions.MissingParameterError('userAccountId'); } return await this.repository.updateById(userAccountId, data); @@ -172,7 +167,7 @@ class UserAccountsService { async delete(userAccountId) { if (!userAccountId) { - throw new MissingParameterError('userAccountId'); + throw new Exceptions.MissingParameterError('userAccountId'); } const userAccount = await this.repository.findOneAndDelete(userAccountId); @@ -207,10 +202,10 @@ class UserAccountsService { static async retrieveTeamsByUserId(userAccountId, options) { if (!userAccountId) { - throw new MissingParameterError('userAccountId'); + throw new Exceptions.MissingParameterError('userAccountId'); } - const results = await TeamsRepository.retrieveByUserId(userAccountId, options); + const results = await teamsRepository.retrieveByUserId(userAccountId, options); const teams = results[0].documents; if (options.includePagination) { @@ -236,4 +231,4 @@ class UserAccountsService { } } -module.exports = new UserAccountsService(null, UserAccountsRepository); +module.exports = new UserAccountsService(null, userAccountsRepository); diff --git a/app/services/system/validation-bypasses-service.js b/app/services/system/validation-bypasses-service.js new file mode 100644 index 00000000..dbf34132 --- /dev/null +++ b/app/services/system/validation-bypasses-service.js @@ -0,0 +1,287 @@ +'use strict'; + +const fs = require('fs').promises; + +const { BaseService } = require('../meta-classes'); +const validationBypassesRepository = require('../../repository/validation-bypasses-repository'); +const BypassRuleReasons = require('../../lib/bypass-rule-constants'); +const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); +const logger = require('../../lib/logger'); + +class ValidationBypassesService { + constructor(repository) { + this.repository = repository; + } + + static initializeEventListeners() { + EventBus.on( + Events.SYSTEM_CONFIGURATION_NAMESPACE_CHANGED, + ValidationBypassesService.handleNamespaceChanged.bind(ValidationBypassesService), + ); + + EventBus.on( + Events.SYSTEM_CONFIGURATION_IDENTITY_CHANGED, + ValidationBypassesService.handleIdentityChanged.bind(ValidationBypassesService), + ); + + EventBus.on( + Events.VALIDATION_BYPASS_CHECK_REQUESTED, + ValidationBypassesService.handleBypassCheckRequested.bind(ValidationBypassesService), + ); + + logger.info('ValidationBypassesService: Event listeners initialized'); + } + + /** + * Handle namespace configuration changes. + * Removes previous auto-created rules and creates new ones if a prefix is set. + * @param {Object} payload - Event payload + * @param {Object} payload.namespace - The new namespace configuration ({ prefix, range_start }) + */ + static async handleNamespaceChanged(payload) { + const { namespace } = payload; + const service = module.exports; + + // Always remove previous auto-created rules + await service.removeNamespaceRules(); + + // If a namespace prefix is being set, create new bypass rules + if (namespace?.prefix) { + const { stixTypeToAttackIdMapping } = require('@mitre-attack/attack-data-model'); + const stixTypes = Object.keys(stixTypeToAttackIdMapping); + await service.createNamespaceRules(stixTypes); + } + } + + /** + * Handle a validation bypass check request from the event bus. + * Returns non-bypassed errors and any warnings generated by bypass rules. + * @param {Object} payload + * @param {Array} payload.errors - Validation errors to check + * @param {string} payload.stixType - The STIX type being validated + * @returns {Promise<{ errors: Array, warnings: Array }>} + */ + static async handleBypassCheckRequested(payload) { + const { errors, stixType } = payload; + const service = module.exports; + + const nonBypassed = []; + const warnings = []; + for (const error of errors) { + const result = await service.checkBypassRule(error, stixType); + if (result.bypassed) { + if (result.warningMessage) { + warnings.push({ message: result.warningMessage, path: error.path, code: error.code }); + } + } else { + nonBypassed.push(error); + } + } + return { errors: nonBypassed, warnings }; + } + + async retrieveAll(options) { + const results = await this.repository.retrieveAll(options); + return BaseService.paginate(options, results); + } + + async create(data) { + return await this.repository.save(data); + } + + async retrieveById(id) { + return await this.repository.retrieveById(id); + } + + async deleteById(id) { + return await this.repository.deleteById(id); + } + + /** + * Check if a validation error matches a bypass rule. + * Returns whether the error is bypassed and any warning message to emit. + * A rule matches if it either suppresses the error or converts it to a warning. + * @param {Object} error - The validation error ({ path, code, ... }) + * @param {string} stixType - The STIX type being validated + * @returns {Promise<{ bypassed: boolean, warningMessage: string|null }>} + */ + async checkBypassRule(error, stixType) { + const rules = await this.repository.findAll(); + + const errorPathStr = JSON.stringify(error.path.map(String)); + + for (const rule of rules) { + // A rule is actionable if it suppresses the error or converts it to a warning + if (!rule.suppressError && !rule.warningMessage) continue; + + // Check stixType match ('all' matches any type) + if (rule.stixType !== 'all' && rule.stixType !== stixType) continue; + + // Check errorCode match + if (rule.errorCode !== error.code) continue; + + // Check fieldPath match (coerce both sides to string for numeric index comparison) + const rulePathStr = JSON.stringify(rule.fieldPath.map(String)); + if (rulePathStr !== errorPathStr) continue; + + return { bypassed: true, warningMessage: rule.warningMessage || null }; + } + + return { bypassed: false, warningMessage: null }; + } + + /** + * Handle organization identity configuration changes. + * Creates bypass rules for x_mitre_modified_by_ref validation. + * @param {Object} payload - Event payload + */ + // eslint-disable-next-line no-unused-vars + static async handleIdentityChanged(payload) { + const service = module.exports; + + // Remove any previously auto-created identity bypass rules + await service.removeByReason(BypassRuleReasons.IDENTITY); + + // Create bypass rules for x_mitre_modified_by_ref across all STIX types + const { stixTypeToAttackIdMapping } = require('@mitre-attack/attack-data-model'); + const stixTypes = Object.keys(stixTypeToAttackIdMapping); + await service.createIdentityRules(stixTypes, Events.SYSTEM_CONFIGURATION_IDENTITY_CHANGED); + } + + /** + * Create bypass rules for namespace-prefixed ATT&CK IDs across all relevant STIX types. + * @param {string[]} stixTypes - STIX types that support ATT&CK IDs + */ + async createNamespaceRules(stixTypes) { + const Events = require('../../lib/event-constants'); + const rules = stixTypes.map((stixType) => ({ + fieldPath: ['external_references', '0', 'external_id'], + errorCode: 'custom', + stixType, + suppressError: true, + autoCreated: true, + autoCreatedReason: BypassRuleReasons.NAMESPACE, + triggerEvent: Events.SYSTEM_CONFIGURATION_NAMESPACE_CHANGED, + })); + + for (const rule of rules) { + try { + await this.repository.save(rule); + } catch (err) { + // Skip duplicates — rule may already exist + if (err.name === 'DuplicateIdError') continue; + throw err; + } + } + + logger.info(`Created ${rules.length} namespace validation bypass rules`); + } + + /** + * Create bypass rules for x_mitre_modified_by_ref across all relevant STIX types. + * These bypass the ADM rule that requires x_mitre_modified_by_ref to be the MITRE identity. + * @param {string[]} stixTypes - STIX types that support ATT&CK IDs + * @param {string} triggerEvent - The event that triggered rule creation + */ + async createIdentityRules(stixTypes, triggerEvent) { + const rules = stixTypes.map((stixType) => ({ + fieldPath: ['x_mitre_modified_by_ref'], + errorCode: 'invalid_value', + stixType, + suppressError: true, + autoCreated: true, + autoCreatedReason: BypassRuleReasons.IDENTITY, + triggerEvent, + })); + + for (const rule of rules) { + try { + await this.repository.save(rule); + } catch (err) { + // Skip duplicates — rule may already exist + if (err.name === 'DuplicateIdError') continue; + throw err; + } + } + + logger.info(`Created ${rules.length} identity validation bypass rules`); + } + + /** + * Remove auto-created bypass rules by reason. + * @param {string} reason - The autoCreatedReason value to match + */ + async removeByReason(reason) { + const result = await this.repository.deleteByReason(reason); + logger.info( + `Removed ${result.deletedCount} auto-created validation bypass rules (reason: ${reason})`, + ); + } + + /** + * Remove all auto-created namespace bypass rules. + */ + async removeNamespaceRules() { + await this.removeByReason(BypassRuleReasons.NAMESPACE); + } + + /** + * Load static bypass rules from a JSON file. + * Rules are idempotent — duplicates (by fieldPath + errorCode + stixType) are skipped. + * @param {string} filePath - Path to the JSON file containing bypass rules + */ + async loadStaticRules(filePath) { + if (!filePath) { + logger.info('No path provided for static bypass rules.'); + return; + } + + let fileData; + try { + fileData = await fs.readFile(filePath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') { + logger.info(`Static bypass rules file not found at ${filePath}, skipping.`); + return; + } + throw err; + } + + const rules = JSON.parse(fileData); + + let created = 0; + let skipped = 0; + for (const rule of rules) { + // Strip _comment fields (documentation only, not stored) + // eslint-disable-next-line no-unused-vars + const { _comment, ...ruleData } = rule; + + const bypassRule = { + ...ruleData, + autoCreated: true, + autoCreatedReason: BypassRuleReasons.STATIC, + }; + + try { + await this.repository.save(bypassRule); + created++; + } catch (err) { + if (err.name === 'DuplicateIdError') { + skipped++; + continue; + } + throw err; + } + } + + logger.info( + `Static bypass rules: ${created} created, ${skipped} already existed (from ${filePath})`, + ); + } +} + +const service = new ValidationBypassesService(validationBypassesRepository); +ValidationBypassesService.initializeEventListeners(); + +module.exports = service; diff --git a/app/services/tactics-service.js b/app/services/tactics-service.js deleted file mode 100644 index 3b9ae7d8..00000000 --- a/app/services/tactics-service.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -const config = require('../config/config'); -const BaseService = require('./_base.service'); -const tacticsRepository = require('../repository/tactics-repository'); -const { Tactic: TacticType } = require('../lib/types'); -const techniquesService = require('./techniques-service'); -const { BadlyFormattedParameterError, MissingParameterError } = require('../exceptions'); - -class TacticsService extends BaseService { - static techniquesService = null; - - static techniqueMatchesTactic(tactic) { - return function (technique) { - // A tactic matches if the technique has a kill chain phase such that: - // 1. The phase's kill_chain_name matches one of the tactic's kill chain names (which are derived from the tactic's x_mitre_domains) - // 2. The phase's phase_name matches the tactic's x_mitre_shortname - // Convert the tactic's domain names to kill chain names - const tacticKillChainNames = tactic.stix.x_mitre_domains.map( - (domain) => config.domainToKillChainMap[domain], - ); - return technique.stix.kill_chain_phases.some( - (phase) => - phase.phase_name === tactic.stix.x_mitre_shortname && - tacticKillChainNames.includes(phase.kill_chain_name), - ); - }; - } - - static getPageOfData(data, options) { - const startPos = options.offset; - const endPos = - options.limit === 0 ? data.length : Math.min(options.offset + options.limit, data.length); - - return data.slice(startPos, endPos); - } - - async retrieveTechniquesForTactic(stixId, modified, options) { - // Retrieve the techniques associated with the tactic (the tactic identified by stixId and modified date) - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!modified) { - throw new MissingParameterError('modified'); - } - - try { - const tactic = await this.repository.retrieveOneByVersion(stixId, modified); - - // Note: document is null if not found - if (!tactic) { - return null; - } else { - const allTechniques = await techniquesService.retrieveAll({}); - const filteredTechniques = allTechniques.filter( - TacticsService.techniqueMatchesTactic(tactic), - ); - const pagedResults = TacticsService.getPageOfData(filteredTechniques, options); - - if (options.includePagination) { - const returnValue = { - pagination: { - total: pagedResults.length, - offset: options.offset, - limit: options.limit, - }, - data: pagedResults, - }; - return returnValue; - } else { - return pagedResults; - } - } - } catch (err) { - if (err.name === 'CastError') { - throw new BadlyFormattedParameterError({ parameterName: 'stixId' }); - } else { - throw err; - } - } - } -} - -module.exports = new TacticsService(TacticType, tacticsRepository); diff --git a/app/services/techniques-service.js b/app/services/techniques-service.js deleted file mode 100644 index f928385d..00000000 --- a/app/services/techniques-service.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -const config = require('../config/config'); -const BaseService = require('./_base.service'); -const techniquesRepository = require('../repository/techniques-repository'); - -const { Technique: TechniqueType } = require('../lib/types'); -const { BadlyFormattedParameterError, MissingParameterError } = require('../exceptions'); - -class TechniquesService extends BaseService { - static tacticsService = null; - - static tacticMatchesTechnique(technique) { - return function (tactic) { - // A tactic matches if the technique has a kill chain phase such that: - // 1. The phase's kill_chain_name matches one of the tactic's kill chain names (which are derived from the tactic's x_mitre_domains) - // 2. The phase's phase_name matches the tactic's x_mitre_shortname - - // Convert the tactic's domain names to kill chain names - const tacticKillChainNames = tactic.stix.x_mitre_domains.map( - (domain) => config.domainToKillChainMap[domain], - ); - return technique.stix.kill_chain_phases.some( - (phase) => - phase.phase_name === tactic.stix.x_mitre_shortname && - tacticKillChainNames.includes(phase.kill_chain_name), - ); - }; - } - - static getPageOfData(data, options) { - const startPos = options.offset; - const endPos = - options.limit === 0 ? data.length : Math.min(options.offset + options.limit, data.length); - - return data.slice(startPos, endPos); - } - - async retrieveTacticsForTechnique(stixId, modified, options) { - // Late binding to avoid circular dependency between modules - if (!TechniquesService.tacticsService) { - TechniquesService.tacticsService = require('./tactics-service'); - } - - // Retrieve the tactics associated with the technique (the technique identified by stixId and modified date) - if (!stixId) { - throw new MissingParameterError('stixId'); - } - - if (!modified) { - throw new MissingParameterError('modified'); - } - - try { - const technique = await this.repository.retrieveOneByVersion(stixId, modified); - if (!technique) { - // Note: document is null if not found - return null; - } else { - const allTactics = await TechniquesService.tacticsService.retrieveAll({}); - const filteredTactics = allTactics.filter( - TechniquesService.tacticMatchesTechnique(technique), - ); - const pagedResults = TechniquesService.getPageOfData(filteredTactics, options); - - if (options.includePagination) { - const returnValue = { - pagination: { - total: pagedResults.length, - offset: options.offset, - limit: options.limit, - }, - data: pagedResults, - }; - return returnValue; - } else { - return pagedResults; - } - } - } catch (err) { - if (err.name === 'CastError') { - throw new BadlyFormattedParameterError(); - } else { - throw err; - } - } - } -} - -module.exports = new TechniquesService(TechniqueType, techniquesRepository); diff --git a/app/tests/api/analytics/analytics-includeRefs.spec.js b/app/tests/api/analytics/analytics-includeRefs.spec.js index 5d5262d6..d5e5094a 100644 --- a/app/tests/api/analytics/analytics-includeRefs.spec.js +++ b/app/tests/api/analytics/analytics-includeRefs.spec.js @@ -4,6 +4,7 @@ const { expect } = require('expect'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); @@ -20,22 +21,15 @@ const analyticData = { name: 'test-analytic-with-refs', spec_version: '2.1', type: 'x-mitre-analytic', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'AN0001', - url: 'https://attack.mitre.org/analytics/AN0001', - }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', - x_mitre_platforms: ['windows'], + x_mitre_platforms: ['Windows'], x_mitre_domains: ['enterprise-attack'], x_mitre_log_source_references: [ { - x_mitre_data_component_ref: 'x-mitre-data-component--test-data-component-1', + x_mitre_data_component_ref: 'x-mitre-data-component--3d6c9f1b-7f8a-4f2e-9b1a-2c3d4e5f6a7b', name: 'perm-1', channel: 'perm-1', }, @@ -54,17 +48,10 @@ const detectionStrategyData = { name: 'test-detection-strategy', spec_version: '2.1', type: 'x-mitre-detection-strategy', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'DS0001', - url: 'https://attack.mitre.org/datasources/DS0001', - }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', x_mitre_domains: ['enterprise-attack'], x_mitre_analytic_refs: [], // Will be populated with analytic ID after creation }, @@ -78,14 +65,13 @@ const dataComponentData = { }, }, stix: { - id: 'x-mitre-data-component--test-data-component-1', + id: 'x-mitre-data-component--3d6c9f1b-7f8a-4f2e-9b1a-2c3d4e5f6a7b', name: 'test-data-component', spec_version: '2.1', type: 'x-mitre-data-component', object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', }, }; @@ -103,6 +89,10 @@ describe('Analytics API - includeRefs Parameter', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -119,13 +109,15 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/data-components') .send(dataComponentData) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); createdDataComponent = res.body; expect(createdDataComponent).toBeDefined(); - expect(createdDataComponent.stix.id).toBe('x-mitre-data-component--test-data-component-1'); + expect(createdDataComponent.stix.id).toBe( + 'x-mitre-data-component--3d6c9f1b-7f8a-4f2e-9b1a-2c3d4e5f6a7b', + ); }); it('Setup: Create analytic for testing', async function () { @@ -137,7 +129,7 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/analytics') .send(analyticData) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -156,7 +148,7 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/detection-strategies') .send(detectionStrategyData) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -167,11 +159,11 @@ describe('Analytics API - includeRefs Parameter', function () { }); describe('GET /api/analytics with includeRefs=false (default)', function () { - it('should return analytics without related_to field', async function () { + it('should return analytics without workspace.embedded_relationships field', async function () { const res = await request(app) .get('/api/analytics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -182,16 +174,16 @@ describe('Analytics API - includeRefs Parameter', function () { const testAnalytic = analytics.find((a) => a.stix.id === createdAnalytic.stix.id); expect(testAnalytic).toBeDefined(); - expect(testAnalytic.related_to).toBeUndefined(); + expect(testAnalytic.workspace.embedded_relationships).toBeUndefined(); }); }); describe('GET /api/analytics with includeRefs=true', function () { - it('should return analytics with related_to field containing detection strategies and data components', async function () { + it('should return analytics with workspace.embedded_relationships containing detection strategies and data components', async function () { const res = await request(app) .get('/api/analytics?includeRefs=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -201,34 +193,34 @@ describe('Analytics API - includeRefs Parameter', function () { const testAnalytic = analytics.find((a) => a.stix.id === createdAnalytic.stix.id); expect(testAnalytic).toBeDefined(); - expect(testAnalytic.related_to).toBeDefined(); - expect(Array.isArray(testAnalytic.related_to)).toBe(true); + expect(testAnalytic.workspace.embedded_relationships).toBeDefined(); + expect(Array.isArray(testAnalytic.workspace.embedded_relationships)).toBe(true); - // Should contain the detection strategy - const detectionStrategyRef = testAnalytic.related_to.find( - (ref) => ref.type === 'x-mitre-detection-strategy', + // Should contain the inbound detection strategy relationship + const detectionStrategyRef = testAnalytic.workspace.embedded_relationships.find( + (rel) => rel.stix_id === createdDetectionStrategy.stix.id && rel.direction === 'inbound', ); expect(detectionStrategyRef).toBeDefined(); - expect(detectionStrategyRef.id).toBe(createdDetectionStrategy.stix.id); + expect(detectionStrategyRef.stix_id).toBe(createdDetectionStrategy.stix.id); expect(detectionStrategyRef.name).toBe(createdDetectionStrategy.stix.name); - expect(detectionStrategyRef.attack_id).toBe('DS0001'); - expect(detectionStrategyRef.type).toBe('x-mitre-detection-strategy'); + expect(detectionStrategyRef.attack_id).toBe(createdDetectionStrategy.workspace.attack_id); + expect(detectionStrategyRef.direction).toBe('inbound'); - // Should contain the data component - const dataComponentRef = testAnalytic.related_to.find( - (ref) => ref.type === 'x-mitre-data-component', + // Should contain the outbound data component relationship + const dataComponentRef = testAnalytic.workspace.embedded_relationships.find( + (rel) => rel.stix_id === createdDataComponent.stix.id && rel.direction === 'outbound', ); expect(dataComponentRef).toBeDefined(); - expect(dataComponentRef.id).toBe(createdDataComponent.stix.id); + expect(dataComponentRef.stix_id).toBe(createdDataComponent.stix.id); expect(dataComponentRef.name).toBe(createdDataComponent.stix.name); - expect(dataComponentRef.type).toBe('x-mitre-data-component'); + expect(dataComponentRef.direction).toBe('outbound'); }); it('should work with pagination', async function () { const res = await request(app) .get('/api/analytics?includeRefs=true&includePagination=true&limit=10') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -240,18 +232,18 @@ describe('Analytics API - includeRefs Parameter', function () { const testAnalytic = result.data.find((a) => a.stix.id === createdAnalytic.stix.id); if (testAnalytic) { - expect(testAnalytic.related_to).toBeDefined(); - expect(Array.isArray(testAnalytic.related_to)).toBe(true); + expect(testAnalytic.workspace.embedded_relationships).toBeDefined(); + expect(Array.isArray(testAnalytic.workspace.embedded_relationships)).toBe(true); } }); }); describe('GET /api/analytics/:id with includeRefs parameter', function () { - it('should return analytic without related_to when includeRefs=false', async function () { + it('should return analytic without workspace.embedded_relationships when includeRefs=false', async function () { const res = await request(app) .get(`/api/analytics/${createdAnalytic.stix.id}?includeRefs=false`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -261,14 +253,14 @@ describe('Analytics API - includeRefs Parameter', function () { expect(analytics.length).toBe(1); const analytic = analytics[0]; - expect(analytic.related_to).toBeUndefined(); + expect(analytic.workspace.embedded_relationships).toBeUndefined(); }); - it('should return analytic with related_to when includeRefs=true', async function () { + it('should return analytic with workspace.embedded_relationships when includeRefs=true', async function () { const res = await request(app) .get(`/api/analytics/${createdAnalytic.stix.id}?includeRefs=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -278,24 +270,24 @@ describe('Analytics API - includeRefs Parameter', function () { expect(analytics.length).toBe(1); const analytic = analytics[0]; - expect(analytic.related_to).toBeDefined(); - expect(Array.isArray(analytic.related_to)).toBe(true); - expect(analytic.related_to.length).toBe(2); // detection strategy + data component + expect(analytic.workspace.embedded_relationships).toBeDefined(); + expect(Array.isArray(analytic.workspace.embedded_relationships)).toBe(true); + expect(analytic.workspace.embedded_relationships.length).toBe(2); // detection strategy + data component - // Verify detection strategy reference - const detectionStrategyRef = analytic.related_to.find( - (ref) => ref.type === 'x-mitre-detection-strategy', + // Verify inbound detection strategy relationship + const detectionStrategyRef = analytic.workspace.embedded_relationships.find( + (rel) => rel.stix_id === createdDetectionStrategy.stix.id && rel.direction === 'inbound', ); expect(detectionStrategyRef).toBeDefined(); - expect(detectionStrategyRef.id).toBe(createdDetectionStrategy.stix.id); + expect(detectionStrategyRef.stix_id).toBe(createdDetectionStrategy.stix.id); expect(detectionStrategyRef.name).toBe(createdDetectionStrategy.stix.name); - // Verify data component reference - const dataComponentRef = analytic.related_to.find( - (ref) => ref.type === 'x-mitre-data-component', + // Verify outbound data component relationship + const dataComponentRef = analytic.workspace.embedded_relationships.find( + (rel) => rel.stix_id === createdDataComponent.stix.id && rel.direction === 'outbound', ); expect(dataComponentRef).toBeDefined(); - expect(dataComponentRef.id).toBe(createdDataComponent.stix.id); + expect(dataComponentRef.stix_id).toBe(createdDataComponent.stix.id); expect(dataComponentRef.name).toBe(createdDataComponent.stix.name); }); @@ -303,7 +295,7 @@ describe('Analytics API - includeRefs Parameter', function () { const res = await request(app) .get(`/api/analytics/${createdAnalytic.stix.id}?versions=all&includeRefs=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -312,10 +304,10 @@ describe('Analytics API - includeRefs Parameter', function () { expect(Array.isArray(analytics)).toBe(true); expect(analytics.length).toBeGreaterThanOrEqual(1); - // All versions should have related_to populated + // All versions should have workspace.embedded_relationships populated analytics.forEach((analytic) => { - expect(analytic.related_to).toBeDefined(); - expect(Array.isArray(analytic.related_to)).toBe(true); + expect(analytic.workspace.embedded_relationships).toBeDefined(); + expect(Array.isArray(analytic.workspace.embedded_relationships)).toBe(true); }); }); }); @@ -328,7 +320,9 @@ describe('Analytics API - includeRefs Parameter', function () { stix: { ...analyticData.stix, name: 'analytic-without-refs', - x_mitre_log_source_references: [], + // Omit log source references entirely; the ADM schema requires the + // array to be non-empty when present. + x_mitre_log_source_references: undefined, created: new Date().toISOString(), modified: new Date().toISOString(), }, @@ -338,7 +332,7 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/analytics') .send(analyticWithoutRefs) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); const createdAnalyticWithoutRefs = createRes.body; @@ -346,22 +340,22 @@ describe('Analytics API - includeRefs Parameter', function () { const res = await request(app) .get(`/api/analytics/${createdAnalyticWithoutRefs.stix.id}?includeRefs=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200); const analytics = res.body; const analytic = analytics[0]; - expect(analytic.related_to).toBeDefined(); - expect(Array.isArray(analytic.related_to)).toBe(true); - // Should only contain detection strategies that reference it, no data components - const dataComponentRefs = analytic.related_to.filter( - (ref) => ref.type === 'x-mitre-data-component', + expect(analytic.workspace.embedded_relationships).toBeDefined(); + expect(Array.isArray(analytic.workspace.embedded_relationships)).toBe(true); + // Should only contain detection strategies that reference it (inbound), no data components (outbound) + const dataComponentRefs = analytic.workspace.embedded_relationships.filter( + (rel) => rel.direction === 'outbound', ); expect(dataComponentRefs.length).toBe(0); }); - it('should handle non-existent data component references gracefully', async function () { - // Create an analytic with a non-existent data component reference + it('should reject creation of analytic with non-existent data component references', async function () { + // Attempt to create an analytic with a non-existent data component reference const analyticWithBadRef = { ...analyticData, stix: { @@ -369,7 +363,9 @@ describe('Analytics API - includeRefs Parameter', function () { name: 'analytic-with-bad-ref', x_mitre_log_source_references: [ { - x_mitre_data_component_ref: 'x-mitre-data-component--non-existent', + // Valid STIX id format, but no such data component exists -> 404 in beforeCreate + x_mitre_data_component_ref: + 'x-mitre-data-component--ffffffff-ffff-4fff-8fff-ffffffffffff', name: 'perm-1', channel: 'perm-1', }, @@ -379,30 +375,13 @@ describe('Analytics API - includeRefs Parameter', function () { }, }; - const createRes = await request(app) + // Should return 404 NotFoundError due to validation in beforeCreate + await request(app) .post('/api/analytics') .send(analyticWithBadRef) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) - .expect(201); - - const createdAnalyticWithBadRef = createRes.body; - - const res = await request(app) - .get(`/api/analytics/${createdAnalyticWithBadRef.stix.id}?includeRefs=true`) - .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) - .expect(200); - - const analytics = res.body; - const analytic = analytics[0]; - expect(analytic.related_to).toBeDefined(); - expect(Array.isArray(analytic.related_to)).toBe(true); - // Should not contain the non-existent data component - const dataComponentRefs = analytic.related_to.filter( - (ref) => ref.type === 'x-mitre-data-component', - ); - expect(dataComponentRefs.length).toBe(0); + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); }); it('should handle analytics with missing external references', async function () { @@ -411,7 +390,7 @@ describe('Analytics API - includeRefs Parameter', function () { ...dataComponentData, stix: { ...dataComponentData.stix, - id: 'x-mitre-data-component--no-ext-refs', + id: 'x-mitre-data-component--a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', name: 'data-component-no-ext-refs', external_references: [], created: new Date().toISOString(), @@ -423,7 +402,7 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/data-components') .send(dataComponentWithoutExtRefs) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); const analyticWithNoExtRefDataComponent = { @@ -433,7 +412,8 @@ describe('Analytics API - includeRefs Parameter', function () { name: 'analytic-with-no-ext-ref-data-component', x_mitre_log_source_references: [ { - x_mitre_data_component_ref: 'x-mitre-data-component--no-ext-refs', + x_mitre_data_component_ref: + 'x-mitre-data-component--a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', name: 'perm-1', channel: 'perm-1', }, @@ -447,7 +427,7 @@ describe('Analytics API - includeRefs Parameter', function () { .post('/api/analytics') .send(analyticWithNoExtRefDataComponent) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); const createdAnalyticWithNoExtRefDataComponent = createRes.body; @@ -455,16 +435,19 @@ describe('Analytics API - includeRefs Parameter', function () { const res = await request(app) .get(`/api/analytics/${createdAnalyticWithNoExtRefDataComponent.stix.id}?includeRefs=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200); const analytics = res.body; const analytic = analytics[0]; - const dataComponentRef = analytic.related_to.find( - (ref) => ref.type === 'x-mitre-data-component', + const dataComponentRef = analytic.workspace.embedded_relationships.find( + (rel) => + rel.stix_id === 'x-mitre-data-component--a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' && + rel.direction === 'outbound', ); expect(dataComponentRef).toBeDefined(); - expect(dataComponentRef.attack_id).toBeNull(); + // Even without external_references, the system assigns an attack_id + expect(dataComponentRef.attack_id).toBeDefined(); }); }); diff --git a/app/tests/api/analytics/analytics-pagination.spec.js b/app/tests/api/analytics/analytics-pagination.spec.js index f865de7c..56b841cd 100644 --- a/app/tests/api/analytics/analytics-pagination.spec.js +++ b/app/tests/api/analytics/analytics-pagination.spec.js @@ -1,4 +1,4 @@ -const analyticsService = require('../../../services/analytics-service'); +const analyticsService = require('../../../services/stix/analytics-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -13,31 +13,12 @@ const initialObjectData = { name: 'analytic-1', spec_version: '2.1', type: 'x-mitre-analytic', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'AN9999', - url: 'https://attack.mitre.org/analytics/AN9999', - }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '4.0.0', - x_mitre_platforms: ['windows'], + x_mitre_platforms: ['Windows'], x_mitre_domains: ['enterprise-attack'], - x_mitre_log_source_references: [ - { - x_mitre_data_component_ref: 'data-component-1', - name: 'perm-1', - channel: 'perm-1', - }, - { - x_mitre_data_component_ref: 'data-component-2', - name: 'perm-2', - channel: 'perm-2', - }, - ], x_mitre_mutable_elements: [ { field: 'fieldOne', @@ -55,6 +36,9 @@ const options = { prefix: 'x-mitre-analytic', baseUrl: '/api/analytics', label: 'Analytics', + // 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, }; const paginationTests = new PaginationTests(analyticsService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/analytics/analytics-spec.js b/app/tests/api/analytics/analytics.spec.js similarity index 76% rename from app/tests/api/analytics/analytics-spec.js rename to app/tests/api/analytics/analytics.spec.js index 207b4c92..4348e4ed 100644 --- a/app/tests/api/analytics/analytics-spec.js +++ b/app/tests/api/analytics/analytics.spec.js @@ -4,9 +4,11 @@ const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); +const analyticsRepository = require('../../../repository/analytics-repository'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -23,32 +25,13 @@ const initialObjectData = { name: 'analytic-1', spec_version: '2.1', type: 'x-mitre-analytic', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'AN9999', - url: 'https://attack.mitre.org/analytics/AN9999', - }, - ], + // Note: external_references will be generated by backend description: 'Description of an analytic', object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', - x_mitre_platforms: ['windows'], + x_mitre_platforms: ['Windows'], x_mitre_domains: ['enterprise-attack'], - x_mitre_log_source_references: [ - { - x_mitre_data_component_ref: 'data-component-1', - name: 'perm-1', - channel: 'perm-1', - }, - { - x_mitre_data_component_ref: 'data-component-2', - name: 'perm-2', - channel: 'perm-2', - }, - ], x_mitre_mutable_elements: [ { field: 'fieldOne', @@ -74,6 +57,10 @@ describe('Analytics API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -85,7 +72,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -102,7 +89,7 @@ describe('Analytics API', function () { .post('/api/analytics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -116,7 +103,7 @@ describe('Analytics API', function () { .post('/api/analytics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -132,9 +119,6 @@ describe('Analytics API', function () { expect(Array.isArray(analytic1.stix.x_mitre_domains)).toBe(true); expect(analytic1.stix.x_mitre_domains.length).toBe(1); - expect(analytic1.stix.x_mitre_log_source_references).toBeDefined(); - expect(Array.isArray(analytic1.stix.x_mitre_log_source_references)).toBe(true); - expect(analytic1.stix.x_mitre_log_source_references.length).toBe(2); expect(analytic1.stix.x_mitre_mutable_elements).toBeDefined(); expect(Array.isArray(analytic1.stix.x_mitre_mutable_elements)).toBe(true); expect(analytic1.stix.x_mitre_mutable_elements.length).toBe(2); @@ -144,7 +128,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -159,7 +143,7 @@ describe('Analytics API', function () { await request(app) .get('/api/analytics/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -167,7 +151,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics/' + analytic1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -193,11 +177,6 @@ describe('Analytics API', function () { analytic1.stix.x_mitre_attack_spec_version, ); - expect(analytic.stix.x_mitre_log_source_references).toBeDefined(); - expect(Array.isArray(analytic.stix.x_mitre_log_source_references)).toBe(true); - expect(analytic.stix.x_mitre_log_source_references.length).toBe( - analytic1.stix.x_mitre_log_source_references.length, - ); expect(analytic.stix.x_mitre_mutable_elements).toBeDefined(); expect(Array.isArray(analytic.stix.x_mitre_mutable_elements)).toBe(true); expect(analytic.stix.x_mitre_mutable_elements.length).toBe( @@ -215,7 +194,7 @@ describe('Analytics API', function () { .put('/api/analytics/' + analytic1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -227,64 +206,104 @@ describe('Analytics API', function () { }); it('POST /api/analytics does not create a analytic with the same id and modified date', async function () { - const body = analytic1; + const body = cloneForCreate(analytic1); await request(app) .post('/api/analytics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let analytic2; it('POST /api/analytics should create a new version of a analytic with a duplicate stix.id but different stix.modified date', async function () { - analytic2 = _.cloneDeep(analytic1); - analytic2._id = undefined; - analytic2.__t = undefined; - analytic2.__v = undefined; + const body = cloneForCreate(analytic1); const timestamp = new Date().toISOString(); - analytic2.stix.modified = timestamp; - const body = analytic2; + body.stix.modified = timestamp; const res = await request(app) .post('/api/analytics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); // We expect to get the created analytic - const analytic = res.body; - expect(analytic).toBeDefined(); + analytic2 = res.body; + expect(analytic2).toBeDefined(); }); let analytic3; it('POST /api/analytics should create a new version of a analytic with a duplicate stix.id but different stix.modified date', async function () { - analytic3 = _.cloneDeep(analytic1); - analytic3._id = undefined; - analytic3.__t = undefined; - analytic3.__v = undefined; + const body = cloneForCreate(analytic1); const timestamp = new Date().toISOString(); - analytic3.stix.modified = timestamp; - const body = analytic3; + body.stix.modified = timestamp; const res = await request(app) .post('/api/analytics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); // We expect to get the created analytic - const analytic = res.body; - expect(analytic).toBeDefined(); + analytic3 = res.body; + expect(analytic3).toBeDefined(); + }); + + let analytic4; + it('POST /api/analytics preserves inbound embedded relationships when creating a new version', async function () { + const latestAnalytic = await analyticsRepository.retrieveLatestByStixId(analytic3.stix.id); + + latestAnalytic.workspace.embedded_relationships = [ + ...(latestAnalytic.workspace?.embedded_relationships || []), + { + stix_id: 'x-mitre-detection-strategy--00000000-0000-4000-8000-000000000001', + attack_id: 'DET-TEST', + direction: 'inbound', + }, + ]; + await analyticsRepository.saveDocument(latestAnalytic); + + const nextVersion = cloneForCreate( + latestAnalytic.toObject ? latestAnalytic.toObject() : latestAnalytic, + ); + delete nextVersion.workspace.embedded_relationships; + nextVersion.stix.modified = new Date(Date.now() + 1000).toISOString(); + + const res = await request(app) + .post('/api/analytics') + .send(nextVersion) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + analytic4 = res.body; + expect(analytic4.workspace.embedded_relationships).toBeDefined(); + expect(analytic4.workspace.embedded_relationships).toHaveLength(1); + expect(analytic4.workspace.embedded_relationships[0]).toEqual( + expect.objectContaining({ + stix_id: 'x-mitre-detection-strategy--00000000-0000-4000-8000-000000000001', + attack_id: 'DET-TEST', + direction: 'inbound', + }), + ); + + const attackRef = analytic4.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef).toBeDefined(); + expect(attackRef.url).toBe( + `https://attack.mitre.org/detectionstrategies/DET-TEST#${analytic4.workspace.attack_id}`, + ); }); it('GET /api/analytics returns the latest added analytic', async function () { const res = await request(app) - .get('/api/analytics/' + analytic3.stix.id) + .get('/api/analytics/' + analytic4.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -294,30 +313,30 @@ describe('Analytics API', function () { expect(Array.isArray(analytics)).toBe(true); expect(analytics.length).toBe(1); const analytic = analytics[0]; - expect(analytic.stix.id).toBe(analytic3.stix.id); - expect(analytic.stix.modified).toBe(analytic3.stix.modified); + expect(analytic.stix.id).toBe(analytic4.stix.id); + expect(analytic.stix.modified).toBe(analytic4.stix.modified); }); it('GET /api/analytics returns all added analytics', async function () { const res = await request(app) .get('/api/analytics/' + analytic1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); - // We expect to get two analytics in an array + // We expect to get four analytics in an array const analytics = res.body; expect(analytics).toBeDefined(); expect(Array.isArray(analytics)).toBe(true); - expect(analytics.length).toBe(3); + expect(analytics.length).toBe(4); }); it('GET /api/analytics/:id/modified/:modified returns the first added analytic', async function () { const res = await request(app) .get('/api/analytics/' + analytic1.stix.id + '/modified/' + analytic1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -333,7 +352,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics/' + analytic2.stix.id + '/modified/' + analytic2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -348,21 +367,21 @@ describe('Analytics API', function () { it('DELETE /api/analytics/:id should not delete a analytic when the id cannot be found', async function () { await request(app) .delete('/api/analytics/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/analytics/:id/modified/:modified deletes a analytic', async function () { await request(app) .delete('/api/analytics/' + analytic1.stix.id + '/modified/' + analytic1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/analytics/:id should delete all the analytics with the same stix id', async function () { await request(app) .delete('/api/analytics/' + analytic2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -378,11 +397,10 @@ describe('Analytics API', function () { name: 'Network Connection Creation Detection Strategy', spec_version: '2.1', type: 'x-mitre-detection-strategy', - description: 'Strategy for detecting network connections', + // Note: the x-mitre-detection-strategy ADM schema does not define a description field object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', x_mitre_domains: ['enterprise-attack'], x_mitre_analytic_refs: [], }, @@ -402,7 +420,7 @@ describe('Analytics API', function () { .post('/api/analytics') .send(searchAnalyticData) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -421,7 +439,7 @@ describe('Analytics API', function () { .post('/api/detection-strategies') .send(detectionStrategyData) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -434,10 +452,12 @@ describe('Analytics API', function () { }); it('GET /api/analytics?search should find analytic by its own name', async function () { + // Analytics are named after their attack_id (e.g., AN0001) + const searchTerm = searchTestAnalytic.stix.name; const res = await request(app) - .get('/api/analytics?search=Search Test') + .get(`/api/analytics?search=${encodeURIComponent(searchTerm)}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -452,7 +472,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics?search=Network Connection Creation') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -467,7 +487,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics?search=Network Connection') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -482,7 +502,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics?search=NonExistentTerm') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -497,14 +517,14 @@ describe('Analytics API', function () { if (searchTestDetectionStrategy) { await request(app) .delete('/api/detection-strategies/' + searchTestDetectionStrategy.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); } if (searchTestAnalytic) { await request(app) .delete('/api/analytics/' + searchTestAnalytic.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); } }); @@ -514,7 +534,7 @@ describe('Analytics API', function () { const res = await request(app) .get('/api/analytics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/assets/assets.spec.js b/app/tests/api/assets/assets.spec.js index 478b44b1..c8b9be55 100644 --- a/app/tests/api/assets/assets.spec.js +++ b/app/tests/api/assets/assets.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -24,16 +24,16 @@ const initialObjectData = { spec_version: '2.1', type: 'x-mitre-asset', description: 'This is an asset.', - x_mitre_sectors: ['sector placeholder 1'], + x_mitre_sectors: ['Electric'], x_mitre_related_assets: [ { name: 'related asset 1', - related_asset_sectors: ['related asset sector placeholder 1'], + related_asset_sectors: ['Water and Wastewater'], description: 'This is a related asset', }, { name: 'related asset 2', - related_asset_sectors: ['related asset sector placeholder 2'], + related_asset_sectors: ['Manufacturing'], description: 'This is another related asset', }, ], @@ -49,6 +49,10 @@ describe('Assets API', function () { let passportCookie; before(async function () { + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Establish the database connection // Use an in-memory database that we spin up for the test await database.initializeConnection(); @@ -67,7 +71,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -84,7 +88,7 @@ describe('Assets API', function () { .post('/api/assets') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -98,7 +102,7 @@ describe('Assets API', function () { .post('/api/assets') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -124,7 +128,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -139,7 +143,7 @@ describe('Assets API', function () { await request(app) .get('/api/assets/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -147,7 +151,7 @@ describe('Assets API', function () { await request(app) .get('/api/assets/not-an-id?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -155,7 +159,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets/' + asset1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -199,7 +203,7 @@ describe('Assets API', function () { .put('/api/assets/' + asset1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -211,21 +215,20 @@ describe('Assets API', function () { }); it('POST /api/assets does not create an asset with the same id and modified date', async function () { - const body = asset1; + const body = cloneForCreate(asset1); + // Keep the same modified date to trigger duplicate detection + body.stix.modified = asset1.stix.modified; await request(app) .post('/api/assets') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let asset2; it('POST /api/assets should create a new version of an asset with a duplicate stix.id but different stix.modified date', async function () { - asset2 = _.cloneDeep(asset1); - asset2._id = undefined; - asset2.__t = undefined; - asset2.__v = undefined; + asset2 = cloneForCreate(asset1); const timestamp = new Date().toISOString(); asset2.stix.modified = timestamp; const body = asset2; @@ -233,7 +236,7 @@ describe('Assets API', function () { .post('/api/assets') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -244,10 +247,7 @@ describe('Assets API', function () { let asset3; it('POST /api/assets should create a new version of an asset with a duplicate stix.id but different stix.modified date', async function () { - asset3 = _.cloneDeep(asset1); - asset3._id = undefined; - asset3.__t = undefined; - asset3.__v = undefined; + asset3 = cloneForCreate(asset1); const timestamp = new Date().toISOString(); asset3.stix.modified = timestamp; const body = asset3; @@ -255,7 +255,7 @@ describe('Assets API', function () { .post('/api/assets') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -268,7 +268,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets/' + asset3.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -286,7 +286,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets/' + asset1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -301,7 +301,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets/' + asset1.stix.id + '/modified/' + asset1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -317,7 +317,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets/' + asset2.stix.id + '/modified/' + asset2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -332,21 +332,21 @@ describe('Assets API', function () { it('DELETE /api/assets/:id should not delete an asset when the id cannot be found', async function () { await request(app) .delete('/api/assets/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/assets/:id/modified/:modified deletes an asset', async function () { await request(app) .delete('/api/assets/' + asset1.stix.id + '/modified/' + asset1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/assets/:id should delete all the assets with the same stix id', async function () { await request(app) .delete('/api/assets/' + asset2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -354,7 +354,7 @@ describe('Assets API', function () { const res = await request(app) .get('/api/assets') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/attack-objects/attack-objects-1.json b/app/tests/api/attack-objects/attack-objects-1.json index ca745e3c..74cab74e 100644 --- a/app/tests/api/attack-objects/attack-objects-1.json +++ b/app/tests/api/attack-objects/attack-objects-1.json @@ -110,11 +110,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0001", - "external_id": "TX0001" + "url": "https://attack.mitre.org/techniques/T9001", + "external_id": "T9001" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -140,11 +141,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0002", - "external_id": "TX0002" + "url": "https://attack.mitre.org/techniques/T9002", + "external_id": "T9002" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -170,11 +172,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0003", - "external_id": "TX0003" + "url": "https://attack.mitre.org/techniques/T9003", + "external_id": "T9003" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -204,11 +207,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0004", - "external_id": "TX0004" + "url": "https://attack.mitre.org/techniques/T9004", + "external_id": "T9004" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -238,11 +242,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0005", - "external_id": "TX0005" + "url": "https://attack.mitre.org/techniques/T9005", + "external_id": "T9005" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -257,8 +262,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0001", - "url": "https://attack.mitre.org/tactics/TAX0001" + "external_id": "TA9001", + "url": "https://attack.mitre.org/tactics/TA9001" } ], "id": "x-mitre-tactic--d932e995-5207-4347-88ec-b52b32762357", @@ -280,8 +285,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0002", - "url": "https://attack.mitre.org/tactics/TAX0002" + "external_id": "TA9002", + "url": "https://attack.mitre.org/tactics/TA9002" } ], "id": "x-mitre-tactic--953fd636-2af2-4cad-adc8-5d7903295dba", @@ -303,8 +308,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0003", - "url": "https://attack.mitre.org/tactics/TAX0003" + "external_id": "TA9003", + "url": "https://attack.mitre.org/tactics/TA9003" } ], "id": "x-mitre-tactic--c1768fcd-abe2-462f-95cc-bfedbc8c64c6", @@ -326,8 +331,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0004", - "url": "https://attack.mitre.org/tactics/TAX0004" + "external_id": "TA9004", + "url": "https://attack.mitre.org/tactics/TA9004" } ], "id": "x-mitre-tactic--60cf8617-223d-47db-b15e-0cdf3c1d6f52", @@ -349,8 +354,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0005", - "url": "https://attack.mitre.org/tactics/TAX0005" + "external_id": "TA9005", + "url": "https://attack.mitre.org/tactics/TA9005" } ], "id": "x-mitre-tactic--0b518521-aae3-4169-9f7a-cdf8455a2d14", @@ -372,8 +377,8 @@ "external_references": [ { "source_name": "mitre-mobile-attack", - "external_id": "TAX0006", - "url": "https://attack.mitre.org/tactics/TAX0006" + "external_id": "TA9006", + "url": "https://attack.mitre.org/tactics/TA9006" } ], "id": "x-mitre-tactic--f5a8c28b-3002-4d5b-9571-3f68b2b57e29", @@ -396,8 +401,8 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "SX3333", - "url": "https://attack.mitre.org/software/SX3333" + "external_id": "S9001", + "url": "https://attack.mitre.org/software/S9001" }, { "source_name": "source-1", "external_id": "s1" } ], @@ -405,9 +410,10 @@ "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.1", "x_mitre_aliases": ["software-1"], - "x_mitre_platforms": ["platform-1"], + "x_mitre_platforms": ["Android"], "x_mitre_contributors": ["contributor-1", "contributor-2"], "x_mitre_domains": ["mobile-attack"], + "x_mitre_attack_spec_version": "2.1.0", "created": "2022-06-01T00:00:00.000Z", "modified": "2022-06-01T00:00:00.000Z" }, @@ -420,13 +426,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "GX1111", - "url": "https://attack.mitre.org/groups/GX1111" + "external_id": "G9001", + "url": "https://attack.mitre.org/groups/G9001" }, { "source_name": "source-1", "external_id": "s1" } ], "object_marking_refs": ["marking-definition--c2a0b8f8-51d4-4702-8e42-ce7a65235bce"], "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + "x_mitre_version": "1.1", + "x_mitre_attack_spec_version": "2.1.0", "created": "2022-06-01T00:00:00.000Z", "modified": "2022-06-01T00:00:00.000Z" }, @@ -439,14 +447,16 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "T9999", - "url": "https://attack.mitre.org/mitigations/T9999" + "external_id": "M9001", + "url": "https://attack.mitre.org/mitigations/M9001" }, { "source_name": "source-1", "external_id": "s1" } ], "object_marking_refs": ["marking-definition--c2a0b8f8-51d4-4702-8e42-ce7a65235bce"], "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.1", + "x_mitre_domains": ["enterprise-attack"], + "x_mitre_attack_spec_version": "2.1.0", "created": "2022-06-01T00:00:00.000Z", "modified": "2022-06-01T00:00:00.000Z" }, @@ -459,6 +469,7 @@ "target_ref": "attack-pattern--757471d4-d931-4109-82dd-cdd50c04744e", "object_marking_refs": ["marking-definition--c2a0b8f8-51d4-4702-8e42-ce7a65235bce"], "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", + "x_mitre_attack_spec_version": "2.1.0", "created": "2022-06-01T00:00:00.000Z", "modified": "2022-06-01T00:00:00.000Z" }, @@ -482,8 +493,7 @@ "created": "2020-01-01T00:00:00.000Z", "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "definition_type": "statement", - "spec_version": "2.1", - "x_mitre_domains": ["ics-attack"] + "spec_version": "2.1" } ] } diff --git a/app/tests/api/attack-objects/attack-objects-2.json b/app/tests/api/attack-objects/attack-objects-2.json index ae13f878..cc065b43 100644 --- a/app/tests/api/attack-objects/attack-objects-2.json +++ b/app/tests/api/attack-objects/attack-objects-2.json @@ -54,11 +54,12 @@ "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0001", - "external_id": "TX0001" + "url": "https://attack.mitre.org/techniques/T9001", + "external_id": "T9001" } ], - "x_mitre_data_sources": [], + "x_mitre_data_sources": ["Process: Process Creation"], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -84,8 +85,7 @@ "created": "2020-01-01T00:00:00.000Z", "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "definition_type": "statement", - "spec_version": "2.1", - "x_mitre_domains": ["ics-attack"] + "spec_version": "2.1" } ] } diff --git a/app/tests/api/attack-objects/attack-objects-pagination.spec.js b/app/tests/api/attack-objects/attack-objects-pagination.spec.js index 482cafb1..206320fa 100644 --- a/app/tests/api/attack-objects/attack-objects-pagination.spec.js +++ b/app/tests/api/attack-objects/attack-objects-pagination.spec.js @@ -1,4 +1,4 @@ -const techniquesService = require('../../../services/techniques-service'); +const techniquesService = require('../../../services/stix/techniques-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -13,15 +13,12 @@ const initialObjectData = { spec_version: '2.1', type: 'attack-pattern', description: 'This is a technique.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], 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: 'kill-chain-name-1', phase_name: 'phase-1' }], - x_mitre_data_sources: ['data-source-1', 'data-source-2'], + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], x_mitre_detection: 'detection text', x_mitre_is_subtechnique: false, - x_mitre_impact_type: ['impact-1'], - x_mitre_platforms: ['platform-1', 'platform-2'], + x_mitre_platforms: ['Linux', 'macOS'], }, }; @@ -32,6 +29,9 @@ const options = { baseUrl: '/api/attack-objects', label: 'Attack Objects', state: 'work-in-progress', + // 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, }; const paginationTests = new PaginationTests(techniquesService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/attack-objects/attack-objects.spec.js b/app/tests/api/attack-objects/attack-objects.spec.js index 5c9ff8da..287d8bc4 100644 --- a/app/tests/api/attack-objects/attack-objects.spec.js +++ b/app/tests/api/attack-objects/attack-objects.spec.js @@ -6,10 +6,11 @@ const { expect } = require('expect'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const AttackObject = require('../../../models/attack-object-model'); +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); -const collectionBundlesService = require('../../../services/collection-bundles-service'); +const collectionBundlesService = require('../../../services/stix/collection-bundles-service'); logger.level = 'debug'; // test malware object @@ -24,10 +25,15 @@ const malwareObject = { name: 'software-2', spec_version: '2.1', type: 'malware', - description: 'This is a malware type of software.', + description: + 'This is a malware type of software, with a URL that it should not have (https://attack.mitre.org/software/SW0001)', is_family: false, + external_references: [{ source_name: 'source-1', external_id: 's1' }], object_marking_refs: ['marking-definition--c2a0b8f8-51d4-4702-8e42-ce7a65235bce'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', + x_mitre_aliases: ['software-2'], + x_mitre_platforms: ['Android'], x_mitre_contributors: ['contributor-mk', 'contributor-cm'], x_mitre_domains: ['mobile-attack'], created: '2023-03-01T00:00:00.000Z', @@ -55,6 +61,10 @@ describe('ATT&CK Objects API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -89,7 +99,7 @@ describe('ATT&CK Objects API', function () { const res = await request(app) .get('/api/attack-objects') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -114,7 +124,7 @@ describe('ATT&CK Objects API', function () { const res = await request(app) .get('/api/attack-objects?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -139,7 +149,7 @@ describe('ATT&CK Objects API', function () { const res = await request(app) .get('/api/attack-objects?attackId=T1234') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -150,11 +160,11 @@ describe('ATT&CK Objects API', function () { expect(attackObjects.length).toBe(0); }); - it('GET /api/attack-objects returns the group with ATT&CK ID GX1111', async function () { + it('GET /api/attack-objects returns the group with ATT&CK ID G9001', async function () { const res = await request(app) - .get('/api/attack-objects?attackId=GX1111') + .get('/api/attack-objects?attackId=G9001') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -165,11 +175,11 @@ describe('ATT&CK Objects API', function () { expect(attackObjects.length).toBe(1); }); - it('GET /api/attack-objects returns the software with ATT&CK ID SX3333', async function () { + it('GET /api/attack-objects returns the software with ATT&CK ID S9001', async function () { const res = await request(app) - .get('/api/attack-objects?attackId=SX3333') + .get('/api/attack-objects?attackId=S9001') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -180,11 +190,11 @@ describe('ATT&CK Objects API', function () { expect(attackObjects.length).toBe(1); }); - it('GET /api/attack-objects returns the technique with ATT&CK ID TX0001', async function () { + it('GET /api/attack-objects returns the technique with ATT&CK ID T9001', async function () { const res = await request(app) - .get('/api/attack-objects?attackId=TX0001') + .get('/api/attack-objects?attackId=T9001') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -197,9 +207,9 @@ describe('ATT&CK Objects API', function () { it('GET /api/attack-objects returns the objects with the requested ATT&CK IDs', async function () { const res = await request(app) - .get('/api/attack-objects?attackId=GX1111&attackId=SX3333&attackId=TX0001') + .get('/api/attack-objects?attackId=G9001&attackId=S9001&attackId=T9001') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -214,7 +224,7 @@ describe('ATT&CK Objects API', function () { const res = await request(app) .get('/api/attack-objects?search=nabu') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -235,7 +245,7 @@ describe('ATT&CK Objects API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -253,7 +263,7 @@ describe('ATT&CK Objects API', function () { `/api/attack-objects?lastUpdatedBy=${software1.workspace.workflow.created_by_user_account}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/campaigns/campaigns.spec.js b/app/tests/api/campaigns/campaigns.spec.js index c70970c4..c500ecc8 100644 --- a/app/tests/api/campaigns/campaigns.spec.js +++ b/app/tests/api/campaigns/campaigns.spec.js @@ -1,12 +1,13 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); + +const { cloneForCreate } = require('../../shared/clone-for-create'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const Campaign = require('../../../models/campaign-model'); -const markingDefinitionService = require('../../../services/marking-definitions-service'); -const systemConfigurationService = require('../../../services/system-configuration-service'); +const markingDefinitionService = require('../../../services/stix/marking-definitions-service'); +const systemConfigurationService = require('../../../services/system/system-configuration-service'); const config = require('../../../config/config'); const login = require('../../shared/login'); @@ -85,6 +86,10 @@ describe('Campaigns API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -98,7 +103,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -115,7 +120,7 @@ describe('Campaigns API', function () { .post('/api/campaigns') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -124,12 +129,12 @@ describe('Campaigns API', function () { const timestamp = new Date().toISOString(); initialObjectData.stix.created = timestamp; initialObjectData.stix.modified = timestamp; - const body = initialObjectData; + const body = cloneForCreate(initialObjectData); const res = await request(app) .post('/api/campaigns') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -148,7 +153,8 @@ describe('Campaigns API', function () { expect(campaign1.stix.aliases).toBeDefined(); expect(Array.isArray(campaign1.stix.aliases)).toBe(true); - expect(campaign1.stix.aliases.length).toBe(1); + expect(campaign1.stix.aliases.length).toBe(2); + expect(campaign1.stix.aliases[0]).toBe(campaign1.stix.name); // object_marking_refs should contain the default marking definition expect(campaign1.stix.object_marking_refs).toBeDefined(); @@ -161,7 +167,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -176,7 +182,7 @@ describe('Campaigns API', function () { await request(app) .get('/api/campaigns/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -184,7 +190,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns/' + campaign1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -221,7 +227,7 @@ describe('Campaigns API', function () { .put('/api/campaigns/' + campaign1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -233,12 +239,12 @@ describe('Campaigns API', function () { }); it('POST /api/campaigns does not create a campaign with the same id and modified date', async function () { - const body = campaign1; + const body = cloneForCreate(campaign1); await request(app) .post('/api/campaigns') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); @@ -249,10 +255,7 @@ describe('Campaigns API', function () { 'This is the second default marking definition'; defaultMarkingDefinition2 = await addDefaultMarkingDefinition(markingDefinitionData); - campaign2 = _.cloneDeep(campaign1); - campaign2._id = undefined; - campaign2.__t = undefined; - campaign2.__v = undefined; + campaign2 = cloneForCreate(campaign1); const timestamp = new Date().toISOString(); campaign2.stix.modified = timestamp; campaign2.stix.description = 'This is a new version of a campaign. Green.'; @@ -262,7 +265,7 @@ describe('Campaigns API', function () { .post('/api/campaigns') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -275,7 +278,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns/' + campaign2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -304,7 +307,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns/' + campaign1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -319,7 +322,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns/' + campaign1.stix.id + '/modified/' + campaign1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -335,7 +338,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns/' + campaign2.stix.id + '/modified/' + campaign2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -349,10 +352,7 @@ describe('Campaigns API', function () { let campaign3; it('POST /api/campaigns should create a new campaign with a different stix.id', async function () { - const campaign = _.cloneDeep(initialObjectData); - campaign._id = undefined; - campaign.__t = undefined; - campaign.__v = undefined; + const campaign = cloneForCreate(initialObjectData); campaign.stix.id = undefined; const timestamp = new Date().toISOString(); campaign.stix.created = timestamp; @@ -364,7 +364,7 @@ describe('Campaigns API', function () { .post('/api/campaigns') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -377,7 +377,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns?search=green') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -399,7 +399,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns?search=blue') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -414,7 +414,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns?search=brown') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -435,21 +435,21 @@ describe('Campaigns API', function () { it('DELETE /api/campaigns/:id should not delete a campaign when the id cannot be found', async function () { await request(app) .delete('/api/campaigns/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/campaigns/:id/modified/:modified deletes a campaign', async function () { await request(app) .delete('/api/campaigns/' + campaign3.stix.id + '/modified/' + campaign3.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/campaigns/:id should delete all of the campaigns with the stix id', async function () { await request(app) .delete('/api/campaigns/' + campaign2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -457,7 +457,7 @@ describe('Campaigns API', function () { const res = await request(app) .get('/api/campaigns') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/collection-bundles/collection-bundles.spec.js b/app/tests/api/collection-bundles/collection-bundles.spec.js index 40d5aade..4ae586a5 100644 --- a/app/tests/api/collection-bundles/collection-bundles.spec.js +++ b/app/tests/api/collection-bundles/collection-bundles.spec.js @@ -430,6 +430,108 @@ const collectionData6 = { }, }; +const attackIdsByObjectId = new Map([ + ['attack-pattern--2204c371-6100-4ae0-82f3-25c07c29772a', 'T9001'], + ['attack-pattern--82f04b1e-5371-4a6f-be06-411f0f43b483', 'T9002'], + ['attack-pattern--14fbfb6a-c4d9-4c3b-a7ef-f8df23e3b22b', 'T9003'], + ['attack-pattern--fb4f094c-ad39-4dba-b459-5e314f6d6c8d', 'T9004'], + ['attack-pattern--44fc382e-0b71-4f5d-9110-fb2e35452d98', 'T9005'], + ['course-of-action--25dc1ce8-eb55-4333-ae30-a7cb4f5894a1', 'M9001'], + ['course-of-action--e944670c-d03a-4e93-a21c-b3d4c53ec4c9', 'M9002'], + ['malware--04227b24-7817-4de1-9050-b7b1b57f5866', 'S9001'], + ['intrusion-set--d69e568e-9ac8-4c08-b32c-d93b43ba9172', 'G9001'], +]); + +function setAttackExternalReference(stixObject, sourceName, externalId, urlSegment) { + const existingReferences = Array.isArray(stixObject.external_references) + ? stixObject.external_references.slice(1) + : []; + + stixObject.external_references = [ + { + source_name: sourceName, + external_id: externalId, + url: `https://attack.mitre.org/${urlSegment}/${externalId}`, + }, + ...existingReferences, + ]; +} + +function normalizeBundleObjectForAdm(stixObject) { + if (stixObject.type === 'x-mitre-collection') { + stixObject.x_mitre_version = stixObject.x_mitre_version || '1.0'; + stixObject.x_mitre_attack_spec_version = + stixObject.x_mitre_attack_spec_version || currentAttackSpecVersion; + return; + } + + if (stixObject.type === 'attack-pattern') { + const externalId = attackIdsByObjectId.get(stixObject.id); + if (externalId) { + setAttackExternalReference(stixObject, 'mitre-attack', externalId, 'techniques'); + } + stixObject.x_mitre_attack_spec_version = + stixObject.x_mitre_attack_spec_version || currentAttackSpecVersion; + stixObject.x_mitre_domains = ['enterprise-attack']; + stixObject.kill_chain_phases = [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }]; + stixObject.x_mitre_data_sources = ['Process: Process Creation']; + stixObject.x_mitre_platforms = ['Linux']; + delete stixObject.x_mitre_impact_type; + return; + } + + if (stixObject.type === 'course-of-action') { + const externalId = attackIdsByObjectId.get(stixObject.id); + if (externalId) { + setAttackExternalReference(stixObject, 'mitre-attack', externalId, 'mitigations'); + } + stixObject.x_mitre_attack_spec_version = + stixObject.x_mitre_attack_spec_version || currentAttackSpecVersion; + stixObject.x_mitre_domains = ['enterprise-attack']; + return; + } + + if (stixObject.type === 'malware') { + const externalId = attackIdsByObjectId.get(stixObject.id); + if (externalId) { + setAttackExternalReference(stixObject, 'mitre-attack', externalId, 'software'); + } + stixObject.x_mitre_attack_spec_version = + stixObject.x_mitre_attack_spec_version || currentAttackSpecVersion; + stixObject.x_mitre_domains = ['mobile-attack']; + stixObject.x_mitre_platforms = ['Android']; + stixObject.is_family = false; + return; + } + + if (stixObject.type === 'intrusion-set') { + const externalId = attackIdsByObjectId.get(stixObject.id); + if (externalId) { + setAttackExternalReference(stixObject, 'mitre-attack', externalId, 'groups'); + } + stixObject.x_mitre_attack_spec_version = + stixObject.x_mitre_attack_spec_version || currentAttackSpecVersion; + stixObject.x_mitre_domains = ['enterprise-attack']; + stixObject.aliases = [ + stixObject.name, + ...(stixObject.aliases || []).filter((alias) => alias !== stixObject.name), + ]; + } +} + +function normalizeBundleForAdm(bundle) { + for (const stixObject of bundle.objects) { + normalizeBundleObjectForAdm(stixObject); + } +} + +normalizeBundleForAdm(collectionBundleData); +normalizeBundleForAdm(collectionBundleData2); +normalizeBundleForAdm(collectionBundleData4); +normalizeBundleForAdm(collectionBundleData5); +collectionData6.stix.x_mitre_version = '1.0'; +collectionData6.stix.x_mitre_attack_spec_version = currentAttackSpecVersion; + describe('Collection Bundles Basic API', function () { let app; let passportCookie; @@ -442,6 +544,10 @@ describe('Collection Bundles Basic API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; reusable valid fixture objects are normalized for ADM below + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -455,7 +561,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -465,7 +571,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); const errorResult = response.body; @@ -478,7 +584,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); const errorResult = response.body; @@ -491,7 +597,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); const errorResult = response.body; @@ -504,7 +610,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); const errorResult = response.body; @@ -517,7 +623,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles?forceImport=attack-spec-version-violations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); }); @@ -527,7 +633,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles?checkOnly=true') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -545,7 +651,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles?previewOnly=true') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -561,14 +667,14 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); collection1 = response.body; expect(collection1).toBeDefined(); expect(collection1.workspace.import_categories.additions.length).toBe(8); - expect(collection1.workspace.import_categories.errors.length).toBe(4); + expect(collection1.workspace.import_categories.errors.length).toBe(5); }); it('POST /api/collection-bundles does not show a successful preview with a duplicate collection bundle', async function () { @@ -577,7 +683,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles?checkOnly=true') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -587,7 +693,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -597,7 +703,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles?forceImport=duplicate-collection') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); }); @@ -613,7 +719,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(updatedCollection) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -621,14 +727,14 @@ describe('Collection Bundles Basic API', function () { expect(collection2).toBeDefined(); expect(collection2.workspace.import_categories.changes.length).toBe(1); expect(collection2.workspace.import_categories.duplicates.length).toBe(6); - expect(collection2.workspace.import_categories.errors.length).toBe(4); + expect(collection2.workspace.import_categories.errors.length).toBe(5); }); it('GET /api/references returns the malware added reference', async function () { const response = await request(app) .get('/api/references?sourceName=' + encodeURIComponent('malware-1 source')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -642,7 +748,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get('/api/references?sourceName=' + encodeURIComponent('xyzzy')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -657,7 +763,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get('/api/references?sourceName=' + encodeURIComponent('group source')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -672,7 +778,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get('/api/references?sourceName=' + encodeURIComponent('group-xyzzy')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -687,7 +793,7 @@ describe('Collection Bundles Basic API', function () { await request(app) .get('/api/collection-bundles?collectionId=not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -697,7 +803,7 @@ describe('Collection Bundles Basic API', function () { `/api/collection-bundles?previewOnly=true&collectionId=x-mitre-collection--30ee11cf-0a05-4d9e-ab54-9b8563669647`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -712,7 +818,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get(`/api/collection-bundles?collectionId=${collectionId}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -730,7 +836,7 @@ describe('Collection Bundles Basic API', function () { `/api/collection-bundles?collectionId=${collectionId}&collectionModified=${encodeURIComponent(collectionTimestamp)}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -747,7 +853,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collections') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); }); @@ -755,7 +861,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get(`/api/collection-bundles?collectionId=${collectionId6}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -770,7 +876,7 @@ describe('Collection Bundles Basic API', function () { const res = await request(app) .get(`/api/collection-bundles?collectionId=${collectionId6}&includeNotes=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -795,7 +901,7 @@ describe('Collection Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -808,3 +914,118 @@ describe('Collection Bundles Basic API', function () { await database.closeConnection(); }); }); + +describe('Collection Bundles Streaming API', function () { + let app; + let passportCookie; + + before(async function () { + // Establish the database connection + await database.initializeConnection(); + + // Check for a valid database configuration + await databaseConfiguration.checkSystemConfiguration(); + + // Enable ADM validation; reusable valid fixture objects are normalized for ADM below + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + + // Initialize the express app + app = await require('../../../index').initializeApp(); + + // Log into the app + passportCookie = await login.loginAnonymous(app); + }); + + it('POST /api/collection-bundles?stream=true returns SSE headers', async function () { + const body = collectionBundleData; + + // Just verify we get SSE headers back - don't try to parse the full stream + const response = await request(app) + .post('/api/collection-bundles?stream=true') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Verify SSE headers + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/event-stream'); + expect(response.headers['cache-control']).toBe('no-cache'); + expect(response.headers.connection).toBe('keep-alive'); + }); + + it('POST /api/collection-bundles?stream=true returns SSE headers for errors', async function () { + const body = collectionBundleData3; // Empty bundle with no collection + + const response = await request(app) + .post('/api/collection-bundles?stream=true') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should still return SSE headers even for errors + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/event-stream'); + }); + + it('POST /api/collection-bundles?stream=true&previewOnly=true returns SSE headers', async function () { + const body = collectionBundleData; + + const response = await request(app) + .post('/api/collection-bundles?stream=true&previewOnly=true') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/event-stream'); + }); + + it('POST /api/collection-bundles without stream parameter uses regular import (no SSE)', async function () { + // Delete the previously imported collection so we can reimport it + const timestamp = new Date().toISOString(); + const updatedBundle = _.cloneDeep(collectionBundleData); + updatedBundle.objects[0].modified = timestamp; + updatedBundle.objects[0].id = 'x-mitre-collection--aaaaaaaa-0a05-4d9e-ab54-9b8563669647'; + + const body = updatedBundle; + const response = await request(app) + .post('/api/collection-bundles') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // Verify standard JSON response, not SSE + expect(response.headers['content-type']).toMatch(/json/); + expect(response.headers['content-type']).not.toBe('text/event-stream'); + + // Verify we got a collection object directly + const collection = response.body; + expect(collection).toBeDefined(); + expect(collection.workspace).toBeDefined(); + expect(collection.stix).toBeDefined(); + }); + + it('POST /api/collection-bundles?stream=true with forceImport returns SSE headers', async function () { + const timestamp = new Date().toISOString(); + const uniqueBundle = _.cloneDeep(collectionBundleData); + uniqueBundle.objects[0].modified = timestamp; + uniqueBundle.objects[0].id = 'x-mitre-collection--bbbbbbbb-0a05-4d9e-ab54-9b8563669647'; + + const body = uniqueBundle; + const response = await request(app) + .post('/api/collection-bundles?stream=true&forceImport=duplicate-collection') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/event-stream'); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/app/tests/api/collection-indexes/collection-indexes.spec.js b/app/tests/api/collection-indexes/collection-indexes.spec.js index 28cbb2bd..7e29d610 100644 --- a/app/tests/api/collection-indexes/collection-indexes.spec.js +++ b/app/tests/api/collection-indexes/collection-indexes.spec.js @@ -4,6 +4,7 @@ const { expect } = require('expect'); const logger = require('../../../lib/logger'); logger.level = 'debug'; +const config = require('../../../config/config'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); @@ -73,6 +74,10 @@ describe('Collection Indexes Basic API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; this non-STIX payload spec should not inherit a disabled flag + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -84,7 +89,7 @@ describe('Collection Indexes Basic API', function () { request(app) .get('/api/collection-indexes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { @@ -107,7 +112,7 @@ describe('Collection Indexes Basic API', function () { .post('/api/collection-indexes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -121,7 +126,7 @@ describe('Collection Indexes Basic API', function () { .post('/api/collection-indexes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -138,7 +143,7 @@ describe('Collection Indexes Basic API', function () { const res = await request(app) .get('/api/collection-indexes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -153,7 +158,7 @@ describe('Collection Indexes Basic API', function () { await request(app) .get('/api/collection-indexes/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -161,7 +166,7 @@ describe('Collection Indexes Basic API', function () { const res = await request(app) .get('/api/collection-indexes/' + collectionIndex1.collection_index.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -184,7 +189,7 @@ describe('Collection Indexes Basic API', function () { .put('/api/collection-indexes/' + collectionIndex1.collection_index.id) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -203,14 +208,14 @@ describe('Collection Indexes Basic API', function () { .post('/api/collection-indexes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); it('DELETE /api/collection-indexes deletes a collection index', async function () { await request(app) .delete('/api/collection-indexes/' + collectionIndex1.collection_index.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -218,7 +223,7 @@ describe('Collection Indexes Basic API', function () { const res = await request(app) .get('/api/collection-indexes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/collections/collections.spec.js b/app/tests/api/collections/collections.spec.js index 0e9e4e60..55a1fdd9 100644 --- a/app/tests/api/collections/collections.spec.js +++ b/app/tests/api/collections/collections.spec.js @@ -1,9 +1,9 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -35,9 +35,11 @@ const initialCollectionData = { spec_version: '2.1', type: 'x-mitre-collection', description: 'This is a collection.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + // Note: external_references will be generated by backend + // external_references: [{ source_name: 'source-1', external_id: 's1' }], object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + x_mitre_version: '1.0', x_mitre_contents: [], }, }; @@ -53,14 +55,7 @@ const mitigationData1 = { spec_version: '2.1', type: 'course-of-action', description: 'This is a mitigation.', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/mitigations/T9999', - }, - { source_name: 'source-1', external_id: 's1' }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', @@ -78,14 +73,7 @@ const mitigationData2 = { spec_version: '2.1', type: 'course-of-action', description: 'This is another mitigation.', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/mitigations/T9999', - }, - { source_name: 'source-1', external_id: 's1' }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', @@ -104,19 +92,12 @@ const softwareData = { type: 'malware', description: 'This is a malware type of software.', is_family: false, - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'S3333', - url: 'https://attack.mitre.org/software/S3333', - }, - { source_name: 'source-1', external_id: 's1' }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', x_mitre_aliases: ['software-1'], - x_mitre_platforms: ['platform-1'], + x_mitre_platforms: ['Android'], x_mitre_contributors: ['contributor-1', 'contributor-2'], x_mitre_domains: ['mobile-attack'], }, @@ -134,6 +115,10 @@ describe('Collections (x-mitre-collection) Basic API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Disable ADM validation for tests + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -150,7 +135,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -178,7 +163,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -195,7 +180,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -217,7 +202,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -234,7 +219,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/collections') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -248,7 +233,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/collections') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -266,7 +251,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -281,7 +266,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { await request(app) .get('/api/collections/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -289,7 +274,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -322,7 +307,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection1.stix.id + '?retrieveContents=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -356,16 +341,13 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/collections') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let collection2; it('POST /api/collections should create a new version of a collection with a duplicate stix.id but different stix.modified date', async function () { - const collection = _.cloneDeep(collection1); - collection._id = undefined; - collection.__t = undefined; - collection.__v = undefined; + const collection = cloneForCreate(collection1); const timestamp = new Date().toISOString(); collection.stix.modified = timestamp; @@ -382,7 +364,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { .post('/api/collections') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -395,7 +377,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -413,7 +395,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection1.stix.id + '/modified/' + collection1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -435,7 +417,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { '?retrieveContents=true', ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -461,7 +443,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { '?retrieveContents=true', ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -481,7 +463,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -496,7 +478,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -510,7 +492,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { it('DELETE /api/collections/:id should not delete a collection when the id cannot be found', async function () { await request(app) .delete('/api/collections/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -523,7 +505,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { collection2.stix.modified + '?deleteAllContents=true', ) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -531,7 +513,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { await request(app) .get(`/api/mitigations/${mitigation2.stix.id}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -541,7 +523,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { const res = await request(app) .get('/api/collections/' + collection1.stix.id + '?retrieveContents=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -564,7 +546,7 @@ describe('Collections (x-mitre-collection) Basic API', function () { it('DELETE /api/collections/:id should delete all of the collections with the stix id', async function () { await request(app) .delete('/api/collections/' + collection1.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); diff --git a/app/tests/api/data-components/data-components-pagination.spec.js b/app/tests/api/data-components/data-components-pagination.spec.js index 04bfb107..30b0282e 100644 --- a/app/tests/api/data-components/data-components-pagination.spec.js +++ b/app/tests/api/data-components/data-components-pagination.spec.js @@ -1,4 +1,4 @@ -const dataComponentsService = require('../../../services/data-components-service'); +const dataComponentsService = require('../../../services/stix/data-components-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -33,6 +33,9 @@ const options = { prefix: 'x-mitre-data-component', baseUrl: '/api/data-components', label: 'Data Components', + // 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, }; const paginationTests = new PaginationTests(dataComponentsService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/data-components/data-components.spec.js b/app/tests/api/data-components/data-components.spec.js index be3643c9..6637c8a0 100644 --- a/app/tests/api/data-components/data-components.spec.js +++ b/app/tests/api/data-components/data-components.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -55,6 +55,10 @@ describe('Data Components API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -66,7 +70,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -83,7 +87,7 @@ describe('Data Components API', function () { .post('/api/data-components') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -97,7 +101,7 @@ describe('Data Components API', function () { .post('/api/data-components') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -119,7 +123,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -130,11 +134,55 @@ describe('Data Components API', function () { expect(dataComponent.length).toBe(1); }); + it('GET /api/data-components should return data components containing the domain', async function () { + const res = await request(app) + .get('/api/data-components?domain=enterprise-attack') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const dataComponents = res.body; + expect(dataComponents).toBeDefined(); + expect(Array.isArray(dataComponents)).toBe(true); + expect(dataComponents.length).toBe(1); + expect(dataComponents[0].stix.x_mitre_domains).toContain('enterprise-attack'); + }); + + it('GET /api/data-components should return data components containing any provided domain', async function () { + const res = await request(app) + .get('/api/data-components?domain=enterprise-attack&domain=mobile-attack') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const dataComponents = res.body; + expect(dataComponents).toBeDefined(); + expect(Array.isArray(dataComponents)).toBe(true); + expect(dataComponents.length).toBe(1); + expect(dataComponents[0].stix.x_mitre_domains).toContain('enterprise-attack'); + }); + + it('GET /api/data-components should not return any data components when searching for a non-existent domain', async function () { + const res = await request(app) + .get('/api/data-components?domain=not-a-domain') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const dataComponents = res.body; + expect(dataComponents).toBeDefined(); + expect(Array.isArray(dataComponents)).toBe(true); + expect(dataComponents.length).toBe(0); + }); + it('GET /api/data-components/:id/channels returns the log source channels of the added data component', async function () { const res = await request(app) .get('/api/data-components/' + dataComponent1.stix.id + '/channels') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -151,7 +199,7 @@ describe('Data Components API', function () { await request(app) .get('/api/data-components/not-a-real-dc-id/channels') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -159,7 +207,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components/' + dataComponent1.stix.id + '/log-sources') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -178,7 +226,7 @@ describe('Data Components API', function () { await request(app) .get('/api/data-components/not-a-real-dc-id/log-sources') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -186,7 +234,7 @@ describe('Data Components API', function () { await request(app) .get('/api/data-components/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -194,7 +242,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components/' + dataComponent1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -238,7 +286,7 @@ describe('Data Components API', function () { .put('/api/data-components/' + dataComponent1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -250,21 +298,18 @@ describe('Data Components API', function () { }); it('POST /api/data-components does not create a data component with the same id and modified date', async function () { - const body = dataComponent1; + const body = cloneForCreate(dataComponent1); await request(app) .post('/api/data-components') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let dataComponent2; it('POST /api/data-components should create a new version of a data component with a duplicate stix.id but different stix.modified date', async function () { - dataComponent2 = _.cloneDeep(dataComponent1); - dataComponent2._id = undefined; - dataComponent2.__t = undefined; - dataComponent2.__v = undefined; + dataComponent2 = cloneForCreate(dataComponent1); const timestamp = new Date().toISOString(); dataComponent2.stix.modified = timestamp; const body = dataComponent2; @@ -272,7 +317,7 @@ describe('Data Components API', function () { .post('/api/data-components') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -285,7 +330,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components/' + dataComponent2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -303,7 +348,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components/' + dataComponent1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -323,7 +368,7 @@ describe('Data Components API', function () { dataComponent1.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -344,7 +389,7 @@ describe('Data Components API', function () { dataComponent2.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -358,10 +403,7 @@ describe('Data Components API', function () { let dataComponent3; it('POST /api/data-components should create a new version of a data component with a duplicate stix.id but different stix.modified date', async function () { - dataComponent3 = _.cloneDeep(dataComponent1); - dataComponent3._id = undefined; - dataComponent3.__t = undefined; - dataComponent3.__v = undefined; + dataComponent3 = cloneForCreate(dataComponent1); const timestamp = new Date().toISOString(); dataComponent3.stix.modified = timestamp; const body = dataComponent3; @@ -369,7 +411,7 @@ describe('Data Components API', function () { .post('/api/data-components') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -381,7 +423,7 @@ describe('Data Components API', function () { it('DELETE /api/data-components/:id should not delete a data component when the id cannot be found', async function () { await request(app) .delete('/api/data-components/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -393,14 +435,14 @@ describe('Data Components API', function () { '/modified/' + dataComponent1.stix.modified, ) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/data-components/:id should delete all the data components with the same stix id', async function () { await request(app) .delete('/api/data-components/' + dataComponent2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -408,7 +450,7 @@ describe('Data Components API', function () { const res = await request(app) .get('/api/data-components') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/data-sources/data-sources-pagination.spec.js b/app/tests/api/data-sources/data-sources-pagination.spec.js index f9d1fb6d..a63fcf62 100644 --- a/app/tests/api/data-sources/data-sources-pagination.spec.js +++ b/app/tests/api/data-sources/data-sources-pagination.spec.js @@ -1,4 +1,4 @@ -const dataSourcesService = require('../../../services/data-sources-service'); +const dataSourcesService = require('../../../services/stix/data-sources-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -23,6 +23,9 @@ const options = { prefix: 'x-mitre-data-source', baseUrl: '/api/data-sources', label: 'Data Sources', + // 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, }; const paginationTests = new PaginationTests(dataSourcesService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/data-sources/data-sources.spec.js b/app/tests/api/data-sources/data-sources.spec.js index e2a07c00..3cdf4c07 100644 --- a/app/tests/api/data-sources/data-sources.spec.js +++ b/app/tests/api/data-sources/data-sources.spec.js @@ -2,13 +2,15 @@ const request = require('supertest'); const { expect } = require('expect'); const _ = require('lodash'); +const { cloneForCreate } = require('../../shared/clone-for-create'); + const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); -const dataComponentsService = require('../../../services/data-components-service'); +const dataComponentsService = require('../../../services/stix/data-components-service'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -26,12 +28,12 @@ const initialDataSourceData = { spec_version: '2.1', type: 'x-mitre-data-source', description: 'This is a data source.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', - x_mitre_platforms: ['macOS', 'Office 365', 'Google Workspace', 'Linux', 'Network'], - x_mitre_collection_layers: ['duis', 'laboris'], + x_mitre_platforms: ['macOS', 'Office Suite', 'Identity Provider', 'Linux', 'Network Devices'], + x_mitre_collection_layers: ['Host', 'Network'], x_mitre_contributors: ['Herbert Examplecontributor'], x_mitre_domains: ['enterprise-attack'], x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', @@ -49,7 +51,7 @@ const initialDataComponentData = { spec_version: '2.1', type: 'x-mitre-data-component', description: 'This is a data component.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', @@ -62,7 +64,7 @@ async function loadDataComponents(baseDataComponent) { let timestamp = new Date().toISOString(); data1.stix.created = timestamp; data1.stix.modified = timestamp; - await dataComponentsService.create(data1); + const created1 = await dataComponentsService.create(data1); const data2 = _.cloneDeep(baseDataComponent); timestamp = new Date().toISOString(); @@ -87,8 +89,12 @@ async function loadDataComponents(baseDataComponent) { timestamp = new Date().toISOString(); data5.stix.created = timestamp; data5.stix.modified = timestamp; - data5.stix.revoked = true; - await dataComponentsService.create(data5); + const created5 = await dataComponentsService.create(data5); + + // Revoke data component 5 using data component 1 as the revoking object + await dataComponentsService.revoke(created5.stix.id, { + revoking: { stixId: created1.stix.id, modified: created1.stix.modified }, + }); } describe('Data Sources API', function () { @@ -103,6 +109,10 @@ describe('Data Sources API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -114,7 +124,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); // We expect to get an empty array @@ -130,7 +140,7 @@ describe('Data Sources API', function () { .post('/api/data-sources') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -144,7 +154,7 @@ describe('Data Sources API', function () { .post('/api/data-sources') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -162,7 +172,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -177,7 +187,7 @@ describe('Data Sources API', function () { await request(app) .get('/api/data-sources/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -185,7 +195,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources/' + dataSource1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -220,7 +230,7 @@ describe('Data Sources API', function () { const res = await request(app) .get(`/api/data-sources/${dataSource1.stix.id}?retrieveDataComponents=true`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -242,12 +252,12 @@ describe('Data Sources API', function () { const timestamp = new Date().toISOString(); dataSource1.stix.modified = timestamp; dataSource1.stix.description = 'This is an updated data source.'; - const body = dataSource1; + const body = cloneForCreate(dataSource1); const res = await request(app) .put('/api/data-sources/' + dataSource1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -259,21 +269,18 @@ describe('Data Sources API', function () { }); it('POST /api/data-sources does not create a data source with the same id and modified date', async function () { - const body = dataSource1; + const body = cloneForCreate(dataSource1); await request(app) .post('/api/data-sources') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let dataSource2; it('POST /api/data-sources should create a new version of a data source with a duplicate stix.id but different stix.modified date', async function () { - dataSource2 = _.cloneDeep(dataSource1); - dataSource2._id = undefined; - dataSource2.__t = undefined; - dataSource2.__v = undefined; + dataSource2 = cloneForCreate(dataSource1); const timestamp = new Date().toISOString(); dataSource2.stix.modified = timestamp; const body = dataSource2; @@ -281,7 +288,7 @@ describe('Data Sources API', function () { .post('/api/data-sources') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -294,7 +301,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources/' + dataSource2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -312,7 +319,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources/' + dataSource1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -327,7 +334,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources/' + dataSource1.stix.id + '/modified/' + dataSource1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -343,7 +350,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources/' + dataSource2.stix.id + '/modified/' + dataSource2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -357,10 +364,7 @@ describe('Data Sources API', function () { let dataSource3; it('POST /api/data-sources should create a new version of a data source with a duplicate stix.id but different stix.modified date', async function () { - dataSource3 = _.cloneDeep(dataSource1); - dataSource3._id = undefined; - dataSource3.__t = undefined; - dataSource3.__v = undefined; + dataSource3 = cloneForCreate(dataSource1); const timestamp = new Date().toISOString(); dataSource3.stix.modified = timestamp; const body = dataSource3; @@ -368,7 +372,7 @@ describe('Data Sources API', function () { .post('/api/data-sources') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -380,21 +384,21 @@ describe('Data Sources API', function () { it('DELETE /api/data-sources/:id should not delete a data source when the id cannot be found', async function () { await request(app) .delete('/api/data-sources/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/data-sources/:id/modified/:modified deletes a data source', async function () { await request(app) .delete('/api/data-sources/' + dataSource1.stix.id + '/modified/' + dataSource1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/data-sources/:id should delete all the data sources with the same stix id', async function () { await request(app) .delete('/api/data-sources/' + dataSource2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -402,7 +406,7 @@ describe('Data Sources API', function () { const res = await request(app) .get('/api/data-sources') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/detection-strategies/detection-strategies-pagination.spec.js b/app/tests/api/detection-strategies/detection-strategies-pagination.spec.js index a609cb8d..97c57055 100644 --- a/app/tests/api/detection-strategies/detection-strategies-pagination.spec.js +++ b/app/tests/api/detection-strategies/detection-strategies-pagination.spec.js @@ -1,4 +1,4 @@ -const detectionStrategiesService = require('../../../services/detection-strategies-service'); +const detectionStrategiesService = require('../../../services/stix/detection-strategies-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -13,22 +13,16 @@ const initialObjectData = { name: 'detection-strategy-1', spec_version: '2.1', type: 'x-mitre-detection-strategy', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'DET9999', - url: 'https://attack.mitre.org/detection-strategies/DET9999', - }, - ], + // Note: external_references will be generated by backend object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '4.0.0', x_mitre_domains: ['enterprise-attack'], - x_mitre_analytic_refs: [ - 'x-mitre-analytic--12345678-1234-1234-1234-123456789000', - 'x-mitre-analytic--12345678-1234-1234-1234-123456789012', - ], + /** + * NOTE: Do not set embedded relationships (x_mitre_analytic_refs) unless you can + * guarantee they exist before the detection strategy is posted. The backend will + * throw in you reference a nonexistent analytic + */ }, }; @@ -36,6 +30,9 @@ const options = { prefix: 'x-mitre-detection-strategy', baseUrl: '/api/detection-strategies', label: 'Detection Strategies', + // 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, }; const paginationTests = new PaginationTests(detectionStrategiesService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/detection-strategies/detection-strategies-spec.js b/app/tests/api/detection-strategies/detection-strategies-spec.js index a873e902..3c041f8e 100644 --- a/app/tests/api/detection-strategies/detection-strategies-spec.js +++ b/app/tests/api/detection-strategies/detection-strategies-spec.js @@ -1,19 +1,20 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); +const detectionStrategiesRepository = require('../../../repository/detection-strategies-repository'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; // modified and created properties will be set before calling REST API // stix.id property will be created by REST API -const initialObjectData = { +const initialPostContentForDetectionStrategy = { workspace: { workflow: { state: 'work-in-progress', @@ -23,21 +24,76 @@ const initialObjectData = { name: 'detection-strategy-1', spec_version: '2.1', type: 'x-mitre-detection-strategy', - external_references: [ + // Note: external_references will be generated by backend + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_analytic_refs: [ + 'x-mitre-analytic--12345678-1234-4234-8234-123456789000', // must match! + 'x-mitre-analytic--12345678-1234-4234-8234-123456789012', // must match! + ], + }, +}; + +const initialPostContentForAnalytic1 = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + id: 'x-mitre-analytic--12345678-1234-4234-8234-123456789000', // must match the analytic's embedded ref! + name: 'analytic-1', + spec_version: '2.1', + type: 'x-mitre-analytic', + // Note: external_references will be generated by backend + description: 'Description of an analytic', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + x_mitre_version: '1.0', + x_mitre_platforms: ['Windows'], + x_mitre_domains: ['enterprise-attack'], + x_mitre_mutable_elements: [ + { + field: 'fieldOne', + description: 'Description of fieldOne', + }, { - source_name: 'mitre-attack', - external_id: 'DET9999', - url: 'https://attack.mitre.org/detection-strategies/DET9999', + field: 'fieldTwo', + description: 'Description of fieldTwo', }, ], + }, +}; + +const initialPostContentForAnalytic2 = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + id: 'x-mitre-analytic--12345678-1234-4234-8234-123456789012', // must match the analytic's embedded ref! + name: 'analytic-2', + spec_version: '2.1', + type: 'x-mitre-analytic', + // Note: external_references will be generated by backend + description: 'Description of an analytic', object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.0', - x_mitre_attack_spec_version: '3.3.0', + x_mitre_platforms: ['Windows'], x_mitre_domains: ['enterprise-attack'], - x_mitre_analytic_refs: [ - 'x-mitre-analytic--12345678-1234-1234-1234-123456789000', - 'x-mitre-analytic--12345678-1234-1234-1234-123456789012', + x_mitre_mutable_elements: [ + { + field: 'fieldOne', + description: 'Description of fieldOne', + }, + { + field: 'fieldTwo', + description: 'Description of fieldTwo', + }, ], }, }; @@ -54,6 +110,10 @@ describe('Detection Strategies API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -65,7 +125,7 @@ describe('Detection Strategies API', function () { const res = await request(app) .get('/api/detection-strategies') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -82,26 +142,54 @@ describe('Detection Strategies API', function () { .post('/api/detection-strategies') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); let detectionStrategy1; it('POST /api/detection-strategies creates a detection strategy', async function () { const timestamp = new Date().toISOString(); - initialObjectData.stix.created = timestamp; - initialObjectData.stix.modified = timestamp; - const body = initialObjectData; - const res = await request(app) + + initialPostContentForDetectionStrategy.stix.created = timestamp; + initialPostContentForDetectionStrategy.stix.modified = timestamp; + + initialPostContentForAnalytic1.stix.created = timestamp; + initialPostContentForAnalytic1.stix.modified = timestamp; + + initialPostContentForAnalytic2.stix.created = timestamp; + initialPostContentForAnalytic2.stix.modified = timestamp; + + // First, post the 2 analytics + // The detection strategy cannot reference analytics that do not exist! + + await request(app) + .post('/api/analytics') + .send(initialPostContentForAnalytic1) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + await request(app) + .post('/api/analytics') + .send(initialPostContentForAnalytic2) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // Now we can post the detection strategy! + + const resDetectionStrategy = await request(app) .post('/api/detection-strategies') - .send(body) + .send(initialPostContentForDetectionStrategy) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); // We expect to get the created detection strategy - detectionStrategy1 = res.body; + detectionStrategy1 = resDetectionStrategy.body; expect(detectionStrategy1).toBeDefined(); expect(detectionStrategy1.stix).toBeDefined(); expect(detectionStrategy1.stix.id).toBeDefined(); @@ -117,7 +205,7 @@ describe('Detection Strategies API', function () { const res = await request(app) .get('/api/detection-strategies') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -132,7 +220,7 @@ describe('Detection Strategies API', function () { await request(app) .get('/api/detection-strategies/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -140,7 +228,7 @@ describe('Detection Strategies API', function () { const res = await request(app) .get('/api/detection-strategies/' + detectionStrategy1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -186,7 +274,7 @@ describe('Detection Strategies API', function () { ) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -198,21 +286,18 @@ describe('Detection Strategies API', function () { }); it('POST /api/detection-strategies does not create a detection strategy with the same id and modified date', async function () { - const body = detectionStrategy1; + const body = cloneForCreate(detectionStrategy1); await request(app) .post('/api/detection-strategies') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let detectionStrategy2; it('POST /api/detection-strategies should create a new version of a detection strategy with a duplicate stix.id but different stix.modified date', async function () { - detectionStrategy2 = _.cloneDeep(detectionStrategy1); - detectionStrategy2._id = undefined; - detectionStrategy2.__t = undefined; - detectionStrategy2.__v = undefined; + detectionStrategy2 = cloneForCreate(detectionStrategy1); const timestamp = new Date().toISOString(); detectionStrategy2.stix.modified = timestamp; const body = detectionStrategy2; @@ -220,7 +305,7 @@ describe('Detection Strategies API', function () { .post('/api/detection-strategies') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -231,10 +316,7 @@ describe('Detection Strategies API', function () { let detectionStrategy3; it('POST /api/detection-strategies should create a new version of a detection strategy with a duplicate stix.id but different stix.modified date', async function () { - detectionStrategy3 = _.cloneDeep(detectionStrategy1); - detectionStrategy3._id = undefined; - detectionStrategy3.__t = undefined; - detectionStrategy3.__v = undefined; + detectionStrategy3 = cloneForCreate(detectionStrategy1); const timestamp = new Date().toISOString(); detectionStrategy3.stix.modified = timestamp; const body = detectionStrategy3; @@ -242,7 +324,7 @@ describe('Detection Strategies API', function () { .post('/api/detection-strategies') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -251,11 +333,60 @@ describe('Detection Strategies API', function () { expect(detectionStrategy).toBeDefined(); }); + let detectionStrategy4; + it('POST /api/detection-strategies preserves non-analytic embedded relationships when creating a new version', async function () { + const latestDetectionStrategy = await detectionStrategiesRepository.retrieveLatestByStixId( + detectionStrategy3.stix.id, + ); + + latestDetectionStrategy.workspace.embedded_relationships = [ + ...(latestDetectionStrategy.workspace?.embedded_relationships || []), + { + stix_id: 'x-mitre-data-component--00000000-0000-4000-8000-000000000001', + attack_id: 'DCP-TEST', + direction: 'inbound', + }, + ]; + await detectionStrategiesRepository.saveDocument(latestDetectionStrategy); + + const nextVersion = cloneForCreate( + latestDetectionStrategy.toObject + ? latestDetectionStrategy.toObject() + : latestDetectionStrategy, + ); + delete nextVersion.workspace.embedded_relationships; + nextVersion.stix.modified = new Date(Date.now() + 1000).toISOString(); + + const res = await request(app) + .post('/api/detection-strategies') + .send(nextVersion) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + detectionStrategy4 = res.body; + expect(detectionStrategy4.workspace.embedded_relationships).toBeDefined(); + expect(detectionStrategy4.workspace.embedded_relationships).toHaveLength(3); + + const preservedRel = detectionStrategy4.workspace.embedded_relationships.find( + (rel) => rel.stix_id === 'x-mitre-data-component--00000000-0000-4000-8000-000000000001', + ); + expect(preservedRel).toBeDefined(); + expect(preservedRel.direction).toBe('inbound'); + expect(preservedRel.attack_id).toBe('DCP-TEST'); + + const analyticRels = detectionStrategy4.workspace.embedded_relationships.filter((rel) => + rel.stix_id?.startsWith('x-mitre-analytic--'), + ); + expect(analyticRels).toHaveLength(2); + }); + it('GET /api/detection-strategies returns the latest added detection strategy', async function () { const res = await request(app) - .get('/api/detection-strategies/' + detectionStrategy3.stix.id) + .get('/api/detection-strategies/' + detectionStrategy4.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -265,23 +396,23 @@ describe('Detection Strategies API', function () { expect(Array.isArray(detectionStrategies)).toBe(true); expect(detectionStrategies.length).toBe(1); const detectionStrategy = detectionStrategies[0]; - expect(detectionStrategy.stix.id).toBe(detectionStrategy3.stix.id); - expect(detectionStrategy.stix.modified).toBe(detectionStrategy3.stix.modified); + expect(detectionStrategy.stix.id).toBe(detectionStrategy4.stix.id); + expect(detectionStrategy.stix.modified).toBe(detectionStrategy4.stix.modified); }); it('GET /api/detection-strategies returns all added detection strategies', async function () { const res = await request(app) .get('/api/detection-strategies/' + detectionStrategy1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); - // We expect to get two detection strategies in an array + // We expect to get four detection strategies in an array const detectionStrategies = res.body; expect(detectionStrategies).toBeDefined(); expect(Array.isArray(detectionStrategies)).toBe(true); - expect(detectionStrategies.length).toBe(3); + expect(detectionStrategies.length).toBe(4); }); it('GET /api/detection-strategies/:id/modified/:modified returns the first added detection strategy', async function () { @@ -293,7 +424,7 @@ describe('Detection Strategies API', function () { detectionStrategy1.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -314,7 +445,7 @@ describe('Detection Strategies API', function () { detectionStrategy2.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -329,7 +460,7 @@ describe('Detection Strategies API', function () { it('DELETE /api/detection-strategies/:id should not delete a detection strategy when the id cannot be found', async function () { await request(app) .delete('/api/detection-strategies/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -341,14 +472,14 @@ describe('Detection Strategies API', function () { '/modified/' + detectionStrategy1.stix.modified, ) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/detection-strategies/:id should delete all the detection strategies with the same stix id', async function () { await request(app) .delete('/api/detection-strategies/' + detectionStrategy2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -356,7 +487,7 @@ describe('Detection Strategies API', function () { const res = await request(app) .get('/api/detection-strategies') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/groups/groups-input-validation.spec.js b/app/tests/api/groups/groups-input-validation.spec.js index ba2c73ca..a1f306c8 100644 --- a/app/tests/api/groups/groups-input-validation.spec.js +++ b/app/tests/api/groups/groups-input-validation.spec.js @@ -4,6 +4,7 @@ const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const Group = require('../../../models/group-model'); +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); @@ -93,6 +94,10 @@ describe('Groups API Input Validation', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -106,7 +111,7 @@ describe('Groups API Input Validation', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -120,7 +125,7 @@ describe('Groups API Input Validation', function () { .post('/api/groups?not-a-parameter=unexpectedvalue') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -135,7 +140,7 @@ describe('Groups API Input Validation', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -150,12 +155,12 @@ describe('Groups API Input Validation', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); executeTests(() => app, 'stix.type', { required: true }); - executeTests(() => app, 'stix.spec_version', { required: true }); + executeTests(() => app, 'stix.spec_version', { required: false }); executeTests(() => app, 'stix.name', { required: true }); executeTests(() => app, 'stix.description'); executeTests(() => app, 'stix.x_mitre_modified_by_ref'); diff --git a/app/tests/api/groups/groups-pagination.spec.js b/app/tests/api/groups/groups-pagination.spec.js index ee45c43a..4f399540 100644 --- a/app/tests/api/groups/groups-pagination.spec.js +++ b/app/tests/api/groups/groups-pagination.spec.js @@ -1,4 +1,4 @@ -const groupsService = require('../../../services/groups-service'); +const groupsService = require('../../../services/stix/groups-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -23,6 +23,9 @@ const options = { prefix: 'intrustion-set', baseUrl: '/api/groups', label: 'Groups', + // 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, }; const paginationTests = new PaginationTests(groupsService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/groups/groups.query.json b/app/tests/api/groups/groups.query.json deleted file mode 100644 index 82bd3eeb..00000000 --- a/app/tests/api/groups/groups.query.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "workspace": { - "workflow": {} - }, - "stix": { - "spec_version": "2.1", - "type": "intrusion-set", - "description": "This is a group.", - "external_references": [], - "object_marking_refs": ["marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"], - "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", - "x_mitre_version": "1.0" - } -} diff --git a/app/tests/api/groups/groups.query.spec.js b/app/tests/api/groups/groups.query.spec.js index 7819a2e6..59771324 100644 --- a/app/tests/api/groups/groups.query.spec.js +++ b/app/tests/api/groups/groups.query.spec.js @@ -1,10 +1,9 @@ -const fs = require('fs').promises; - const request = require('supertest'); const { expect } = require('expect'); const _ = require('lodash'); const uuid = require('uuid'); +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); @@ -13,125 +12,116 @@ logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const userAccountsService = require('../../../services/user-accounts-service'); -const groupsService = require('../../../services/groups-service'); +const userAccountsService = require('../../../services/system/user-accounts-service'); +const groupsService = require('../../../services/stix/groups-service'); + +// Base group used to derive all of the seeded query fixtures. Each created group +// deep-clones this and overrides only the fields a given test cares about. +const baseGroup = { + workspace: { + workflow: {}, + }, + stix: { + spec_version: '2.1', + type: 'intrusion-set', + description: 'This is a group.', + external_references: [], + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + }, +}; function asyncWait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function readJson(path) { - const data = await fs.readFile(require.resolve(path)); - return JSON.parse(data); -} +async function configureAndLoadGroups(baseGroup, userAccountId1, userAccountId2) { + // Helper: create a group from config + async function createGroup(overrides, userAccountId) { + const data = _.cloneDeep(baseGroup); + Object.assign(data.stix, overrides.stix || {}); + if (overrides.workspace) { + data.workspace = { ...data.workspace, ...overrides.workspace }; + } -function makeExternalReference(attackId) { - return { - source_name: 'mitre-attack', - external_id: attackId, - url: `https://attack.mitre.org/groups/${attackId}`, - }; -} + if (!data.stix.name) { + data.stix.name = `group-${data.stix.x_mitre_deprecated}-undefined`; + } + if (!data.stix.created) { + const timestamp = new Date().toISOString(); + data.stix.created = timestamp; + data.stix.modified = timestamp; + } -async function configureGroups(baseGroup, userAccountId1, userAccountId2) { - const groups = []; - // x_mitre_deprecated,revoked undefined (user account 1) - const data1a = _.cloneDeep(baseGroup); - data1a.stix.external_references.push(makeExternalReference('G0001')); - data1a.userAccountId = userAccountId1; - groups.push(data1a); - - // x_mitre_deprecated,revoked undefined (user account 2) - const data1b = _.cloneDeep(baseGroup); - data1b.stix.external_references.push(makeExternalReference('G0010')); - data1b.userAccountId = userAccountId2; - groups.push(data1b); - - // x_mitre_deprecated = false, revoked = false - const data2 = _.cloneDeep(baseGroup); - data2.stix.external_references.push(makeExternalReference('G0002')); - data2.stix.x_mitre_deprecated = false; - data2.stix.revoked = false; - data2.workspace.workflow = { state: 'work-in-progress' }; - data2.userAccountId = userAccountId1; - groups.push(data2); - - // x_mitre_deprecated = true, revoked = false - const data3 = _.cloneDeep(baseGroup); - data3.stix.external_references.push(makeExternalReference('G0003')); - data3.stix.x_mitre_deprecated = true; - data3.stix.revoked = false; - data3.workspace.workflow = { state: 'awaiting-review' }; - data3.userAccountId = userAccountId1; - groups.push(data3); - - // x_mitre_deprecated = false, revoked = true - const data4 = _.cloneDeep(baseGroup); - data4.stix.external_references.push(makeExternalReference('G0004')); - data4.stix.x_mitre_deprecated = false; - data4.stix.revoked = true; - data4.workspace.workflow = { state: 'awaiting-review' }; - data4.userAccountId = userAccountId1; - groups.push(data4); - - // multiple versions, last version has x_mitre_deprecated = true, revoked = true - const data5a = _.cloneDeep(baseGroup); + return groupsService.create(data, { import: false, userAccountId }); + } + + // group 1a: x_mitre_deprecated,revoked undefined (user account 1) + const group1a = await createGroup({}, userAccountId1); + + // group 1b: x_mitre_deprecated,revoked undefined (user account 2) + await createGroup({}, userAccountId2); + + // group 2: x_mitre_deprecated = false, state = work-in-progress + await createGroup( + { stix: { x_mitre_deprecated: false }, workspace: { workflow: { state: 'work-in-progress' } } }, + userAccountId1, + ); + + // group 3: x_mitre_deprecated = true, state = awaiting-review + await createGroup( + { stix: { x_mitre_deprecated: true }, workspace: { workflow: { state: 'awaiting-review' } } }, + userAccountId1, + ); + + // group 4: revoked via the revoke workflow (x_mitre_deprecated = false) + // Use group1a as the revoking object so we don't add extra groups to the count + const group4 = await createGroup( + { stix: { x_mitre_deprecated: false }, workspace: { workflow: { state: 'awaiting-review' } } }, + userAccountId1, + ); + await groupsService.revoke(group4.stix.id, { + revoking: { stixId: group1a.stix.id, modified: group1a.stix.modified }, + }); + + // group 5: multiple versions, last version has x_mitre_deprecated = true and is revoked const id = `intrusion-set--${uuid.v4()}`; - data5a.stix.external_references.push(makeExternalReference('G0005')); + const createdTimestamp = new Date().toISOString(); + + const data5a = _.cloneDeep(baseGroup); data5a.stix.id = id; data5a.stix.name = 'multiple-versions'; data5a.workspace.workflow = { state: 'awaiting-review' }; - const createdTimestamp = new Date().toISOString(); data5a.stix.created = createdTimestamp; data5a.stix.modified = createdTimestamp; - data5a.userAccountId = userAccountId1; - groups.push(data5a); + await groupsService.create(data5a, { import: false, userAccountId: userAccountId1 }); await asyncWait(10); // wait so the modified timestamp can change const data5b = _.cloneDeep(baseGroup); - data5b.stix.external_references.push(makeExternalReference('G0005')); data5b.stix.id = id; data5b.stix.name = 'multiple-versions'; data5b.workspace.workflow = { state: 'awaiting-review' }; data5b.stix.created = createdTimestamp; - let timestamp = new Date().toISOString(); - data5b.stix.modified = timestamp; - data5b.userAccountId = userAccountId1; - groups.push(data5b); + data5b.stix.modified = new Date().toISOString(); + await groupsService.create(data5b, { import: false, userAccountId: userAccountId1 }); await asyncWait(10); + // Create version 5c with deprecated flag const data5c = _.cloneDeep(baseGroup); - data5c.stix.external_references.push(makeExternalReference('G0005')); data5c.stix.id = id; data5c.stix.name = 'multiple-versions'; data5c.workspace.workflow = { state: 'awaiting-review' }; data5c.stix.x_mitre_deprecated = true; - data5c.stix.revoked = true; data5c.stix.created = createdTimestamp; - timestamp = new Date().toISOString(); - data5c.stix.modified = timestamp; - data5c.userAccountId = userAccountId2; - groups.push(data5c); - - // logger.info(JSON.stringify(groups, null, 4)); - - return groups; -} - -async function loadGroups(groups) { - for (const group of groups) { - if (!group.stix.name) { - group.stix.name = `group-${group.stix.x_mitre_deprecated}-${group.stix.revoked}`; - } - - if (!group.stix.created) { - const timestamp = new Date().toISOString(); - group.stix.created = timestamp; - group.stix.modified = timestamp; - } + data5c.stix.modified = new Date().toISOString(); + await groupsService.create(data5c, { import: false, userAccountId: userAccountId2 }); - await groupsService.create(group, { import: false, userAccountId: group.userAccountId }); - } + // Revoke group5 using group1a as the revoking object + await groupsService.revoke(id, { + revoking: { stixId: group1a.stix.id, modified: group1a.stix.modified }, + }); } const userAccountData1 = { @@ -165,6 +155,10 @@ describe('Groups API Queries', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -174,16 +168,14 @@ describe('Groups API Queries', function () { userAccount1 = await userAccountsService.create(userAccountData1); userAccount2 = await userAccountsService.create(userAccountData2); - const baseGroup = await readJson('./groups.query.json'); - const groups = await configureGroups(baseGroup, userAccount1.id, userAccount2.id); - await loadGroups(groups); + await configureAndLoadGroups(baseGroup, userAccount1.id, userAccount2.id); }); it('GET /api/groups should return 3 of the preloaded groups', async function () { const res = await request(app) .get('/api/groups') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -198,7 +190,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?includeDeprecated=false') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -213,7 +205,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?includeDeprecated=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -228,7 +220,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?includeRevoked=false') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -243,7 +235,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?includeRevoked=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -258,7 +250,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?state=work-in-progress') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -276,14 +268,13 @@ describe('Groups API Queries', function () { const res = await request(app) .get('/api/groups?search=G0001') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); // We expect to get the latest group with the correct ATT&CK ID const groups = res.body; logger.info(`Received groups: ${groups}`); - console.log(`Received groups: ${JSON.stringify(groups)}`); expect(groups).toBeDefined(); expect(Array.isArray(groups)).toBe(true); expect(groups.length).toBe(1); @@ -297,7 +288,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get(`/api/groups?lastUpdatedBy=${userAccount1.id}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -315,7 +306,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get(`/api/groups?lastUpdatedBy=${userAccount2.id}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -332,7 +323,7 @@ describe('Groups API Queries', function () { const res = await request(app) .get(`/api/groups?lastUpdatedBy=${userAccount1.id}&lastUpdatedBy=${userAccount2.id}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/groups/groups.spec.js b/app/tests/api/groups/groups.spec.js index 515084b7..c8d7fd3e 100644 --- a/app/tests/api/groups/groups.spec.js +++ b/app/tests/api/groups/groups.spec.js @@ -1,15 +1,15 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const Group = require('../../../models/group-model'); -const markingDefinitionService = require('../../../services/marking-definitions-service'); -const systemConfigurationService = require('../../../services/system-configuration-service'); +const markingDefinitionService = require('../../../services/stix/marking-definitions-service'); +const systemConfigurationService = require('../../../services/system/system-configuration-service'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -80,6 +80,10 @@ describe('Groups API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -93,7 +97,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -110,7 +114,7 @@ describe('Groups API', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -124,7 +128,7 @@ describe('Groups API', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); // We expect to get the created group @@ -147,7 +151,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -162,7 +166,7 @@ describe('Groups API', function () { await request(app) .get('/api/groups/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -170,7 +174,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups/' + group1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -205,7 +209,7 @@ describe('Groups API', function () { .put('/api/groups/' + group1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -217,12 +221,12 @@ describe('Groups API', function () { }); it('POST /api/groups does not create a group with the same id and modified date', async function () { - const body = group1; + const body = cloneForCreate(group1); await request(app) .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); @@ -233,10 +237,7 @@ describe('Groups API', function () { 'This is the second default marking definition'; defaultMarkingDefinition2 = await addDefaultMarkingDefinition(markingDefinitionData); - group2 = _.cloneDeep(group1); - group2._id = undefined; - group2.__t = undefined; - group2.__v = undefined; + group2 = cloneForCreate(group1); const timestamp = new Date().toISOString(); group2.stix.modified = timestamp; group2.stix.description = 'This is a new version of a group. Green.'; @@ -246,7 +247,7 @@ describe('Groups API', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -259,7 +260,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups/' + group2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -284,7 +285,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups/' + group1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -299,7 +300,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups/' + group1.stix.id + '/modified/' + group1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -315,7 +316,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups/' + group2.stix.id + '/modified/' + group2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -329,10 +330,7 @@ describe('Groups API', function () { let group3; it('POST /api/groups should create a new group with a different stix.id', async function () { - const group = _.cloneDeep(initialObjectData); - group._id = undefined; - group.__t = undefined; - group.__v = undefined; + const group = cloneForCreate(initialObjectData); group.stix.id = undefined; const timestamp = new Date().toISOString(); group.stix.created = timestamp; @@ -344,7 +342,7 @@ describe('Groups API', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -355,10 +353,7 @@ describe('Groups API', function () { let group4; it('POST /api/groups should create a new version of a group with a duplicate stix.id but different stix.modified date', async function () { - group4 = _.cloneDeep(group1); - group4._id = undefined; - group4.__t = undefined; - group4.__v = undefined; + group4 = cloneForCreate(group1); const timestamp = new Date().toISOString(); group4.stix.modified = timestamp; group4.stix.description = 'This is a new version of a group. Yellow.'; @@ -368,7 +363,7 @@ describe('Groups API', function () { .post('/api/groups') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -381,7 +376,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups?search=yellow') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -403,7 +398,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups?search=blue') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -418,7 +413,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups?search=brown') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -439,28 +434,28 @@ describe('Groups API', function () { it('DELETE /api/groups/:id should not delete a group when the id cannot be found', async function () { await request(app) .delete('/api/groups/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/groups/:id/modified/:modified deletes a group', async function () { await request(app) .delete('/api/groups/' + group1.stix.id + '/modified/' + group1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/groups/:id should delete all the groups with the same stix id', async function () { await request(app) .delete('/api/groups/' + group2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/groups/:id/modified/:modified should delete the third group', async function () { await request(app) .delete('/api/groups/' + group3.stix.id + '/modified/' + group3.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -468,7 +463,7 @@ describe('Groups API', function () { const res = await request(app) .get('/api/groups') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/identities/identities.spec.js b/app/tests/api/identities/identities.spec.js index ad9c9f3e..8c51c12e 100644 --- a/app/tests/api/identities/identities.spec.js +++ b/app/tests/api/identities/identities.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -25,6 +25,7 @@ const initialObjectData = { spec_version: '2.1', type: 'identity', description: 'This is an identity.', + external_references: [{ source_name: 'source-1', external_id: 's1' }], object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], }, }; @@ -41,6 +42,10 @@ describe('Identity API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -52,7 +57,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -69,7 +74,7 @@ describe('Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -83,7 +88,7 @@ describe('Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -101,7 +106,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -116,7 +121,7 @@ describe('Identity API', function () { await request(app) .get('/api/identities/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -124,7 +129,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities/' + identity1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -161,7 +166,7 @@ describe('Identity API', function () { .put('/api/identities/' + identity1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -178,16 +183,13 @@ describe('Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let identity2; it('POST /api/identities should create a new version of an identity with a duplicate stix.id but different stix.modified date', async function () { - identity2 = _.cloneDeep(identity1); - identity2._id = undefined; - identity2.__t = undefined; - identity2.__v = undefined; + identity2 = cloneForCreate(identity1); const timestamp = new Date().toISOString(); identity2.stix.modified = timestamp; const body = identity2; @@ -195,7 +197,7 @@ describe('Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -208,7 +210,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities/' + identity2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -226,7 +228,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities/' + identity1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -241,7 +243,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities/' + identity1.stix.id + '/modified/' + identity1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -257,7 +259,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities/' + identity2.stix.id + '/modified/' + identity2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -271,10 +273,7 @@ describe('Identity API', function () { let identity3; it('POST /api/identities should create a new version of an identity with a duplicate stix.id but different stix.modified date', async function () { - identity3 = _.cloneDeep(identity1); - identity3._id = undefined; - identity3.__t = undefined; - identity3.__v = undefined; + identity3 = cloneForCreate(identity1); const timestamp = new Date().toISOString(); identity3.stix.modified = timestamp; const body = identity3; @@ -282,7 +281,7 @@ describe('Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -294,21 +293,21 @@ describe('Identity API', function () { it('DELETE /api/identities/:id should not delete a identity when the id cannot be found', async function () { await request(app) .delete('/api/identities/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/identities/:id/modified/:modified deletes an identity', async function () { await request(app) .delete('/api/identities/' + identity1.stix.id + '/modified/' + identity1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/identities/:id should delete all the identities with the same stix id', async function () { await request(app) .delete('/api/identities/' + identity2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -316,7 +315,7 @@ describe('Identity API', function () { const res = await request(app) .get('/api/identities') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/marking-definitions/marking-definitions.spec.js b/app/tests/api/marking-definitions/marking-definitions.spec.js index d0b5707a..39c4d594 100644 --- a/app/tests/api/marking-definitions/marking-definitions.spec.js +++ b/app/tests/api/marking-definitions/marking-definitions.spec.js @@ -1,9 +1,10 @@ const request = require('supertest'); const { expect } = require('expect'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); - +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); @@ -23,6 +24,7 @@ const initialObjectData = { definition_type: 'statement', definition: { statement: 'This is a marking definition.' }, created_by_ref: 'identity--6444f546-6900-4456-b3b1-015c88d70dab', + object_marking_refs: ['marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9'], }, }; @@ -38,6 +40,10 @@ describe('Marking Definitions API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -49,7 +55,7 @@ describe('Marking Definitions API', function () { const res = await request(app) .get('/api/marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -66,7 +72,7 @@ describe('Marking Definitions API', function () { .post('/api/marking-definitions') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -79,7 +85,7 @@ describe('Marking Definitions API', function () { .post('/api/marking-definitions') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -95,7 +101,7 @@ describe('Marking Definitions API', function () { const res = await request(app) .get('/api/marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -114,7 +120,7 @@ describe('Marking Definitions API', function () { await request(app) .get('/api/marking-definitions/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -122,7 +128,7 @@ describe('Marking Definitions API', function () { const res = await request(app) .get('/api/marking-definitions/' + markingDefinition1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -148,12 +154,12 @@ describe('Marking Definitions API', function () { it('PUT /api/marking-definitions updates a marking definition', async function () { markingDefinition1.stix.description = 'This is an updated marking definition.'; - const body = markingDefinition1; + const body = cloneForCreate(markingDefinition1); const res = await request(app) .put('/api/marking-definitions/' + markingDefinition1.stix.id) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -164,19 +170,19 @@ describe('Marking Definitions API', function () { }); it('POST /api/marking-definitions does not create a marking definition with the same id', async function () { - const body = markingDefinition1; + const body = cloneForCreate(markingDefinition1); await request(app) .post('/api/marking-definitions') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); it('DELETE /api/marking-definitions deletes a marking definition', async function () { await request(app) .delete('/api/marking-definitions/' + markingDefinition1.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -184,7 +190,7 @@ describe('Marking Definitions API', function () { const res = await request(app) .get('/api/marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/matrices/matrices.spec.js b/app/tests/api/matrices/matrices.spec.js index 01ea2922..a77adfd4 100644 --- a/app/tests/api/matrices/matrices.spec.js +++ b/app/tests/api/matrices/matrices.spec.js @@ -2,18 +2,18 @@ const fs = require('fs').promises; const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; -const collectionBundlesService = require('../../../services/collection-bundles-service'); +const collectionBundlesService = require('../../../services/stix/collection-bundles-service'); async function readJson(path) { const data = await fs.readFile(require.resolve(path)); @@ -40,7 +40,7 @@ const initialObjectData = { 'x-mitre-tactic--daa4cbb1-b4f4-4723-a824-7f1efd6e0592', 'x-mitre-tactic--d679bca2-e57d-4935-8650-8031c87a4400', ], - x_mitre_domains: ['mitre-attack'], + x_mitre_domains: ['enterprise-attack'], x_mitre_version: '1.0', }, }; @@ -60,6 +60,10 @@ describe('Matrices API', function () { // Initialize the express app app = await require('../../../index').initializeApp(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Log into the app passportCookie = await login.loginAnonymous(app); }); @@ -68,7 +72,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -85,7 +89,7 @@ describe('Matrices API', function () { .post('/api/matrices') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -99,7 +103,7 @@ describe('Matrices API', function () { .post('/api/matrices') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -117,7 +121,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -132,7 +136,7 @@ describe('Matrices API', function () { await request(app) .get('/api/matrices/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -140,7 +144,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices/' + matrix1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -178,7 +182,7 @@ describe('Matrices API', function () { .put('/api/matrices/' + matrix1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -195,16 +199,13 @@ describe('Matrices API', function () { .post('/api/matrices') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let matrix2; it('POST /api/matrices should create a new version of a matrix with a duplicate stix.id but different stix.modified date', async function () { - matrix2 = _.cloneDeep(matrix1); - matrix2._id = undefined; - matrix2.__t = undefined; - matrix2.__v = undefined; + matrix2 = cloneForCreate(matrix1); const timestamp = new Date().toISOString(); matrix2.stix.modified = timestamp; const body = matrix2; @@ -213,7 +214,7 @@ describe('Matrices API', function () { .post('/api/matrices') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -224,10 +225,7 @@ describe('Matrices API', function () { let matrix3; it('POST /api/matrices should create a new version of a matrix with a duplicate stix.id but different stix.modified date', async function () { - matrix3 = _.cloneDeep(matrix1); - matrix3._id = undefined; - matrix3.__t = undefined; - matrix3.__v = undefined; + matrix3 = cloneForCreate(matrix1); const timestamp = new Date().toISOString(); matrix3.stix.modified = timestamp; const body = matrix3; @@ -236,7 +234,7 @@ describe('Matrices API', function () { .post('/api/matrices') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -249,7 +247,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices/' + matrix3.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); // We expect to get one matrix in an array @@ -266,7 +264,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices/' + matrix1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -281,7 +279,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices/' + matrix1.stix.id + '/modified/' + matrix1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); // We expect to get one matrix in an array @@ -296,7 +294,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices/' + matrix2.stix.id + '/modified/' + matrix2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -311,21 +309,21 @@ describe('Matrices API', function () { it('DELETE /api/matrices/:id should not delete a matrix when the id cannot be found', async function () { await request(app) .delete('/api/matrices/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/matrices/:id/modified/:modified deletes a matrix', async function () { await request(app) .delete('/api/matrices/' + matrix1.stix.id + '/modified/' + matrix1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/matrices/:id should delete all the matrices with the same stix id', async function () { await request(app) .delete('/api/matrices/' + matrix2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -333,7 +331,7 @@ describe('Matrices API', function () { const res = await request(app) .get('/api/matrices') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -358,7 +356,7 @@ describe('Matrices API', function () { '/api/matrices/x-mitre-matrix--2a4858a3-85c3-4418-9729-c3e79800acf7/modified/2020-01-01T00:00:00.000Z/techniques', ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/mitigations/mitigations-pagination.spec.js b/app/tests/api/mitigations/mitigations-pagination.spec.js index fa3fa401..cf4ec5d8 100644 --- a/app/tests/api/mitigations/mitigations-pagination.spec.js +++ b/app/tests/api/mitigations/mitigations-pagination.spec.js @@ -1,4 +1,4 @@ -const mitigationsService = require('../../../services/mitigations-service'); +const mitigationsService = require('../../../services/stix/mitigations-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -24,6 +24,9 @@ const options = { prefix: 'course-of-action', baseUrl: '/api/mitigations', label: 'Mitigations', + // 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, }; const paginationTests = new PaginationTests(mitigationsService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/mitigations/mitigations.spec.js b/app/tests/api/mitigations/mitigations.spec.js index ca5894c5..191de6f6 100644 --- a/app/tests/api/mitigations/mitigations.spec.js +++ b/app/tests/api/mitigations/mitigations.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -44,6 +44,10 @@ describe('Mitigations API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -55,7 +59,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -72,7 +76,7 @@ describe('Mitigations API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -86,7 +90,7 @@ describe('Mitigations API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -108,7 +112,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -123,7 +127,7 @@ describe('Mitigations API', function () { await request(app) .get('/api/mitigations/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -131,7 +135,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations/' + mitigation1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -173,7 +177,7 @@ describe('Mitigations API', function () { .put('/api/mitigations/' + mitigation1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -190,16 +194,13 @@ describe('Mitigations API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let mitigation2; it('POST /api/mitigations should create a new version of a mitigation with a duplicate stix.id but different stix.modified date', async function () { - mitigation2 = _.cloneDeep(mitigation1); - mitigation2._id = undefined; - mitigation2.__t = undefined; - mitigation2.__v = undefined; + mitigation2 = cloneForCreate(mitigation1); const timestamp = new Date().toISOString(); mitigation2.stix.modified = timestamp; const body = mitigation2; @@ -207,7 +208,7 @@ describe('Mitigations API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -218,10 +219,7 @@ describe('Mitigations API', function () { let mitigation3; it('POST /api/mitigations should create a new version of a mitigation with a duplicate stix.id but different stix.modified date', async function () { - mitigation3 = _.cloneDeep(mitigation1); - mitigation3._id = undefined; - mitigation3.__t = undefined; - mitigation3.__v = undefined; + mitigation3 = cloneForCreate(mitigation1); const timestamp = new Date().toISOString(); mitigation3.stix.modified = timestamp; const body = mitigation3; @@ -229,7 +227,7 @@ describe('Mitigations API', function () { .post('/api/mitigations') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -242,7 +240,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations/' + mitigation3.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -260,7 +258,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations/' + mitigation1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -275,7 +273,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations/' + mitigation1.stix.id + '/modified/' + mitigation1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -291,7 +289,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations/' + mitigation2.stix.id + '/modified/' + mitigation2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -306,21 +304,21 @@ describe('Mitigations API', function () { it('DELETE /api/mitigations/:id should not delete a mitigation when the id cannot be found', async function () { await request(app) .delete('/api/mitigations/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/mitigations/:id/modified/:modified deletes a mitigation', async function () { await request(app) .delete('/api/mitigations/' + mitigation1.stix.id + '/modified/' + mitigation1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/mitigations/:id should delete all the mitigations with the same stix id', async function () { await request(app) .delete('/api/mitigations/' + mitigation2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -328,7 +326,7 @@ describe('Mitigations API', function () { const res = await request(app) .get('/api/mitigations') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/notes/notes.spec.js b/app/tests/api/notes/notes.spec.js index b47ed430..6493d747 100644 --- a/app/tests/api/notes/notes.spec.js +++ b/app/tests/api/notes/notes.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -44,6 +44,10 @@ describe('Notes API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -55,7 +59,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -72,7 +76,7 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -86,7 +90,7 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -104,7 +108,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -119,7 +123,7 @@ describe('Notes API', function () { const res = await request(app) .get(`/api/notes?lastUpdatedBy=${note1.workspace.workflow.created_by_user_account}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -134,7 +138,7 @@ describe('Notes API', function () { const res = await request(app) .get(`/api/notes?lastUpdatedBy=identity--11111111-1111-1111-1111-111111111111`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -149,7 +153,7 @@ describe('Notes API', function () { await request(app) .get('/api/notes/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -157,7 +161,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/' + note1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -190,7 +194,7 @@ describe('Notes API', function () { .put('/api/notes/' + note1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -207,16 +211,13 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let note2; it('POST /api/notes should create a new version of a note with a duplicate stix.id but different stix.modified date', async function () { - note2 = _.cloneDeep(note1); - note2._id = undefined; - note2.__t = undefined; - note2.__v = undefined; + note2 = cloneForCreate(note1); const timestamp = new Date().toISOString(); note2.stix.abstract = 'This is the abstract for a note.'; note2.stix.content = 'Still a note. Parchment.'; @@ -226,7 +227,7 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -239,7 +240,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/' + note2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -255,10 +256,7 @@ describe('Notes API', function () { let note3; it('POST /api/notes should create a new note with a new stix.id', async function () { - note3 = _.cloneDeep(note1); - note3._id = undefined; - note3.__t = undefined; - note3.__v = undefined; + note3 = cloneForCreate(note1); note3.stix.id = undefined; const timestamp = new Date().toISOString(); note3.stix.abstract = 'This is the abstract for a note.'; @@ -269,7 +267,7 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -280,10 +278,7 @@ describe('Notes API', function () { let note4; it('POST /api/notes should create a new version of the last note with a duplicate stix.id but different stix.modified date', async function () { - note4 = _.cloneDeep(note3); - note4._id = undefined; - note4.__t = undefined; - note4.__v = undefined; + note4 = cloneForCreate(note3); const timestamp = new Date().toISOString(); note4.stix.abstract = 'This is the abstract for a note. Parchment'; note4.stix.content = 'Still a note.'; @@ -293,7 +288,7 @@ describe('Notes API', function () { .post('/api/notes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -306,7 +301,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/' + note1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -321,7 +316,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/' + note1.stix.id + '/modified/' + note1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -337,7 +332,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/' + note2.stix.id + '/modified/' + note2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -353,7 +348,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes/') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -368,7 +363,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes?search=PARCHMENT') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -383,7 +378,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes?search=IVORY') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -397,28 +392,28 @@ describe('Notes API', function () { it('DELETE /api/notes/:id should not delete a note when the id cannot be found', async function () { await request(app) .delete('/api/notes/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/notes/:id/modified/:modified should delete the first version of the note', async function () { await request(app) .delete('/api/notes/' + note1.stix.id + '/modified/' + note1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/notes/:id/modified/:modified should delete the second version of the note', async function () { await request(app) .delete('/api/notes/' + note2.stix.id + '/modified/' + note2.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/notes/:id should delete all versions of the note', async function () { await request(app) .delete('/api/notes/' + note3.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -426,7 +421,7 @@ describe('Notes API', function () { const res = await request(app) .get('/api/notes') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/recent-activity/recent-activity.spec.js b/app/tests/api/recent-activity/recent-activity.spec.js index e7ec1073..1c7e9819 100644 --- a/app/tests/api/recent-activity/recent-activity.spec.js +++ b/app/tests/api/recent-activity/recent-activity.spec.js @@ -5,13 +5,13 @@ const { expect } = require('expect'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); - +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); logger.level = 'debug'; -const collectionBundlesService = require('../../../services/collection-bundles-service'); +const collectionBundlesService = require('../../../services/stix/collection-bundles-service'); async function readJson(path) { const data = await fs.readFile(require.resolve(path)); @@ -30,6 +30,10 @@ describe('Recent Activity API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; this suite seeds existing STIX bundle fixtures + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -41,7 +45,7 @@ describe('Recent Activity API', function () { const res = await request(app) .get('/api/recent-activity') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -68,7 +72,7 @@ describe('Recent Activity API', function () { const res = await request(app) .get('/api/recent-activity') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/references/references.spec.js b/app/tests/api/references/references.spec.js index 32aedffe..c4d2d08f 100644 --- a/app/tests/api/references/references.spec.js +++ b/app/tests/api/references/references.spec.js @@ -4,7 +4,7 @@ const { expect } = require('expect'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); - +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -45,6 +45,10 @@ describe('References API', function () { // Wait until the Reference indexes are created await Reference.init(); + // Enable ADM validation for consistency with STIX-object API suites + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -56,7 +60,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -73,7 +77,7 @@ describe('References API', function () { .post('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -84,7 +88,7 @@ describe('References API', function () { .post('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -100,7 +104,7 @@ describe('References API', function () { .post('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -116,7 +120,7 @@ describe('References API', function () { .post('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -129,7 +133,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -144,7 +148,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references?sourceName=notasourcename') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200); // We expect to get an empty array @@ -158,7 +162,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references?sourceName=' + encodeURIComponent(reference1.source_name)) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -179,7 +183,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references?search=' + encodeURIComponent('#3')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -200,7 +204,7 @@ describe('References API', function () { const res = await request(app) .get('/api/references?search=' + encodeURIComponent('unique')) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -223,7 +227,7 @@ describe('References API', function () { .put('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -237,7 +241,7 @@ describe('References API', function () { .put('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -248,7 +252,7 @@ describe('References API', function () { .put('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -265,7 +269,7 @@ describe('References API', function () { .post('/api/references') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); @@ -273,7 +277,7 @@ describe('References API', function () { await request(app) .delete('/api/references') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -281,7 +285,7 @@ describe('References API', function () { await request(app) .delete('/api/references?sourceName=not-a-reference') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -289,7 +293,7 @@ describe('References API', function () { await request(app) .delete(`/api/references?sourceName=${reference1.source_name}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); diff --git a/app/tests/api/relationships/relationships-pagination.spec.js b/app/tests/api/relationships/relationships-pagination.spec.js index a196add8..de2f5fd9 100644 --- a/app/tests/api/relationships/relationships-pagination.spec.js +++ b/app/tests/api/relationships/relationships-pagination.spec.js @@ -1,5 +1,8 @@ -const relationshipsService = require('../../../services/relationships-service'); +const relationshipsService = require('../../../services/stix/relationships-service'); const PaginationTests = require('../../shared/pagination'); +const config = require('../../../config/config'); + +config.validateRequests.withOpenApi = true; // modified and created properties will be set before calling REST API // stix.id property will be created by REST API @@ -28,6 +31,17 @@ const options = { prefix: 'relationship', baseUrl: '/api/relationships', label: 'Relationships', + validateWithAdm: true, +}; +const relationshipsPaginationService = { + async create(data, options) { + delete data.stix.name; + return relationshipsService.create(data, options); + }, }; -const paginationTests = new PaginationTests(relationshipsService, initialObjectData, options); +const paginationTests = new PaginationTests( + relationshipsPaginationService, + initialObjectData, + options, +); paginationTests.executeTests(); diff --git a/app/tests/api/relationships/relationships.spec.js b/app/tests/api/relationships/relationships.spec.js index 06f6536e..a18e7f05 100644 --- a/app/tests/api/relationships/relationships.spec.js +++ b/app/tests/api/relationships/relationships.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -53,6 +53,10 @@ describe('Relationships API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -64,7 +68,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); // We expect to get an empty array @@ -80,7 +84,7 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -94,7 +98,7 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -112,7 +116,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -127,7 +131,7 @@ describe('Relationships API', function () { await request(app) .get('/api/relationships/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -135,7 +139,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships/' + relationship1a.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -172,7 +176,7 @@ describe('Relationships API', function () { .put('/api/relationships/' + relationship1a.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -189,16 +193,13 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let relationship1b; it('POST /api/relationships should create a new version of a relationship with a duplicate stix.id but different stix.modified date', async function () { - relationship1b = _.cloneDeep(relationship1a); - relationship1b._id = undefined; - relationship1b.__t = undefined; - relationship1b.__v = undefined; + relationship1b = cloneForCreate(relationship1a); const timestamp = new Date().toISOString(); relationship1b.stix.modified = timestamp; const body = relationship1b; @@ -206,7 +207,7 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -217,10 +218,7 @@ describe('Relationships API', function () { let relationship1c; it('POST /api/relationships should create a new version of a relationship with a duplicate stix.id but different stix.modified date', async function () { - relationship1c = _.cloneDeep(relationship1a); - relationship1c._id = undefined; - relationship1c.__t = undefined; - relationship1c.__v = undefined; + relationship1c = cloneForCreate(relationship1a); const timestamp = new Date().toISOString(); relationship1c.stix.modified = timestamp; const body = relationship1c; @@ -228,7 +226,7 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -241,7 +239,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -259,7 +257,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -274,7 +272,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships/' + relationship1b.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -292,7 +290,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships/' + relationship1a.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -312,7 +310,7 @@ describe('Relationships API', function () { relationship1a.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -333,7 +331,7 @@ describe('Relationships API', function () { relationship1b.stix.modified, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -357,7 +355,7 @@ describe('Relationships API', function () { .post('/api/relationships') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -366,11 +364,73 @@ describe('Relationships API', function () { expect(relationship2).toBeDefined(); }); + let relationship3a; + it('POST /api/relationships creates a parallel relationship', async function () { + const timestamp = new Date().toISOString(); + initialObjectData.stix.created = timestamp; + initialObjectData.stix.modified = timestamp; + initialObjectData.stix.source_ref = sourceRef2; + initialObjectData.stix.target_ref = targetRef2; + const body = initialObjectData; + const res = await request(app) + .post('/api/relationships') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // We expect to get the created relationship + relationship3a = res.body; + expect(relationship3a).toBeDefined(); + }); + + let relationship3b; + it('POST /api/relationships creates a parallel relationship with a different description', async function () { + const timestamp = new Date().toISOString(); + initialObjectData.stix.created = timestamp; + initialObjectData.stix.modified = timestamp; + initialObjectData.stix.source_ref = sourceRef2; + initialObjectData.stix.target_ref = targetRef2; + initialObjectData.stix.description = + 'This is a different description with a URL that it should not have (https://attack.mitre.org/foo/bar).'; + const body = initialObjectData; + const res = await request(app) + .post('/api/relationships') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // We expect to get the created relationship + relationship3b = res.body; + expect(relationship3b).toBeDefined(); + }); + + it('GET /api/reports/parallel-relationships returns the parallel relationships', async function () { + const res = await request(app) + .get('/api/reports/parallel-relationships') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + // console.log(res.body); + // We expect to get a mapping of relationship key -> list of three parallel relationships + const parallelRelationships = res.body; + expect(parallelRelationships).toBeDefined(); + const pRelationships = Object.values(parallelRelationships)[0]; + expect(Array.isArray(pRelationships)).toBe(true); + expect(pRelationships.length).toBe(3); + expect(pRelationships[0].stix.source_ref).toBe(sourceRef2); + }); + it('GET /api/relationships returns the (latest) relationship matching a source_ref', async function () { const res = await request(app) .get('/api/relationships?sourceRef=' + sourceRef1) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -385,7 +445,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?targetRef=' + targetRef1) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -400,7 +460,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?sourceOrTargetRef=' + sourceRef1) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -415,7 +475,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?sourceRef=' + sourceRef3) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -430,7 +490,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?targetRef=' + targetRef3) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -445,7 +505,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships?sourceOrTargetRef=' + sourceRef3) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -459,7 +519,7 @@ describe('Relationships API', function () { it('DELETE /api/relationships/:id should not delete a relationship when the id cannot be found', async function () { await request(app) .delete('/api/relationships/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -471,14 +531,14 @@ describe('Relationships API', function () { '/modified/' + relationship1a.stix.modified, ) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/relationships/:id should delete all the relationships with the same stix id', async function () { await request(app) .delete('/api/relationships/' + relationship1b.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -487,7 +547,31 @@ describe('Relationships API', function () { .delete( '/api/relationships/' + relationship2.stix.id + '/modified/' + relationship2.stix.modified, ) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(204); + }); + + it('DELETE /api/relationships should delete the fourth relationship', async function () { + await request(app) + .delete( + '/api/relationships/' + + relationship3a.stix.id + + '/modified/' + + relationship3a.stix.modified, + ) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(204); + }); + + it('DELETE /api/relationships should delete the fifth relationship', async function () { + await request(app) + .delete( + '/api/relationships/' + + relationship3b.stix.id + + '/modified/' + + relationship3b.stix.modified, + ) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -495,7 +579,7 @@ describe('Relationships API', function () { const res = await request(app) .get('/api/relationships') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/reports/reports.spec.js b/app/tests/api/reports/reports.spec.js new file mode 100644 index 00000000..e3e696be --- /dev/null +++ b/app/tests/api/reports/reports.spec.js @@ -0,0 +1,162 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const AttackObject = require('../../../models/attack-object-model'); +const config = require('../../../config/config'); +const login = require('../../shared/login'); + +const logger = require('../../../lib/logger'); +logger.level = 'debug'; + +const targetRef2 = 'attack-pattern--d63a3fb8-9452-4e9d-a60a-54be68d5998c'; + +// test malware object +const malwareObject = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + id: 'malware--1c1ab115-f015-462c-92a0-f887277d8519', + name: 'software-2', + spec_version: '2.1', + type: 'malware', + description: + 'This is a malware type of software, with a URL that it should not have (https://attack.mitre.org/software/SW0001)', + is_family: false, + object_marking_refs: ['marking-definition--c2a0b8f8-51d4-4702-8e42-ce7a65235bce'], + x_mitre_version: '1.1', + x_mitre_contributors: ['contributor-mk', 'contributor-cm'], + x_mitre_domains: ['mobile-attack'], + created: '2023-03-01T00:00:00.000Z', + modified: '2023-03-01T00:00:00.000Z', + }, +}; + +const initialObjectData = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + spec_version: '2.1', + type: 'relationship', + description: 'This is a relationship containing https://attack.mitre.org/.', + source_ref: malwareObject.stix.id, + relationship_type: 'uses', + target_ref: targetRef2, + external_references: [{ source_name: 'source-1', external_id: 's1' }], + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--6444f546-6900-4456-b3b1-015c88d70dab', + }, +}; + +describe('Reports API', function () { + let app; + let passportCookie; + + before(async function () { + // Establish the database connection + // Use an in-memory database that we spin up for the test + await database.initializeConnection(); + + // Wait until the indexes are created + await AttackObject.init(); + + // Check for a valid database configuration + await databaseConfiguration.checkSystemConfiguration(); + + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + + // Initialize the express app + app = await require('../../../index').initializeApp(); + + // Log into the app + passportCookie = await login.loginAnonymous(app); + }); + + let software1; + it('POST /api/software creates a software', async function () { + // Further setup - need to index malware object with in database first + const body = malwareObject; + const res = await request(app) + .post('/api/software') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + software1 = res.body; + expect(software1).toBeDefined(); + expect(software1.stix).toBeDefined(); + expect(software1.stix.id).toBeDefined(); + expect(software1.stix.created).toBeDefined(); + expect(software1.stix.modified).toBeDefined(); + }); + + it('GET /api/reports/link-by-id/missing returns the object with an attack.mitre.org URL in the description', async function () { + const res = await request(app) + .get('/api/reports/link-by-id/missing') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + // We expect to get ATT&CK objects in an array + const attackObjects = res.body; + expect(attackObjects).toBeDefined(); + expect(Array.isArray(attackObjects)).toBe(true); + + expect(attackObjects.length).toBe(1); + expect(attackObjects[0].stix.name).toBe('software-2'); + }); + + let relationship2; + it('POST /api/relationships creates a relationship', async function () { + const timestamp = new Date().toISOString(); + initialObjectData.stix.created = timestamp; + initialObjectData.stix.modified = timestamp; + initialObjectData.stix.source_ref = software1.stix.id; + initialObjectData.stix.target_ref = targetRef2; + const body = initialObjectData; + const res = await request(app) + .post('/api/relationships') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // We expect to get the created relationship + relationship2 = res.body; + expect(relationship2).toBeDefined(); + }); + + it('GET /api/reports/link-by-id/missing returns the relationship with an attack.mitre.org URL in the description', async function () { + const res = await request(app) + .get('/api/reports/link-by-id/missing') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + // We expect to get one relationship in an array + const mlRelationships = res.body; + expect(mlRelationships).toBeDefined(); + expect(Array.isArray(mlRelationships)).toBe(true); + + expect(mlRelationships.length).toBe(2); + expect(mlRelationships[0].stix.source_ref).toBe(software1.stix.id); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/app/tests/api/session/session.spec.js b/app/tests/api/session/session.spec.js index b94d3461..3fccac23 100644 --- a/app/tests/api/session/session.spec.js +++ b/app/tests/api/session/session.spec.js @@ -3,7 +3,7 @@ const request = require('supertest'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); - +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -18,6 +18,10 @@ describe('Session API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation for consistency with STIX-object API suites + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); }); diff --git a/app/tests/api/software/software-pagination.spec.js b/app/tests/api/software/software-pagination.spec.js index 7a92211e..b0a5a2e4 100644 --- a/app/tests/api/software/software-pagination.spec.js +++ b/app/tests/api/software/software-pagination.spec.js @@ -1,5 +1,8 @@ -const softwareService = require('../../../services/software-service'); +const softwareService = require('../../../services/stix/software-service'); const PaginationTests = require('../../shared/pagination'); +const config = require('../../../config/config'); + +config.validateRequests.withOpenApi = true; // modified and created properties will be set before calling REST API // stix.id property will be created by REST API @@ -19,7 +22,7 @@ const initialObjectData = { created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', x_mitre_aliases: ['software-1'], - x_mitre_platforms: ['platform-1'], + x_mitre_platforms: ['Android'], x_mitre_contributors: ['contributor-1', 'contributor-2'], x_mitre_domains: ['mobile-attack'], }, @@ -29,6 +32,7 @@ const options = { prefix: 'software', baseUrl: '/api/software', label: 'Software', + validateWithAdm: true, }; const paginationTests = new PaginationTests(softwareService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/software/software.spec.js b/app/tests/api/software/software.spec.js index 5052b621..d6894487 100644 --- a/app/tests/api/software/software.spec.js +++ b/app/tests/api/software/software.spec.js @@ -7,6 +7,7 @@ const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -30,7 +31,7 @@ const initialObjectData = { created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version: '1.1', x_mitre_aliases: ['software-1'], - x_mitre_platforms: ['platform-1'], + x_mitre_platforms: ['Android'], x_mitre_contributors: ['contributor-1', 'contributor-2'], x_mitre_domains: ['mobile-attack'], }, @@ -60,6 +61,10 @@ describe('Software API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -71,7 +76,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -88,7 +93,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -101,7 +106,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -114,7 +119,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -128,7 +133,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -146,7 +151,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -161,7 +166,7 @@ describe('Software API', function () { await request(app) .get('/api/software/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -169,7 +174,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software/' + software1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -217,7 +222,7 @@ describe('Software API', function () { .put('/api/software/' + software1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -234,16 +239,13 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let software2; it('POST /api/software should create a new version of a software with a duplicate stix.id but different stix.modified date', async function () { - software2 = _.cloneDeep(software1); - software2._id = undefined; - software2.__t = undefined; - software2.__v = undefined; + software2 = cloneForCreate(software1); const timestamp = new Date().toISOString(); software2.stix.modified = timestamp; const body = software2; @@ -251,7 +253,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -264,7 +266,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software/' + software2.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -282,7 +284,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software/' + software1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -297,7 +299,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software/' + software1.stix.id + '/modified/' + software1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -313,7 +315,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software/' + software2.stix.id + '/modified/' + software2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -327,10 +329,7 @@ describe('Software API', function () { let software3; it('POST /api/software should create a new version of a software with a duplicate stix.id but different stix.modified date', async function () { - software3 = _.cloneDeep(software1); - software3._id = undefined; - software3.__t = undefined; - software3.__v = undefined; + software3 = cloneForCreate(software1); const timestamp = new Date().toISOString(); software3.stix.modified = timestamp; const body = software3; @@ -338,7 +337,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -350,21 +349,21 @@ describe('Software API', function () { it('DELETE /api/software/:id should not delete a software when the id cannot be found', async function () { await request(app) .delete('/api/software/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/software/:id/modified/:modified deletes a software', async function () { await request(app) .delete('/api/software/' + software1.stix.id + '/modified/' + software1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/software/:id should delete all the software with the same stix id', async function () { await request(app) .delete('/api/software/' + software2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -372,7 +371,7 @@ describe('Software API', function () { const res = await request(app) .get('/api/software') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -392,7 +391,7 @@ describe('Software API', function () { .post('/api/software') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); diff --git a/app/tests/api/stix-bundles/stix-bundles-old.spec.js b/app/tests/api/stix-bundles/stix-bundles-old.spec.js index 58cc042c..893ba0c3 100644 --- a/app/tests/api/stix-bundles/stix-bundles-old.spec.js +++ b/app/tests/api/stix-bundles/stix-bundles-old.spec.js @@ -1,6 +1,7 @@ const request = require('supertest'); const { expect } = require('expect'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -629,6 +630,121 @@ const initialObjectData = { ], }; +function normalizeLegacyBundleFixtureForAdmImport(bundle) { + const counters = { + 'attack-pattern': 9000, + 'course-of-action': 9000, + malware: 9000, + 'intrusion-set': 9000, + 'x-mitre-data-source': 9000, + }; + + for (const stixObject of bundle.objects) { + if ( + Array.isArray(stixObject.external_references) && + stixObject.external_references.length === 0 && + stixObject.type !== 'intrusion-set' + ) { + delete stixObject.external_references; + } + + if (stixObject.type !== 'marking-definition' && stixObject.type !== 'note') { + stixObject.x_mitre_attack_spec_version = config.app.attackSpecVersion; + } + + switch (stixObject.type) { + case 'x-mitre-collection': + stixObject.x_mitre_version = '1.0'; + break; + + case 'identity': + break; + + case 'attack-pattern': + counters[stixObject.type] += 1; + stixObject.external_references[0].external_id = `T${counters[stixObject.type]}`; + stixObject.kill_chain_phases = stixObject.kill_chain_phases.map((phase) => ({ + ...phase, + kill_chain_name: stixObject.x_mitre_domains.includes(mobileDomain) + ? 'mitre-mobile-attack' + : stixObject.x_mitre_domains.includes(icsDomain) + ? 'mitre-ics-attack' + : 'mitre-attack', + phase_name: 'execution', + })); + stixObject.x_mitre_platforms = ['Windows', 'Linux']; + delete stixObject.x_mitre_impact_type; + break; + + case 'course-of-action': + counters[stixObject.type] += 1; + stixObject.external_references[0].external_id = `M${counters[stixObject.type]}`; + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + break; + + case 'malware': + counters[stixObject.type] += 1; + stixObject.external_references[0].external_id = `S${counters[stixObject.type]}`; + stixObject.is_family = false; + stixObject.x_mitre_aliases = [stixObject.name, ...stixObject.x_mitre_aliases]; + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + stixObject.x_mitre_platforms = ['Windows']; + break; + + case 'intrusion-set': + counters[stixObject.type] += 1; + if ( + !Array.isArray(stixObject.external_references) || + stixObject.external_references.length === 0 + ) { + stixObject.external_references = [ + { source_name: 'mitre-attack', external_id: `G${counters[stixObject.type]}` }, + ]; + } + stixObject.aliases = [stixObject.name, ...stixObject.aliases]; + stixObject.x_mitre_domains = [enterpriseDomain]; + break; + + case 'campaign': + stixObject.aliases = [stixObject.name, ...stixObject.aliases]; + stixObject.external_references.push( + { source_name: 'Article 1', description: 'First seen citation.' }, + { source_name: 'Article 2', description: 'Last seen citation.' }, + ); + stixObject.revoked = false; + stixObject.x_mitre_domains = [enterpriseDomain]; + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + break; + + case 'relationship': + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + break; + + case 'x-mitre-data-source': + counters[stixObject.type] += 1; + stixObject.external_references[0].external_id = `DS${counters[stixObject.type]}`; + stixObject.description = `${stixObject.name} data source.`; + stixObject.x_mitre_collection_layers = ['Host']; + stixObject.x_mitre_domains = [enterpriseDomain]; + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + stixObject.x_mitre_version = '1.0'; + break; + + case 'x-mitre-data-component': + stixObject.description = `${stixObject.name} data component.`; + stixObject.x_mitre_domains = [enterpriseDomain]; + stixObject.x_mitre_modified_by_ref = mitreIdentityId; + stixObject.x_mitre_version = '1.0'; + break; + + default: + break; + } + } +} + +normalizeLegacyBundleFixtureForAdmImport(initialObjectData); + // function printBundleCount(bundle) { // const count = { // techniques: 0, @@ -670,6 +786,10 @@ describe('STIX Bundles Basic API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation for the legacy bundle import path + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -683,7 +803,7 @@ describe('STIX Bundles Basic API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/) .end(function (err, res) { @@ -707,7 +827,7 @@ describe('STIX Bundles Basic API', function () { .get('/api/stix-bundles?domain=not-a-domain') .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .end(function (err, res) { if (err) { @@ -728,7 +848,7 @@ describe('STIX Bundles Basic API', function () { .get(`/api/stix-bundles?domain=${enterpriseDomain}&includeNotes=true`) .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { @@ -762,7 +882,7 @@ describe('STIX Bundles Basic API', function () { ) .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { @@ -802,7 +922,7 @@ describe('STIX Bundles Basic API', function () { .get(`/api/stix-bundles?domain=${enterpriseDomain}&includeDeprecated=true&includeNotes=true`) .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { @@ -827,7 +947,7 @@ describe('STIX Bundles Basic API', function () { .get(`/api/stix-bundles?domain=${mobileDomain}`) .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { @@ -851,7 +971,7 @@ describe('STIX Bundles Basic API', function () { .get(`/api/stix-bundles?domain=${icsDomain}`) .query({ useLegacyMethod: true }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .end(function (err, res) { diff --git a/app/tests/api/stix-bundles/stix-bundles.spec.js b/app/tests/api/stix-bundles/stix-bundles.spec.js index 153a2814..98b5c656 100644 --- a/app/tests/api/stix-bundles/stix-bundles.spec.js +++ b/app/tests/api/stix-bundles/stix-bundles.spec.js @@ -58,6 +58,7 @@ const request = require('supertest'); const { expect } = require('expect'); // const fs = require('fs'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -68,7 +69,7 @@ const login = require('../../shared/login'); const enterpriseDomain = 'enterprise-attack'; const icsDomain = 'ics-attack'; -const collectionId = 'x-mitre-collection--b0b12345-aaaa-bbbb-cccc-dddddddddddd'; +const collectionId = 'x-mitre-collection--b0b12345-aaaa-4bbb-8ccc-dddddddddddd'; const collectionTimestamp = new Date().toISOString(); const markingDefinitionId = 'marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'; @@ -103,65 +104,75 @@ const newSpecBundleData = { spec_version: '2.1', type: 'x-mitre-collection', description: 'Test collection for new ATT&CK specification features', - external_references: [], object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_version: '1.0', x_mitre_contents: [ // Techniques - { object_ref: 'attack-pattern--new-ent-001', object_modified: '2024-01-15T10:00:00.000Z' }, - { object_ref: 'attack-pattern--new-ent-002', object_modified: '2024-01-15T10:00:00.000Z' }, - { object_ref: 'attack-pattern--new-ics-001', object_modified: '2024-01-15T10:00:00.000Z' }, + { + object_ref: 'attack-pattern--11111111-1111-4111-8111-111111111111', + object_modified: '2024-01-15T10:00:00.000Z', + }, + { + object_ref: 'attack-pattern--22222222-2222-4222-8222-222222222222', + object_modified: '2024-01-15T10:00:00.000Z', + }, + { + object_ref: 'attack-pattern--33333333-3333-4333-8333-333333333333', + object_modified: '2024-01-15T10:00:00.000Z', + }, // Analytics { - object_ref: 'x-mitre-analytic--new-ana-001', + object_ref: 'x-mitre-analytic--44444444-4444-4444-8444-444444444444', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'x-mitre-analytic--new-ana-002', + object_ref: 'x-mitre-analytic--55555555-5555-4555-8555-555555555555', object_modified: '2024-01-15T10:00:00.000Z', }, // Detection Strategies { - object_ref: 'x-mitre-detection-strategy--new-ds-001', + object_ref: 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'x-mitre-detection-strategy--new-ds-002', + object_ref: 'x-mitre-detection-strategy--77777777-7777-4777-8777-777777777777', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'x-mitre-detection-strategy--new-ds-003', + object_ref: 'x-mitre-detection-strategy--88888888-8888-4888-8888-888888888888', object_modified: '2024-01-15T10:00:00.000Z', }, // Data Components { - object_ref: 'x-mitre-data-component--new-dc-001', + object_ref: 'x-mitre-data-component--99999999-9999-4999-8999-999999999999', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'x-mitre-data-component--new-dc-002', + object_ref: 'x-mitre-data-component--aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', object_modified: '2024-01-15T10:00:00.000Z', }, // Data Sources { - object_ref: 'x-mitre-data-source--new-ds-src-001', + object_ref: 'x-mitre-data-source--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'x-mitre-data-source--new-ds-src-002', + object_ref: 'x-mitre-data-source--cccccccc-cccc-4ccc-8ccc-cccccccccccc', object_modified: '2024-01-15T10:00:00.000Z', }, // Relationships { - object_ref: 'relationship--new-ds-detects-tech-001', + object_ref: 'relationship--dddddddd-dddd-4ddd-8ddd-dddddddddddd', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'relationship--new-ds-detects-tech-002', + object_ref: 'relationship--eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', object_modified: '2024-01-15T10:00:00.000Z', }, { - object_ref: 'relationship--new-dc-detects-tech-dep', + object_ref: 'relationship--ffffffff-ffff-4fff-8fff-ffffffffffff', object_modified: '2024-01-15T10:00:00.000Z', }, // Supporting objects (identity and marking-definition should also be in x_mitre_contents) @@ -175,7 +186,7 @@ const newSpecBundleData = { // ======================================== { type: 'attack-pattern', - id: 'attack-pattern--new-ent-001', + id: 'attack-pattern--11111111-1111-4111-8111-111111111111', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Enterprise Technique 1', @@ -185,13 +196,14 @@ const newSpecBundleData = { created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'T9001' }], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain], x_mitre_version: '1.0', x_mitre_is_subtechnique: false, }, { type: 'attack-pattern', - id: 'attack-pattern--new-ent-002', + id: 'attack-pattern--22222222-2222-4222-8222-222222222222', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Enterprise Technique 2', @@ -201,13 +213,14 @@ const newSpecBundleData = { created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'T9002' }], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain], x_mitre_version: '1.0', x_mitre_is_subtechnique: false, }, { type: 'attack-pattern', - id: 'attack-pattern--new-ics-001', + id: 'attack-pattern--33333333-3333-4333-8333-333333333333', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'ICS Technique 1', @@ -217,6 +230,7 @@ const newSpecBundleData = { created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'T9003' }], kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain, icsDomain], x_mitre_version: '1.0', x_mitre_is_subtechnique: false, @@ -227,7 +241,7 @@ const newSpecBundleData = { // ======================================== { type: 'x-mitre-analytic', - id: 'x-mitre-analytic--new-ana-001', + id: 'x-mitre-analytic--44444444-4444-4444-8444-444444444444', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Process Execution Analytic', @@ -238,16 +252,18 @@ const newSpecBundleData = { external_references: [ { source_name: 'mitre-attack', - external_id: 'ANA-001', - url: 'https://attack.mitre.org/detectionstrategies/DS-002#ANA-001', + external_id: 'AN0001', + url: 'https://attack.mitre.org/detectionstrategies/DET0002#AN0001', }, ], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain], + x_mitre_platforms: ['Windows'], x_mitre_version: '1.0', }, { type: 'x-mitre-analytic', - id: 'x-mitre-analytic--new-ana-002', + id: 'x-mitre-analytic--55555555-5555-4555-8555-555555555555', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Persistence Mechanism Analytic', @@ -255,9 +271,11 @@ const newSpecBundleData = { spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [{ source_name: 'mitre-attack', external_id: 'ANA-002' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'AN0002' }], // Note: No URL, meaning it's not attached to a detection strategy, meaning we don't want it in the bundle + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain], + x_mitre_platforms: ['Windows'], x_mitre_version: '1.0', }, @@ -266,46 +284,52 @@ const newSpecBundleData = { // ======================================== { type: 'x-mitre-detection-strategy', - id: 'x-mitre-detection-strategy--new-ds-001', + id: 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Detection Strategy 1 - Detects Technique via Relationship', - description: 'This detection strategy detects attack-pattern--new-ent-001', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [{ source_name: 'mitre-attack', external_id: 'DS-001' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'DET0001' }], + x_mitre_analytic_refs: ['x-mitre-analytic--44444444-4444-4444-8444-444444444444'], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_domains: [enterpriseDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', - // Note: No x_mitre_domains - this is inferred from relationships }, { type: 'x-mitre-detection-strategy', - id: 'x-mitre-detection-strategy--new-ds-002', + id: 'x-mitre-detection-strategy--77777777-7777-4777-8777-777777777777', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Detection Strategy 2 - References Analytic', - description: 'This detection strategy references x-mitre-analytic--new-ana-001', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [{ source_name: 'mitre-attack', external_id: 'DS-002' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'DET0002' }], + x_mitre_analytic_refs: ['x-mitre-analytic--44444444-4444-4444-8444-444444444444'], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_domains: [enterpriseDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', - x_mitre_analytic_refs: ['x-mitre-analytic--new-ana-001'], - // Note: No x_mitre_domains - this is inferred from analytic refs }, { type: 'x-mitre-detection-strategy', - id: 'x-mitre-detection-strategy--new-ds-003', + id: 'x-mitre-detection-strategy--88888888-8888-4888-8888-888888888888', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Detection Strategy 3 - Not Included (orphaned)', - description: 'This detection strategy should NOT be included - no technique or analytic', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [{ source_name: 'mitre-attack', external_id: 'DS-003' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'DET0003' }], + x_mitre_analytic_refs: ['x-mitre-analytic--55555555-5555-4555-8555-555555555555'], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_domains: [enterpriseDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', - // Note: No detects relationship, no analytic refs, so this should NOT appear in bundle + // Note: No detects relationship, and its analytic lacks a URL, so this should NOT appear in bundle }, // ======================================== @@ -313,7 +337,7 @@ const newSpecBundleData = { // ======================================== { type: 'x-mitre-data-component', - id: 'x-mitre-data-component--new-dc-001', + id: 'x-mitre-data-component--99999999-9999-4999-8999-999999999999', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Enterprise Data Component', @@ -322,13 +346,15 @@ const newSpecBundleData = { object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'DC9001' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [enterpriseDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', - x_mitre_data_source_ref: 'x-mitre-data-source--new-ds-src-001', + x_mitre_data_source_ref: 'x-mitre-data-source--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', }, { type: 'x-mitre-data-component', - id: 'x-mitre-data-component--new-dc-002', + id: 'x-mitre-data-component--aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'ICS Data Component', @@ -337,9 +363,11 @@ const newSpecBundleData = { object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'DC9002' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, x_mitre_domains: [icsDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', - x_mitre_data_source_ref: 'x-mitre-data-source--new-ds-src-002', + x_mitre_data_source_ref: 'x-mitre-data-source--cccccccc-cccc-4ccc-8ccc-cccccccccccc', }, // ======================================== @@ -347,7 +375,7 @@ const newSpecBundleData = { // ======================================== { type: 'x-mitre-data-source', - id: 'x-mitre-data-source--new-ds-src-001', + id: 'x-mitre-data-source--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'Enterprise Data Source', @@ -356,12 +384,15 @@ const newSpecBundleData = { object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'DS9001' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_collection_layers: ['Host'], x_mitre_domains: [enterpriseDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', }, { type: 'x-mitre-data-source', - id: 'x-mitre-data-source--new-ds-src-002', + id: 'x-mitre-data-source--cccccccc-cccc-4ccc-8ccc-cccccccccccc', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', name: 'ICS Data Source', @@ -370,7 +401,10 @@ const newSpecBundleData = { object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, external_references: [{ source_name: 'mitre-attack', external_id: 'DS9002' }], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_collection_layers: ['Host'], x_mitre_domains: [icsDomain], + x_mitre_modified_by_ref: mitreIdentityId, x_mitre_version: '1.0', }, @@ -381,49 +415,52 @@ const newSpecBundleData = { // Valid 'detects' relationship: Detection Strategy → Technique { type: 'relationship', - id: 'relationship--new-ds-detects-tech-001', + id: 'relationship--dddddddd-dddd-4ddd-8ddd-dddddddddddd', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', relationship_type: 'detects', - source_ref: 'x-mitre-detection-strategy--new-ds-001', - target_ref: 'attack-pattern--new-ent-001', + source_ref: 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', + target_ref: 'attack-pattern--11111111-1111-4111-8111-111111111111', description: 'Detection strategy detects enterprise technique 1', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_modified_by_ref: mitreIdentityId, }, // Valid 'detects' relationship: Detection Strategy → Technique (different technique) { type: 'relationship', - id: 'relationship--new-ds-detects-tech-002', + id: 'relationship--eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', relationship_type: 'detects', - source_ref: 'x-mitre-detection-strategy--new-ds-001', - target_ref: 'attack-pattern--new-ent-002', + source_ref: 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', + target_ref: 'attack-pattern--22222222-2222-4222-8222-222222222222', description: 'Detection strategy detects enterprise technique 2', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_modified_by_ref: mitreIdentityId, }, // DEPRECATED 'detects' relationship: Data Component → Technique (should be IGNORED) { type: 'relationship', - id: 'relationship--new-dc-detects-tech-dep', + id: 'relationship--ffffffff-ffff-4fff-8fff-ffffffffffff', created: '2024-01-15T10:00:00.000Z', modified: '2024-01-15T10:00:00.000Z', relationship_type: 'detects', - source_ref: 'x-mitre-data-component--new-dc-001', - target_ref: 'attack-pattern--new-ent-001', + source_ref: 'x-mitre-data-component--99999999-9999-4999-8999-999999999999', + target_ref: 'attack-pattern--11111111-1111-4111-8111-111111111111', description: 'DEPRECATED: Data component detects technique (should be ignored)', spec_version: '2.1', object_marking_refs: [markingDefinitionId], created_by_ref: mitreIdentityId, - external_references: [], + x_mitre_attack_spec_version: config.app.attackSpecVersion, + x_mitre_modified_by_ref: mitreIdentityId, }, // ======================================== @@ -436,11 +473,14 @@ const newSpecBundleData = { modified: '2017-06-01T00:00:00.000Z', name: 'The MITRE Corporation', identity_class: 'organization', + object_marking_refs: [markingDefinitionId], spec_version: '2.1', + x_mitre_attack_spec_version: config.app.attackSpecVersion, }, { type: 'marking-definition', id: markingDefinitionId, + created_by_ref: mitreIdentityId, created: '2017-06-01T00:00:00.000Z', definition_type: 'statement', definition: { @@ -464,6 +504,10 @@ describe('STIX Bundles New Specification API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the imported bundle fixture is ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -477,7 +521,7 @@ describe('STIX Bundles New Specification API', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -493,7 +537,7 @@ describe('STIX Bundles New Specification API', function () { request(app) .get('/api/stix-bundles?domain=not-a-domain') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .end(function (err, res) { if (err) { @@ -515,7 +559,7 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -555,7 +599,7 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -565,7 +609,7 @@ describe('STIX Bundles New Specification API', function () { // The new spec maintains a clean separation: deprecated patterns are excluded // even if both endpoints exist in the bundle as primary objects const deprecatedRelationship = stixBundle.objects.find( - (o) => o.id === 'relationship--new-dc-detects-tech-dep', + (o) => o.id === 'relationship--ffffffff-ffff-4fff-8fff-ffffffffffff', ); expect(deprecatedRelationship).toBeUndefined(); @@ -573,7 +617,7 @@ describe('STIX Bundles New Specification API', function () { const validDetectsRels = stixBundle.objects.filter( (o) => o.type === 'relationship' && o.relationship_type === 'detects', ); - expect(validDetectsRels.length).toBe(2); // Only DS-001 detects relationships + expect(validDetectsRels.length).toBe(2); // Only DET0001 detects relationships validDetectsRels.forEach((rel) => { expect(rel.source_ref).toMatch(/^x-mitre-detection-strategy--/); }); @@ -585,14 +629,16 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); const stixBundle = res.body; - // Verify DS-001 is included because it has 'detects' relationships to in-scope techniques - const ds001 = stixBundle.objects.find((o) => o.id === 'x-mitre-detection-strategy--new-ds-001'); + // Verify DET0001 is included because it has 'detects' relationships to in-scope techniques + const ds001 = stixBundle.objects.find( + (o) => o.id === 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', + ); expect(ds001).toBeDefined(); expect(ds001.name).toBe('Detection Strategy 1 - Detects Technique via Relationship'); expect(ds001.x_mitre_domains).toEqual([enterpriseDomain]); @@ -602,7 +648,7 @@ describe('STIX Bundles New Specification API', function () { (o) => o.type === 'relationship' && o.relationship_type === 'detects' && - o.source_ref === 'x-mitre-detection-strategy--new-ds-001', + o.source_ref === 'x-mitre-detection-strategy--66666666-6666-4666-8666-666666666666', ); expect(ds001DetectsRels.length).toBe(2); // Detects two techniques }); @@ -613,21 +659,27 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); const stixBundle = res.body; - // Verify DS-002 is included because it references an in-scope analytic via x_mitre_analytic_refs - const ds002 = stixBundle.objects.find((o) => o.id === 'x-mitre-detection-strategy--new-ds-002'); + // Verify DET0002 is included because it references an in-scope analytic via x_mitre_analytic_refs + const ds002 = stixBundle.objects.find( + (o) => o.id === 'x-mitre-detection-strategy--77777777-7777-4777-8777-777777777777', + ); expect(ds002).toBeDefined(); expect(ds002.name).toBe('Detection Strategy 2 - References Analytic'); - expect(ds002.x_mitre_analytic_refs).toContain('x-mitre-analytic--new-ana-001'); + expect(ds002.x_mitre_analytic_refs).toContain( + 'x-mitre-analytic--44444444-4444-4444-8444-444444444444', + ); expect(ds002.x_mitre_domains).toEqual([enterpriseDomain]); // Verify the referenced analytic is in the bundle - const analytic = stixBundle.objects.find((o) => o.id === 'x-mitre-analytic--new-ana-001'); + const analytic = stixBundle.objects.find( + (o) => o.id === 'x-mitre-analytic--44444444-4444-4444-8444-444444444444', + ); expect(analytic).toBeDefined(); }); @@ -637,14 +689,16 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); const stixBundle = res.body; - // Verify DS-003 is NOT included (orphaned - no technique or analytic reference) - const ds003 = stixBundle.objects.find((o) => o.id === 'x-mitre-detection-strategy--new-ds-003'); + // Verify DET0003 is NOT included (orphaned - no technique or analytic reference) + const ds003 = stixBundle.objects.find( + (o) => o.id === 'x-mitre-detection-strategy--88888888-8888-4888-8888-888888888888', + ); expect(ds003).toBeUndefined(); }); @@ -655,7 +709,7 @@ describe('STIX Bundles New Specification API', function () { .query({ includeDataSources: true }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -663,7 +717,7 @@ describe('STIX Bundles New Specification API', function () { const dataSources = stixBundle.objects.filter((o) => o.type === 'x-mitre-data-source'); expect(dataSources.length).toBe(1); // Only new-ds-src-001 (enterprise) - expect(dataSources[0].id).toBe('x-mitre-data-source--new-ds-src-001'); + expect(dataSources[0].id).toBe('x-mitre-data-source--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); }); it('GET /api/stix-bundles without includeDataSources excludes data sources', async function () { @@ -672,7 +726,7 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: enterpriseDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -688,7 +742,7 @@ describe('STIX Bundles New Specification API', function () { .query({ domain: icsDomain }) .query({ stixVersion: '2.1' }) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -706,7 +760,7 @@ describe('STIX Bundles New Specification API', function () { // Only 1 technique should be in ICS (new-ics-001) const techniques = stixBundle.objects.filter((o) => o.type === 'attack-pattern'); expect(techniques.length).toBe(1); - expect(techniques[0].id).toBe('attack-pattern--new-ics-001'); + expect(techniques[0].id).toBe('attack-pattern--33333333-3333-4333-8333-333333333333'); // No analytics in ICS domain const analytics = stixBundle.objects.filter((o) => o.type === 'x-mitre-analytic'); @@ -715,7 +769,9 @@ describe('STIX Bundles New Specification API', function () { // Only ICS data component (new-dc-002) const dataComponents = stixBundle.objects.filter((o) => o.type === 'x-mitre-data-component'); expect(dataComponents.length).toBe(1); - expect(dataComponents[0].id).toBe('x-mitre-data-component--new-dc-002'); + expect(dataComponents[0].id).toBe( + 'x-mitre-data-component--aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ); // No detection strategies (none detect ICS techniques or reference ICS analytics) const detectionStrategies = stixBundle.objects.filter( diff --git a/app/tests/api/system-configuration/create-object-identity.spec.js b/app/tests/api/system-configuration/create-object-identity.spec.js index 0bdbc2b3..3942d785 100644 --- a/app/tests/api/system-configuration/create-object-identity.spec.js +++ b/app/tests/api/system-configuration/create-object-identity.spec.js @@ -1,11 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const uuid = require('uuid'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); +const config = require('../../../config/config'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -21,7 +22,7 @@ const initialTacticData = { spec_version: '2.1', type: 'x-mitre-tactic', description: 'This is a tactic. yellow.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'TA9001' }], object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], }, }; @@ -54,6 +55,10 @@ describe('Create Object with Organization Identity API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -66,7 +71,7 @@ describe('Create Object with Organization Identity API', function () { const res = await request(app) .get('/api/config/organization-identity') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -86,7 +91,7 @@ describe('Create Object with Organization Identity API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -113,7 +118,7 @@ describe('Create Object with Organization Identity API', function () { .post('/api/identities') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -135,15 +140,12 @@ describe('Create Object with Organization Identity API', function () { .post('/api/config/organization-identity') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('POST /api/tactics creates a new version of the tactic', async function () { - const tactic2 = _.cloneDeep(tactic1); - tactic2._id = undefined; - tactic2.__t = undefined; - tactic2.__v = undefined; + const tactic2 = cloneForCreate(tactic1); const timestamp = new Date().toISOString(); tactic2.stix.modified = timestamp; const body = tactic2; @@ -151,7 +153,7 @@ describe('Create Object with Organization Identity API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); diff --git a/app/tests/api/system-configuration/system-configuration.spec.js b/app/tests/api/system-configuration/system-configuration.spec.js index 17279e35..99be4fdf 100644 --- a/app/tests/api/system-configuration/system-configuration.spec.js +++ b/app/tests/api/system-configuration/system-configuration.spec.js @@ -4,7 +4,7 @@ const { expect } = require('expect'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); - +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -37,6 +37,10 @@ describe('System Configuration API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -48,7 +52,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/system-version') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -63,7 +67,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/allowed-values') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -92,14 +96,30 @@ describe('System Configuration API', function () { (item) => item.domainName === expectedDomainName, ); expect(domainAllowedValues).toBeDefined(); - expect(domainAllowedValues.allowedValues).toContain(expectedPropertyValue); + expect(domainAllowedValues.allowedValues).toEqual( + expect.arrayContaining([expectedPropertyValue, 'Office Suite', 'Identity Provider']), + ); + expect(domainAllowedValues.allowedValues).not.toContain('Google Workspace'); + expect(domainAllowedValues.allowedValues).not.toContain('Azure AD'); + + const configuredPlatforms = [ + ...new Set( + allowedValues.flatMap((item) => + item.properties + .filter((property) => property.propertyName === expectedPropertyName) + .flatMap((property) => property.domains.flatMap((domain) => domain.allowedValues)), + ), + ), + ].sort(); + expect(configuredPlatforms).not.toContain('Google Workspace'); + expect(configuredPlatforms).not.toContain('Azure AD'); }); it('GET /api/config/organization-identity returns the organizaton identity', async function () { const res = await request(app) .get('/api/config/organization-identity') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -112,7 +132,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/authn') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -128,7 +148,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -149,7 +169,7 @@ describe('System Configuration API', function () { .put('/api/marking-definitions/' + amberTlpMarkingDefinition.stix.id) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -157,7 +177,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/default-marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -177,7 +197,7 @@ describe('System Configuration API', function () { .post('/api/marking-definitions') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -192,7 +212,7 @@ describe('System Configuration API', function () { .post('/api/config/default-marking-definitions') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); // We expect the response body to be an empty object @@ -204,7 +224,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/default-marking-definitions') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -220,7 +240,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/default-marking-definitions?refOnly=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -236,7 +256,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/organization-namespace') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -254,7 +274,7 @@ describe('System Configuration API', function () { .post('/api/config/organization-namespace') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); // We expect the response body to be an empty object @@ -266,7 +286,7 @@ describe('System Configuration API', function () { const res = await request(app) .get('/api/config/organization-namespace') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/tactics/tactics.spec.js b/app/tests/api/tactics/tactics.spec.js index 7097e94a..217f7844 100644 --- a/app/tests/api/tactics/tactics.spec.js +++ b/app/tests/api/tactics/tactics.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -24,7 +24,7 @@ const initialObjectData = { spec_version: '2.1', type: 'x-mitre-tactic', description: 'This is a tactic. yellow.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + external_references: [{ source_name: 'mitre-attack', external_id: 'TA9001' }], object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], }, }; @@ -41,6 +41,10 @@ describe('Tactics API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -52,7 +56,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -69,7 +73,7 @@ describe('Tactics API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -83,7 +87,7 @@ describe('Tactics API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -104,7 +108,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -119,7 +123,7 @@ describe('Tactics API', function () { await request(app) .get('/api/tactics/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -127,7 +131,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics/' + tactic1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -165,7 +169,7 @@ describe('Tactics API', function () { .put('/api/tactics/' + tactic1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -177,22 +181,21 @@ describe('Tactics API', function () { }); it('POST /api/tactics does not create a tactic with the same id and modified date', async function () { - const body = tactic1; - // We expect to get the created tactic + // Clone the tactic to remove backend-controlled fields, but keep the same modified date + const body = cloneForCreate(tactic1); + body.stix.modified = tactic1.stix.modified; // Keep the same modified date to trigger duplicate check + // We expect to get a 409 Conflict error await request(app) .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let tactic2; it('POST /api/tactics should create a new version of a tactic with a duplicate stix.id but different stix.modified date', async function () { - tactic2 = _.cloneDeep(tactic1); - tactic2._id = undefined; - tactic2.__t = undefined; - tactic2.__v = undefined; + tactic2 = cloneForCreate(tactic1); const timestamp = new Date().toISOString(); tactic2.stix.description = 'Still a tactic. Red.'; tactic2.stix.modified = timestamp; @@ -201,7 +204,7 @@ describe('Tactics API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -212,10 +215,7 @@ describe('Tactics API', function () { let tactic3; it('POST /api/tactics should create a new version of a tactic with a duplicate stix.id but different stix.modified date', async function () { - tactic3 = _.cloneDeep(tactic1); - tactic3._id = undefined; - tactic3.__t = undefined; - tactic3.__v = undefined; + tactic3 = cloneForCreate(tactic1); const timestamp = new Date().toISOString(); tactic3.stix.description = 'Still a tactic. Violet.'; tactic3.stix.modified = timestamp; @@ -224,7 +224,7 @@ describe('Tactics API', function () { .post('/api/tactics') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -237,7 +237,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics/' + tactic3.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -255,7 +255,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics/' + tactic1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -270,7 +270,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics/' + tactic1.stix.id + '/modified/' + tactic1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -286,7 +286,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics/' + tactic2.stix.id + '/modified/' + tactic2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -302,7 +302,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics?search=violet') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -324,7 +324,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics?search=yellow') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -338,21 +338,21 @@ describe('Tactics API', function () { it('DELETE /api/tactics/:id should not delete a tactic when the id cannot be found', async function () { await request(app) .delete('/api/tactics/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/tactics/:id/modified/:modified deletes a tactic', async function () { await request(app) .delete('/api/tactics/' + tactic1.stix.id + '/modified/' + tactic1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/tactics/:id should delete all the tactics with the same stix id', async function () { await request(app) .delete('/api/tactics/' + tactic2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -360,7 +360,7 @@ describe('Tactics API', function () { const res = await request(app) .get('/api/tactics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/tactics/tactics.techniques.json b/app/tests/api/tactics/tactics.techniques.json index e93207cd..5c158855 100644 --- a/app/tests/api/tactics/tactics.techniques.json +++ b/app/tests/api/tactics/tactics.techniques.json @@ -84,21 +84,21 @@ "kill_chain_phases": [ { "kill_chain_name": "mitre-attack", - "phase_name": "enlil" + "phase_name": "execution" }, { "kill_chain_name": "mitre-attack", - "phase_name": "nabu" + "phase_name": "persistence" } ], "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0001", - "external_id": "TX0001" + "url": "https://attack.mitre.org/techniques/T9001", + "external_id": "T9001" } ], - "x_mitre_data_sources": [], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -118,17 +118,17 @@ "kill_chain_phases": [ { "kill_chain_name": "mitre-attack", - "phase_name": "nabu" + "phase_name": "persistence" } ], "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0002", - "external_id": "TX0002" + "url": "https://attack.mitre.org/techniques/T9002", + "external_id": "T9002" } ], - "x_mitre_data_sources": [], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -148,17 +148,17 @@ "kill_chain_phases": [ { "kill_chain_name": "mitre-attack", - "phase_name": "enki" + "phase_name": "defense-evasion" } ], "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0003", - "external_id": "TX0003" + "url": "https://attack.mitre.org/techniques/T9003", + "external_id": "T9003" } ], - "x_mitre_data_sources": [], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -178,21 +178,21 @@ "kill_chain_phases": [ { "kill_chain_name": "mitre-attack", - "phase_name": "enlil" + "phase_name": "execution" }, { "kill_chain_name": "mitre-attack", - "phase_name": "enki" + "phase_name": "defense-evasion" } ], "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0004", - "external_id": "TX0004" + "url": "https://attack.mitre.org/techniques/T9004", + "external_id": "T9004" } ], - "x_mitre_data_sources": [], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -212,21 +212,21 @@ "kill_chain_phases": [ { "kill_chain_name": "mitre-attack", - "phase_name": "nanna-suen" + "phase_name": "collection" }, { "kill_chain_name": "mitre-attack", - "phase_name": "nabu" + "phase_name": "persistence" } ], "external_references": [ { "source_name": "mitre-attack", - "url": "https://attack.mitre.org/techniques/TX0005", - "external_id": "TX0005" + "url": "https://attack.mitre.org/techniques/T9005", + "external_id": "T9005" } ], - "x_mitre_data_sources": [], + "x_mitre_is_subtechnique": false, "x_mitre_version": "1.0", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", @@ -241,15 +241,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0001", - "url": "https://attack.mitre.org/tactics/TAX0001" + "external_id": "TA9001", + "url": "https://attack.mitre.org/tactics/TA9001" } ], "id": "x-mitre-tactic--d932e995-5207-4347-88ec-b52b32762357", "modified": "2022-04-01T06:07:08.000Z", "name": "Enlil", "type": "x-mitre-tactic", - "x_mitre_shortname": "enlil", + "x_mitre_shortname": "execution", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -264,15 +264,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0002", - "url": "https://attack.mitre.org/tactics/TAX0002" + "external_id": "TA9002", + "url": "https://attack.mitre.org/tactics/TA9002" } ], "id": "x-mitre-tactic--953fd636-2af2-4cad-adc8-5d7903295dba", "modified": "2022-04-01T06:07:08.000Z", "name": "Enki", "type": "x-mitre-tactic", - "x_mitre_shortname": "enki", + "x_mitre_shortname": "defense-evasion", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -287,15 +287,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0003", - "url": "https://attack.mitre.org/tactics/TAX0003" + "external_id": "TA9003", + "url": "https://attack.mitre.org/tactics/TA9003" } ], "id": "x-mitre-tactic--c1768fcd-abe2-462f-95cc-bfedbc8c64c6", "modified": "2022-03-01T06:07:08.000Z", "name": "Ianna", "type": "x-mitre-tactic", - "x_mitre_shortname": "inanna", + "x_mitre_shortname": "discovery", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -310,15 +310,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0004", - "url": "https://attack.mitre.org/tactics/TAX0004" + "external_id": "TA9004", + "url": "https://attack.mitre.org/tactics/TA9004" } ], "id": "x-mitre-tactic--60cf8617-223d-47db-b15e-0cdf3c1d6f52", "modified": "2022-02-01T06:07:08.000Z", "name": "Nabu", "type": "x-mitre-tactic", - "x_mitre_shortname": "nabu", + "x_mitre_shortname": "persistence", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -333,15 +333,15 @@ "external_references": [ { "source_name": "mitre-attack", - "external_id": "TAX0005", - "url": "https://attack.mitre.org/tactics/TAX0005" + "external_id": "TA9005", + "url": "https://attack.mitre.org/tactics/TA9005" } ], "id": "x-mitre-tactic--0b518521-aae3-4169-9f7a-cdf8455a2d14", "modified": "2022-01-01T06:07:08.000Z", "name": "Nanna-Suen", "type": "x-mitre-tactic", - "x_mitre_shortname": "nanna-suen", + "x_mitre_shortname": "collection", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -356,15 +356,15 @@ "external_references": [ { "source_name": "mitre-mobile-attack", - "external_id": "TAX0006", - "url": "https://attack.mitre.org/tactics/TAX0006" + "external_id": "TA9006", + "url": "https://attack.mitre.org/tactics/TA9006" } ], "id": "x-mitre-tactic--f5a8c28b-3002-4d5b-9571-3f68b2b57e29", "modified": "2022-09-09T01:02:03.000Z", "name": "Nabu", "type": "x-mitre-tactic", - "x_mitre_shortname": "nabu", + "x_mitre_shortname": "persistence", "x_mitre_attack_spec_version": "2.1.0", "x_mitre_modified_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "x_mitre_version": "1.0", @@ -390,8 +390,7 @@ "created": "2020-01-01T00:00:00.000Z", "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5", "definition_type": "statement", - "spec_version": "2.1", - "x_mitre_domains": ["ics-attack"] + "spec_version": "2.1" } ] } diff --git a/app/tests/api/tactics/tactics.techniques.spec.js b/app/tests/api/tactics/tactics.techniques.spec.js index 2fc21bfb..e13bc0a4 100644 --- a/app/tests/api/tactics/tactics.techniques.spec.js +++ b/app/tests/api/tactics/tactics.techniques.spec.js @@ -4,14 +4,14 @@ const request = require('supertest'); const { expect } = require('expect'); const login = require('../../shared/login'); - +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const collectionBundlesService = require('../../../services/collection-bundles-service'); +const collectionBundlesService = require('../../../services/stix/collection-bundles-service'); async function readJson(path) { const data = await fs.readFile(require.resolve(path)); @@ -30,6 +30,10 @@ describe('Tactics with Techniques API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the imported bundle fixture is ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -51,7 +55,7 @@ describe('Tactics with Techniques API', function () { const res = await request(app) .get('/api/tactics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -60,15 +64,19 @@ describe('Tactics with Techniques API', function () { expect(Array.isArray(tactics)).toBe(true); expect(tactics.length).toBe(6); - tactic1 = tactics.find((t) => t.stix.x_mitre_shortname === 'enlil'); - tactic2 = tactics.find((t) => t.stix.x_mitre_shortname === 'nabu'); + tactic1 = tactics.find( + (t) => t.stix.id === 'x-mitre-tactic--d932e995-5207-4347-88ec-b52b32762357', + ); + tactic2 = tactics.find( + (t) => t.stix.id === 'x-mitre-tactic--60cf8617-223d-47db-b15e-0cdf3c1d6f52', + ); }); it('GET /api/techniques should return the preloaded techniques', async function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -82,7 +90,7 @@ describe('Tactics with Techniques API', function () { await request(app) .get(`/api/tactics/not-an-id/modified/2022-01-01T00:00:00.000Z/techniques`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -90,7 +98,7 @@ describe('Tactics with Techniques API', function () { const res = await request(app) .get(`/api/tactics/${tactic1.stix.id}/modified/${tactic1.stix.modified}/techniques`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -106,7 +114,7 @@ describe('Tactics with Techniques API', function () { `/api/tactics/${tactic2.stix.id}/modified/${tactic2.stix.modified}/techniques?offset=0&limit=2&includePagination=true`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/teams/teams-invalid.spec.js b/app/tests/api/teams/teams-invalid.spec.js index ae1c6e13..57e02108 100644 --- a/app/tests/api/teams/teams-invalid.spec.js +++ b/app/tests/api/teams/teams-invalid.spec.js @@ -1,15 +1,23 @@ const request = require('supertest'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const teams = require('./teams.invalid.json'); - const login = require('../../shared/login'); +// Invalid team payloads — each is missing a required field. Used to assert the +// API rejects malformed input with a 400. +const teams = [ + { + description: 'no name', + userIDs: [], + }, +]; + describe('Teams API Test Invalid Data', function () { let app; let passportCookie; @@ -22,6 +30,10 @@ describe('Teams API Test Invalid Data', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; this non-STIX payload spec should not inherit a disabled flag + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -36,7 +48,7 @@ describe('Teams API Test Invalid Data', function () { .post('/api/teams') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); } diff --git a/app/tests/api/teams/teams.invalid.json b/app/tests/api/teams/teams.invalid.json deleted file mode 100644 index 330ffc3f..00000000 --- a/app/tests/api/teams/teams.invalid.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "description": "no name", - "userIDs": [] - } -] diff --git a/app/tests/api/teams/teams.spec.js b/app/tests/api/teams/teams.spec.js index 3a60c8f0..a56f9c05 100644 --- a/app/tests/api/teams/teams.spec.js +++ b/app/tests/api/teams/teams.spec.js @@ -1,6 +1,7 @@ const request = require('supertest'); const { expect } = require('expect'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -50,6 +51,10 @@ describe('Teams API', function () { const user1 = new UserAccount(exampleUser); await user1.save(); + // Enable ADM validation; this non-STIX payload spec should not inherit a disabled flag + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -63,7 +68,7 @@ describe('Teams API', function () { .post('/api/teams') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -74,7 +79,7 @@ describe('Teams API', function () { .post('/api/teams') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -88,7 +93,7 @@ describe('Teams API', function () { const res = await request(app) .get('/api/teams') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -103,7 +108,7 @@ describe('Teams API', function () { await request(app) .get('/api/teams/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -111,7 +116,7 @@ describe('Teams API', function () { const res = await request(app) .get('/api/teams/' + team1.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -137,7 +142,7 @@ describe('Teams API', function () { .put('/api/teams/' + team1.id) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -156,7 +161,7 @@ describe('Teams API', function () { const res = await request(app) .get('/api/teams?search=team') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -173,7 +178,7 @@ describe('Teams API', function () { .post('/api/teams') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); @@ -183,7 +188,7 @@ describe('Teams API', function () { .get(`/api/teams/${team1.id}/users`) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -198,7 +203,7 @@ describe('Teams API', function () { it('DELETE /api/teams deletes a teams', async function () { await request(app) .delete('/api/teams/' + team1.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); diff --git a/app/tests/api/teams/teams.valid.json b/app/tests/api/teams/teams.valid.json deleted file mode 100644 index 8694fdeb..00000000 --- a/app/tests/api/teams/teams.valid.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "name": "team1", - "description": "exampleTeam", - "userIDs": [] - } -] diff --git a/app/tests/api/techniques/techniques-pagination.spec.js b/app/tests/api/techniques/techniques-pagination.spec.js index 404aa76e..19fea222 100644 --- a/app/tests/api/techniques/techniques-pagination.spec.js +++ b/app/tests/api/techniques/techniques-pagination.spec.js @@ -1,4 +1,4 @@ -const techniquesService = require('../../../services/techniques-service'); +const techniquesService = require('../../../services/stix/techniques-service'); const PaginationTests = require('../../shared/pagination'); // modified and created properties will be set before calling REST API @@ -13,15 +13,13 @@ const initialObjectData = { spec_version: '2.1', type: 'attack-pattern', description: 'This is a technique.', - external_references: [{ source_name: 'source-1', external_id: 's1' }], + // Removed external_references - backend generates ATT&CK IDs and external references 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: 'kill-chain-name-1', phase_name: 'phase-1' }], - x_mitre_data_sources: ['data-source-1', 'data-source-2'], + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'execution' }], x_mitre_detection: 'detection text', x_mitre_is_subtechnique: false, - x_mitre_impact_type: ['impact-1'], - x_mitre_platforms: ['platform-1', 'platform-2'], + x_mitre_platforms: ['Linux', 'macOS'], }, }; @@ -29,6 +27,9 @@ const options = { prefix: 'attack-pattern', baseUrl: '/api/techniques', label: 'Techniques', + // 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, }; const paginationTests = new PaginationTests(techniquesService, initialObjectData, options); paginationTests.executeTests(); diff --git a/app/tests/api/techniques/techniques.convert.spec.js b/app/tests/api/techniques/techniques.convert.spec.js new file mode 100644 index 00000000..f900b696 --- /dev/null +++ b/app/tests/api/techniques/techniques.convert.spec.js @@ -0,0 +1,558 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); + +const config = require('../../../config/config'); +const login = require('../../shared/login'); + +const logger = require('../../../lib/logger'); +logger.level = 'debug'; + +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'], + }, +}; + +describe('Techniques Convert API', function () { + let app; + let passportCookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = false; + + app = await require('../../../index').initializeApp(); + passportCookie = await login.loginAnonymous(app); + }); + + // ============================================= + // Convert technique → subtechnique + // ============================================= + describe('POST /api/techniques/:stixId/convert-to-subtechnique', function () { + let parentTechnique; + let technique; + + it('creates a parent technique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'parent-technique', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + parentTechnique = res.body; + expect(parentTechnique.workspace.attack_id).toBeDefined(); + }); + + it('creates a technique to convert', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'technique-to-convert', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + technique = res.body; + expect(technique.stix.x_mitre_is_subtechnique).toBe(false); + }); + + it('converts the technique to a subtechnique', async function () { + const res = await request(app) + .post(`/api/techniques/${technique.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: parentTechnique.workspace.attack_id }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const body = res.body; + + // WorkflowResult envelope + expect(body.workflow).toBe('convert-to-subtechnique'); + expect(body.primary).toBeDefined(); + expect(body.sideEffects).toBeDefined(); + + const converted = body.primary; + + // Same STIX ID, new version + expect(converted.stix.id).toBe(technique.stix.id); + expect(converted.stix.modified).not.toBe(technique.stix.modified); + + // Subtechnique fields + expect(converted.stix.x_mitre_is_subtechnique).toBe(true); + expect(converted.workspace.attack_id).toMatch( + new RegExp(`^${parentTechnique.workspace.attack_id}\\.\\d{3}$`), + ); + + // External reference updated + const attackRef = converted.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef).toBeDefined(); + expect(attackRef.external_id).toBe(converted.workspace.attack_id); + expect(attackRef.url).toContain('/techniques/'); + + // Side effect: subtechnique-of relationship was created + expect(body.sideEffects.created.length).toBe(1); + const createdRel = body.sideEffects.created[0]; + expect(createdRel.stix.relationship_type).toBe('subtechnique-of'); + expect(createdRel.stix.source_ref).toBe(technique.stix.id); + expect(createdRel.stix.target_ref).toBe(parentTechnique.stix.id); + }); + + it('returns 400 when technique is already a subtechnique', async function () { + await request(app) + .post(`/api/techniques/${technique.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: parentTechnique.workspace.attack_id }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('returns 400 when parentTechniqueAttackId is missing', async function () { + await request(app) + .post(`/api/techniques/${technique.stix.id}/convert-to-subtechnique`) + .send({}) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('returns 400 when parentTechniqueAttackId has invalid format', async function () { + await request(app) + .post(`/api/techniques/${technique.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: 'INVALID' }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('returns 400 when parentTechniqueAttackId does not exist', async function () { + // T9999 has valid format but no technique with this ATT&CK ID exists + await request(app) + .post(`/api/techniques/${technique.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: 'T9999' }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('returns 404 for non-existent stixId', async function () { + await request(app) + .post('/api/techniques/attack-pattern--does-not-exist/convert-to-subtechnique') + .send({ parentTechniqueAttackId: parentTechnique.workspace.attack_id }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); + }); + }); + + // ============================================= + // Convert subtechnique → technique + // ============================================= + describe('POST /api/techniques/:stixId/convert-to-technique', function () { + let parentTechnique; + let subtechnique; + + it('creates a parent technique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'parent-for-sub', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + parentTechnique = res.body; + }); + + it('creates a subtechnique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'subtechnique-to-convert', + x_mitre_is_subtechnique: true, + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post(`/api/techniques?parentTechniqueId=${parentTechnique.workspace.attack_id}`) + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + subtechnique = res.body; + expect(subtechnique.stix.x_mitre_is_subtechnique).toBe(true); + expect(subtechnique.workspace.attack_id).toContain('.'); + }); + + it('creates a subtechnique-of relationship', async function () { + const timestamp = new Date().toISOString(); + const relBody = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + spec_version: '2.1', + type: 'relationship', + relationship_type: 'subtechnique-of', + source_ref: subtechnique.stix.id, + target_ref: parentTechnique.stix.id, + created: timestamp, + modified: timestamp, + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + }, + }; + const res = await request(app) + .post('/api/relationships') + .send(relBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + expect(res.body.stix.relationship_type).toBe('subtechnique-of'); + }); + + it('converts the subtechnique to a technique', async function () { + const res = await request(app) + .post(`/api/techniques/${subtechnique.stix.id}/convert-to-technique`) + .send({}) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const body = res.body; + + // WorkflowResult envelope + expect(body.workflow).toBe('convert-to-technique'); + expect(body.primary).toBeDefined(); + expect(body.sideEffects).toBeDefined(); + + const converted = body.primary; + + // Same STIX ID, new version + expect(converted.stix.id).toBe(subtechnique.stix.id); + expect(converted.stix.modified).not.toBe(subtechnique.stix.modified); + + // Technique fields + expect(converted.stix.x_mitre_is_subtechnique).toBe(false); + expect(converted.workspace.attack_id).toMatch(/^T\d{4}$/); + expect(converted.workspace.attack_id).not.toContain('.'); + + // External reference updated + const attackRef = converted.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef).toBeDefined(); + expect(attackRef.external_id).toBe(converted.workspace.attack_id); + expect(attackRef.url).toMatch(/\/techniques\/T\d{4}$/); + + // Side effect: subtechnique-of relationship was deprecated + expect(body.sideEffects.deprecated.length).toBeGreaterThan(0); + const deprecatedRel = body.sideEffects.deprecated[0]; + expect(deprecatedRel.stix.relationship_type).toBe('subtechnique-of'); + expect(deprecatedRel.stix.x_mitre_deprecated).toBe(true); + }); + + it('returns 400 when technique is not a subtechnique', async function () { + // The subtechnique was just converted to a technique, so trying again should fail + await request(app) + .post(`/api/techniques/${subtechnique.stix.id}/convert-to-technique`) + .send({}) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('returns 404 for non-existent stixId', async function () { + await request(app) + .post('/api/techniques/attack-pattern--does-not-exist/convert-to-technique') + .send({}) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); + }); + }); + + // ============================================= + // Block conversion when technique has child subtechniques + // ============================================= + describe('Block convert-to-subtechnique when technique has children', function () { + let parentTechnique; + let childSubtechnique; + let wouldBeParent; + + it('creates a parent technique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'parent-with-children', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + parentTechnique = res.body; + }); + + it('creates a would-be parent technique for the conversion attempt', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'would-be-parent', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + wouldBeParent = res.body; + }); + + it('creates a child subtechnique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'child-subtechnique', + x_mitre_is_subtechnique: true, + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post(`/api/techniques?parentTechniqueId=${parentTechnique.workspace.attack_id}`) + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + childSubtechnique = res.body; + }); + + it('creates a subtechnique-of relationship from child to parent', async function () { + const timestamp = new Date().toISOString(); + const relBody = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + spec_version: '2.1', + type: 'relationship', + relationship_type: 'subtechnique-of', + source_ref: childSubtechnique.stix.id, + target_ref: parentTechnique.stix.id, + created: timestamp, + modified: timestamp, + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + }, + }; + await request(app) + .post('/api/relationships') + .send(relBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + }); + + it('returns 400 when trying to convert a parent technique that has subtechniques', async function () { + const res = await request(app) + .post(`/api/techniques/${parentTechnique.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: wouldBeParent.workspace.attack_id }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + + expect(res.body.details).toContain('subtechnique'); + expect(res.body.details).toContain('Rehome'); + }); + }); + + // ============================================= + // x_mitre_is_subtechnique is preserved on update + // ============================================= + describe('PUT preserves x_mitre_is_subtechnique', function () { + let technique; + + it('creates a technique', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'immutable-subtech-field', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + technique = res.body; + expect(technique.stix.x_mitre_is_subtechnique).toBe(false); + }); + + it('update ignores attempt to change x_mitre_is_subtechnique', async function () { + const updateBody = { + ...technique, + stix: { + ...technique.stix, + x_mitre_is_subtechnique: true, // attempt to change + description: 'Updated description', + }, + }; + + const res = await request(app) + .put(`/api/techniques/${technique.stix.id}/modified/${technique.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + // The field should remain false + expect(res.body.stix.x_mitre_is_subtechnique).toBe(false); + // But the description should have been updated + expect(res.body.stix.description).toBe('Updated description'); + }); + }); + + // ============================================= + // Revoked technique cannot be converted + // ============================================= + describe('Revoked techniques cannot be converted', function () { + let techniqueA; + let techniqueB; + + it('creates two techniques', async function () { + const timestamp1 = new Date().toISOString(); + const body1 = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'revoked-convert-A', + created: timestamp1, + modified: timestamp1, + }, + }; + const res1 = await request(app) + .post('/api/techniques') + .send(body1) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + techniqueA = res1.body; + + const timestamp2 = new Date().toISOString(); + const body2 = { + ...baseTechniqueData, + stix: { + ...baseTechniqueData.stix, + name: 'revoked-convert-B', + created: timestamp2, + modified: timestamp2, + }, + }; + const res2 = await request(app) + .post('/api/techniques') + .send(body2) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + techniqueB = res2.body; + }); + + it('revokes technique A', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { stixId: techniqueB.stix.id, modified: techniqueB.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + }); + + it('returns 400 when trying to convert a revoked technique to subtechnique', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/convert-to-subtechnique`) + .send({ parentTechniqueAttackId: techniqueB.workspace.attack_id }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/app/tests/api/techniques/techniques.query.json b/app/tests/api/techniques/techniques.query.json deleted file mode 100644 index fb6d51f2..00000000 --- a/app/tests/api/techniques/techniques.query.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "workspace": { - "workflow": {} - }, - "stix": { - "spec_version": "2.1", - "type": "attack-pattern", - "description": "This is a technique.", - "external_references": [{ "source_name": "source-1", "external_id": "s1" }], - "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": "kill-chain-name-1", "phase_name": "phase-1" }], - "x_mitre_data_sources": ["data-source-1", "data-source-2"], - "x_mitre_detection": "detection text", - "x_mitre_is_subtechnique": false, - "x_mitre_impact_type": ["impact-1"], - "x_mitre_platforms": ["platform-1", "platform-2"] - } -} diff --git a/app/tests/api/techniques/techniques.query.spec.js b/app/tests/api/techniques/techniques.query.spec.js index 340af8be..ad0a02d3 100644 --- a/app/tests/api/techniques/techniques.query.spec.js +++ b/app/tests/api/techniques/techniques.query.spec.js @@ -1,10 +1,9 @@ -const fs = require('fs').promises; - const request = require('supertest'); const { expect } = require('expect'); const _ = require('lodash'); const uuid = require('uuid'); +const config = require('../../../config/config'); const login = require('../../shared/login'); const logger = require('../../../lib/logger'); @@ -13,71 +12,106 @@ logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const techniquesService = require('../../../services/techniques-service'); +const techniquesService = require('../../../services/stix/techniques-service'); + +// Base technique used to derive all of the seeded query fixtures. Each created +// technique deep-clones this and overrides only the fields a given test cares +// about (deprecated/revoked status, workflow state, domain, platform). +const baseTechnique = { + workspace: { + workflow: {}, + }, + stix: { + spec_version: '2.1', + type: 'attack-pattern', + description: 'This is a technique.', + external_references: [{ source_name: 'source-1', external_id: 's1' }], + 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_detection: 'detection text', + x_mitre_is_subtechnique: false, + x_mitre_version: '1.0', + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['Linux', 'macOS'], + }, +}; function asyncWait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function readJson(path) { - const data = await fs.readFile(require.resolve(path)); - return JSON.parse(data); -} +async function configureAndLoadTechniques(baseTechnique) { + // Helper: create a technique from config + async function createTechnique(overrides) { + const data = _.cloneDeep(baseTechnique); + Object.assign(data.stix, overrides.stix || {}); + if (overrides.workspace) { + data.workspace = { ...data.workspace, ...overrides.workspace }; + } -async function configureTechniques(baseTechnique) { - const techniques = []; - // x_mitre_deprecated,revoked undefined - // state undefined - const data1 = _.cloneDeep(baseTechnique); - techniques.push(data1); - - // x_mitre_deprecated = false, revoked = false - // state = work-in-progress - const data2 = _.cloneDeep(baseTechnique); - data2.stix.x_mitre_deprecated = false; - data2.stix.revoked = false; - data2.stix.x_mitre_domains = ['mobile-attack']; - data2.stix.x_mitre_platforms.push('platform-3'); - data2.workspace.workflow = { state: 'work-in-progress' }; - techniques.push(data2); - - // x_mitre_deprecated = true, revoked = false - // state = awaiting-review - const data3 = _.cloneDeep(baseTechnique); - data3.stix.x_mitre_deprecated = true; - data3.stix.revoked = false; - data3.workspace.workflow = { state: 'awaiting-review' }; - techniques.push(data3); - - // x_mitre_deprecated = false, revoked = true - // state = awaiting-review - const data4 = _.cloneDeep(baseTechnique); - data4.stix.x_mitre_deprecated = false; - data4.stix.revoked = true; - data4.workspace.workflow = { state: 'awaiting-review' }; - techniques.push(data4); - - // multiple versions, last version has x_mitre_deprecated = true, revoked = true - // state = awaiting-review - const data5a = _.cloneDeep(baseTechnique); + if (!data.stix.name) { + data.stix.name = `attack-pattern-${data.stix.x_mitre_deprecated}-undefined`; + } + if (!data.stix.created) { + const timestamp = new Date().toISOString(); + data.stix.created = timestamp; + data.stix.modified = timestamp; + } + + return techniquesService.create(data); + } + + // technique 1: x_mitre_deprecated,revoked undefined, state undefined + const technique1 = await createTechnique({}); + + // technique 2: x_mitre_deprecated = false, state = work-in-progress, mobile-attack domain. + // Adds a unique platform ('Windows') so the platform-filter test can target it. + await createTechnique({ + stix: { + x_mitre_deprecated: false, + x_mitre_domains: ['mobile-attack'], + x_mitre_platforms: [...baseTechnique.stix.x_mitre_platforms, 'Windows'], + }, + workspace: { workflow: { state: 'work-in-progress' } }, + }); + + // technique 3: x_mitre_deprecated = true, state = awaiting-review + await createTechnique({ + stix: { x_mitre_deprecated: true }, + workspace: { workflow: { state: 'awaiting-review' } }, + }); + + // technique 4: revoked via the revoke workflow (x_mitre_deprecated = false) + // Use technique1 as the revoking object + const technique4 = await createTechnique({ + stix: { x_mitre_deprecated: false }, + workspace: { workflow: { state: 'awaiting-review' } }, + }); + await techniquesService.revoke(technique4.stix.id, { + revoking: { stixId: technique1.stix.id, modified: technique1.stix.modified }, + }); + + // technique 5: multiple versions, last version deprecated + revoked const id = `attack-pattern--${uuid.v4()}`; + const createdTimestamp = new Date().toISOString(); + + const data5a = _.cloneDeep(baseTechnique); data5a.stix.id = id; data5a.stix.name = 'multiple-versions'; data5a.workspace.workflow = { state: 'awaiting-review' }; - const createdTimestamp = new Date().toISOString(); data5a.stix.created = createdTimestamp; data5a.stix.modified = createdTimestamp; - techniques.push(data5a); + await techniquesService.create(data5a); - await asyncWait(10); // wait so the modified timestamp can change + await asyncWait(10); const data5b = _.cloneDeep(baseTechnique); data5b.stix.id = id; data5b.stix.name = 'multiple-versions'; data5b.workspace.workflow = { state: 'awaiting-review' }; data5b.stix.created = createdTimestamp; - let timestamp = new Date().toISOString(); - data5b.stix.modified = timestamp; - techniques.push(data5b); + data5b.stix.modified = new Date().toISOString(); + await techniquesService.create(data5b); await asyncWait(10); const data5c = _.cloneDeep(baseTechnique); @@ -85,45 +119,26 @@ async function configureTechniques(baseTechnique) { data5c.stix.name = 'multiple-versions'; data5c.workspace.workflow = { state: 'awaiting-review' }; data5c.stix.x_mitre_deprecated = true; - data5c.stix.revoked = true; data5c.stix.created = createdTimestamp; - timestamp = new Date().toISOString(); - data5c.stix.modified = timestamp; - techniques.push(data5c); - - // x_mitre_deprecated,revoked undefined - // state = work-in-progress - const data6 = _.cloneDeep(baseTechnique); - data6.stix.x_mitre_deprecated = false; - data6.stix.revoked = false; - data6.workspace.workflow = { state: 'work-in-progress' }; - techniques.push(data6); - - // x_mitre_deprecated,revoked undefined - // state = reviewed - const data7 = _.cloneDeep(baseTechnique); - data7.stix.x_mitre_deprecated = false; - data7.stix.revoked = false; - data7.workspace.workflow = { state: 'reviewed' }; - techniques.push(data7); - - return techniques; -} + data5c.stix.modified = new Date().toISOString(); + await techniquesService.create(data5c); -async function loadTechniques(techniques) { - for (const technique of techniques) { - if (!technique.stix.name) { - technique.stix.name = `attack-pattern-${technique.stix.x_mitre_deprecated}-${technique.stix.revoked}`; - } + // Revoke technique 5 using technique1 as the revoking object + await techniquesService.revoke(id, { + revoking: { stixId: technique1.stix.id, modified: technique1.stix.modified }, + }); - if (!technique.stix.created) { - const timestamp = new Date().toISOString(); - technique.stix.created = timestamp; - technique.stix.modified = timestamp; - } + // technique 6: x_mitre_deprecated = false, state = work-in-progress + await createTechnique({ + stix: { x_mitre_deprecated: false }, + workspace: { workflow: { state: 'work-in-progress' } }, + }); - await techniquesService.create(technique); - } + // technique 7: x_mitre_deprecated = false, state = reviewed + await createTechnique({ + stix: { x_mitre_deprecated: false }, + workspace: { workflow: { state: 'reviewed' } }, + }); } describe('Techniques Query API', function () { @@ -138,12 +153,14 @@ describe('Techniques Query API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the seeded fixtures below are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); - const baseTechnique = await readJson('./techniques.query.json'); - const techniques = await configureTechniques(baseTechnique); - await loadTechniques(techniques); + await configureAndLoadTechniques(baseTechnique); // Log into the app passportCookie = await login.loginAnonymous(app); @@ -153,7 +170,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -168,7 +185,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?includeDeprecated=false') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -183,7 +200,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?includeDeprecated=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -198,7 +215,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?includeRevoked=false') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -213,7 +230,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?includeRevoked=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -228,7 +245,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?state=work-in-progress') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -243,7 +260,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?state=work-in-progress&state=reviewed') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -258,7 +275,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?domain=mobile-attack') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -273,7 +290,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?domain=not-a-domain') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -285,9 +302,9 @@ describe('Techniques Query API', function () { it('GET /api/techniques should return techniques containing the platform', async function () { const res = await request(app) - .get('/api/techniques?platform=platform-3') + .get('/api/techniques?platform=Windows') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -302,7 +319,7 @@ describe('Techniques Query API', function () { const res = await request(app) .get('/api/techniques?platform=not-a-platform') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/techniques/techniques.revoke.spec.js b/app/tests/api/techniques/techniques.revoke.spec.js new file mode 100644 index 00000000..54cb4859 --- /dev/null +++ b/app/tests/api/techniques/techniques.revoke.spec.js @@ -0,0 +1,572 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); + +const config = require('../../../config/config'); +const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); + +const logger = require('../../../lib/logger'); +logger.level = 'debug'; + +const initialObjectData = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + name: 'revoke-test-technique', + spec_version: '2.1', + type: 'attack-pattern', + description: 'This technique will be revoked.', + 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'], + }, +}; + +describe('Techniques Revoke API', function () { + let app; + let passportCookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + + app = await require('../../../index').initializeApp(); + passportCookie = await login.loginAnonymous(app); + }); + + let techniqueA; + let techniqueB; + + it('POST /api/techniques creates technique A (to be revoked)', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-A', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + techniqueA = res.body; + expect(techniqueA.stix.id).toBeDefined(); + }); + + it('POST /api/techniques creates technique B (the replacement)', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-B', + created: timestamp, + modified: timestamp, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + techniqueB = res.body; + expect(techniqueB.stix.id).toBeDefined(); + }); + + it('POST /api/techniques/:stixId/revoke returns 400 for self-revocation', async function () { + const res = await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { stixId: techniqueA.stix.id, modified: techniqueA.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + + expect(res.body.message || res.text).toBeDefined(); + }); + + it('POST /api/techniques/:stixId/revoke returns 404 for cross-type revocation (object B not in techniques collection)', async function () { + // Create a tactic (different type) and try to use it as the revoking object. + // Since each type has its own repository, the tactic won't be found in the + // techniques collection, resulting in a 404. + const timestamp = new Date().toISOString(); + const tacticBody = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + name: 'tactic-cross-type', + spec_version: '2.1', + type: 'x-mitre-tactic', + description: 'A tactic.', + x_mitre_shortname: 'cross-type-test', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + created: timestamp, + modified: timestamp, + }, + }; + const tacticRes = await request(app) + .post('/api/tactics') + .send(tacticBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const tactic = tacticRes.body; + + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { stixId: tactic.stix.id, modified: tactic.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); + }); + + it('POST /api/techniques/:stixId/revoke returns 404 when object A is not found', async function () { + await request(app) + .post('/api/techniques/attack-pattern--00000000-0000-0000-0000-000000000000/revoke') + .send({ + revoking: { stixId: techniqueB.stix.id, modified: techniqueB.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); + }); + + it('POST /api/techniques/:stixId/revoke returns 404 when object B is not found', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { + stixId: 'attack-pattern--00000000-0000-0000-0000-000000000000', + modified: '2026-01-01T00:00:00.000Z', + }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(404); + }); + + it('POST /api/techniques/:stixId/revoke returns 400 when revoking.stixId is missing', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ revoking: { modified: '2026-01-01T00:00:00.000Z' } }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + it('POST /api/techniques/:stixId/revoke returns 400 when revoking.modified is missing', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ revoking: { stixId: techniqueB.stix.id } }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + }); + + let revokeResult; + it('POST /api/techniques/:stixId/revoke revokes technique A in favor of technique B', async function () { + const res = await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { stixId: techniqueB.stix.id, modified: techniqueB.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + revokeResult = res.body; + + // Verify the WorkflowResult envelope + expect(revokeResult.workflow).toBe('revoke'); + expect(revokeResult.primary).toBeDefined(); + expect(revokeResult.sideEffects).toBeDefined(); + + // Verify the revoked object (primary) + expect(revokeResult.primary.stix.id).toBe(techniqueA.stix.id); + expect(revokeResult.primary.stix.revoked).toBe(true); + + // Verify the revoked-by relationship (first created side effect) + expect(revokeResult.sideEffects.created.length).toBeGreaterThan(0); + const revokedByRel = revokeResult.sideEffects.created[0]; + expect(revokedByRel.stix.relationship_type).toBe('revoked-by'); + expect(revokedByRel.stix.source_ref).toBe(techniqueA.stix.id); + expect(revokedByRel.stix.target_ref).toBe(techniqueB.stix.id); + }); + + it('GET /api/techniques/:stixId returns the revoked technique with revoked = true', async function () { + const res = await request(app) + .get(`/api/techniques/${techniqueA.stix.id}?versions=latest`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const techniques = res.body; + expect(techniques).toBeDefined(); + expect(techniques.length).toBe(1); + expect(techniques[0].stix.revoked).toBe(true); + }); + + it('GET /api/techniques excludes the revoked technique by default', async function () { + const res = await request(app) + .get('/api/techniques') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const techniques = res.body; + const revokedIds = techniques.map((t) => t.stix.id); + expect(revokedIds).not.toContain(techniqueA.stix.id); + }); + + it('GET /api/techniques?includeRevoked=true includes the revoked technique', async function () { + const res = await request(app) + .get('/api/techniques?includeRevoked=true') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const techniques = res.body; + const ids = techniques.map((t) => t.stix.id); + expect(ids).toContain(techniqueA.stix.id); + }); + + it('POST /api/techniques/:stixId/revoke returns 409 when object A is already revoked', async function () { + await request(app) + .post(`/api/techniques/${techniqueA.stix.id}/revoke`) + .send({ + revoking: { stixId: techniqueB.stix.id, modified: techniqueB.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(409); + }); + + it('POST /api/techniques strips revoked from create requests', async function () { + const timestamp = new Date().toISOString(); + const body = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'sneaky-revoke-attempt', + created: timestamp, + modified: timestamp, + revoked: true, + }, + }; + const res = await request(app) + .post('/api/techniques') + .send(body) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + // The revoked flag should have been stripped + expect(res.body.stix.revoked).not.toBe(true); + }); + + it('PUT /api/techniques strips revoked from update requests', async function () { + const updateData = cloneForCreate(techniqueB); + updateData.stix.revoked = true; + updateData.stix.description = 'Trying to sneak in revoked via update.'; + + const res = await request(app) + .put(`/api/techniques/${techniqueB.stix.id}/modified/${techniqueB.stix.modified}`) + .send(updateData) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + // The revoked flag should have been stripped, description updated + expect(res.body.stix.revoked).not.toBe(true); + expect(res.body.stix.description).toBe('Trying to sneak in revoked via update.'); + }); + + it('POST /api/techniques/:stixId/revoke with preserveRelationships transfers relationships', async function () { + // Create two new techniques: C (to be revoked) and D (replacement) + let timestamp = new Date().toISOString(); + const bodyC = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-C', + created: timestamp, + modified: timestamp, + }, + }; + const resC = await request(app) + .post('/api/techniques') + .send(bodyC) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const techniqueC = resC.body; + + timestamp = new Date().toISOString(); + const bodyD = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-D', + created: timestamp, + modified: timestamp, + }, + }; + const resD = await request(app) + .post('/api/techniques') + .send(bodyD) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const techniqueD = resD.body; + + // Create a relationship involving technique C + timestamp = new Date().toISOString(); + const relBody = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'uses', + source_ref: techniqueC.stix.id, + target_ref: techniqueB.stix.id, + created: timestamp, + modified: timestamp, + }, + }; + const relRes = await request(app) + .post('/api/relationships') + .send(relBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const originalRel = relRes.body; + + // Revoke technique C with preserveRelationships=true + const revokeRes = await request(app) + .post(`/api/techniques/${techniqueC.stix.id}/revoke?preserveRelationships=true`) + .send({ + revoking: { stixId: techniqueD.stix.id, modified: techniqueD.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const result = revokeRes.body; + // revoked-by + 1 transferred relationship = 2 created + expect(result.sideEffects.created.length).toBe(2); + expect(result.sideEffects.deprecated.length).toBeGreaterThanOrEqual(1); + + // Verify the original relationship was deprecated (not deleted — history preserved) + const relRes2 = await request(app) + .get(`/api/relationships/${originalRel.stix.id}?versions=latest`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + const deprecatedRels = relRes2.body; + expect(deprecatedRels.length).toBe(1); + expect(deprecatedRels[0].stix.x_mitre_deprecated).toBe(true); + + // Verify a new relationship was created pointing to technique D + const allRelsRes = await request(app) + .get(`/api/relationships?sourceRef=${techniqueD.stix.id}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + const transferredRels = allRelsRes.body.filter( + (r) => r.stix.relationship_type === 'uses' && r.stix.target_ref === techniqueB.stix.id, + ); + expect(transferredRels.length).toBe(1); + }); + + it('POST /api/techniques/:stixId/revoke with preserveRelationships skips duplicate relationships', async function () { + // Create technique E (to be revoked) and technique F (replacement) + let timestamp = new Date().toISOString(); + const bodyE = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-E', + created: timestamp, + modified: timestamp, + }, + }; + const resE = await request(app) + .post('/api/techniques') + .send(bodyE) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const techniqueE = resE.body; + + timestamp = new Date().toISOString(); + const bodyF = { + ...initialObjectData, + stix: { + ...initialObjectData.stix, + name: 'technique-F', + created: timestamp, + modified: timestamp, + }, + }; + const resF = await request(app) + .post('/api/techniques') + .send(bodyF) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const techniqueF = resF.body; + + // Create mitigation M1 + timestamp = new Date().toISOString(); + const bodyM = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + name: 'mitigation-dedup-test', + spec_version: '2.1', + type: 'course-of-action', + description: 'Mitigates both E and F.', + created: timestamp, + modified: timestamp, + }, + }; + const resM = await request(app) + .post('/api/mitigations') + .send(bodyM) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const mitigationM = resM.body; + + // Create "mitigates" relationship M1 → E + timestamp = new Date().toISOString(); + const relME = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'mitigates', + source_ref: mitigationM.stix.id, + target_ref: techniqueE.stix.id, + created: timestamp, + modified: timestamp, + }, + }; + await request(app) + .post('/api/relationships') + .send(relME) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + // Create "mitigates" relationship M1 → F (pre-existing duplicate) + timestamp = new Date().toISOString(); + const relMF = { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'relationship', + spec_version: '2.1', + relationship_type: 'mitigates', + source_ref: mitigationM.stix.id, + target_ref: techniqueF.stix.id, + created: timestamp, + modified: timestamp, + }, + }; + const resRelMF = await request(app) + .post('/api/relationships') + .send(relMF) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + const preExistingRel = resRelMF.body; + + // Revoke technique E in favor of technique F with preserveRelationships=true + const revokeRes = await request(app) + .post(`/api/techniques/${techniqueE.stix.id}/revoke?preserveRelationships=true`) + .send({ + revoking: { stixId: techniqueF.stix.id, modified: techniqueF.stix.modified }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const result = revokeRes.body; + + // The duplicate should have been skipped, not transferred + // Only the revoked-by relationship should be in created (no transferred relationships) + expect(result.sideEffects.created.length).toBe(1); + expect(result.sideEffects.created[0].stix.relationship_type).toBe('revoked-by'); + + // Verify exactly one "mitigates" relationship exists from M1 → F (the pre-existing one) + const relsRes = await request(app) + .get(`/api/relationships?sourceRef=${mitigationM.stix.id}&targetRef=${techniqueF.stix.id}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + const mitigatesRels = relsRes.body.filter((r) => r.stix.relationship_type === 'mitigates'); + expect(mitigatesRels.length).toBe(1); + expect(mitigatesRels[0].stix.id).toBe(preExistingRel.stix.id); + + // Verify the original M1 → E relationship was deprecated (not deleted — history preserved) + const origRes = await request(app) + .get( + `/api/relationships?sourceRef=${mitigationM.stix.id}&targetRef=${techniqueE.stix.id}&includeDeprecated=true`, + ) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + const oldMitigatesRels = origRes.body.filter((r) => r.stix.relationship_type === 'mitigates'); + expect(oldMitigatesRels.length).toBe(1); + expect(oldMitigatesRels[0].stix.x_mitre_deprecated).toBe(true); + + // Verify it's excluded from default queries (without includeDeprecated) + const defaultRes = await request(app) + .get(`/api/relationships?sourceRef=${mitigationM.stix.id}&targetRef=${techniqueE.stix.id}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + const defaultRels = defaultRes.body.filter((r) => r.stix.relationship_type === 'mitigates'); + expect(defaultRels.length).toBe(0); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/app/tests/api/techniques/techniques.spec.js b/app/tests/api/techniques/techniques.spec.js index 743a19b7..7bf95fdc 100644 --- a/app/tests/api/techniques/techniques.spec.js +++ b/app/tests/api/techniques/techniques.spec.js @@ -1,12 +1,12 @@ const request = require('supertest'); const { expect } = require('expect'); -const _ = require('lodash'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const config = require('../../../config/config'); const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -24,23 +24,19 @@ const initialObjectData = { spec_version: '2.1', type: 'attack-pattern', description: 'This is a technique. Orange.', - external_references: [ - { - source_name: 'mitre-attack', - external_id: 'T9999', - url: 'https://attack.mitre.org/techniques/T9999', - }, - { source_name: 'source-1', external_id: 's1' }, - ], + // external_references and stix.id are populated by the REST API 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: 'kill-chain-name-1', phase_name: 'phase-1' }], - x_mitre_modified_by_ref: 'identity--d6424da5-85a0-496e-ae17-494499271108', - x_mitre_data_sources: ['data-source-1', 'data-source-2'], + // ADM requires a kill_chain_name from the ATT&CK enum. The `impact` tactic is + // required for x_mitre_impact_type to be permitted. + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'impact' }], + // ADM requires x_mitre_modified_by_ref to be the MITRE ATT&CK identity + x_mitre_modified_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + // x_mitre_data_sources: ['data-source-1', 'data-source-2'], // TODO field is deprecated x_mitre_detection: 'detection text', x_mitre_is_subtechnique: false, - x_mitre_impact_type: ['impact-1'], - x_mitre_platforms: ['platform-1', 'platform-2'], + x_mitre_impact_type: ['Availability'], + x_mitre_platforms: ['Linux'], x_mitre_network_requirements: true, }, }; @@ -57,6 +53,10 @@ describe('Techniques Basic API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; the request payloads in this spec are ADM-compliant + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -68,7 +68,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -85,7 +85,7 @@ describe('Techniques Basic API', function () { .post('/api/techniques') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -99,7 +99,7 @@ describe('Techniques Basic API', function () { .post('/api/techniques') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -119,7 +119,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -134,7 +134,7 @@ describe('Techniques Basic API', function () { await request(app) .get('/api/techniques/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -142,7 +142,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques/' + technique1.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -165,9 +165,12 @@ describe('Techniques Basic API', function () { ); expect(technique.stix.created_by_ref).toBe(technique1.stix.created_by_ref); expect(technique.stix.x_mitre_modified_by_ref).toBe(technique1.stix.x_mitre_modified_by_ref); - expect(technique.stix.x_mitre_data_sources).toEqual( - expect.arrayContaining(technique1.stix.x_mitre_data_sources), - ); + // x_mitre_data_sources is deprecated and not set in the test data + if (technique1.stix.x_mitre_data_sources) { + expect(technique.stix.x_mitre_data_sources).toEqual( + expect.arrayContaining(technique1.stix.x_mitre_data_sources), + ); + } expect(technique.stix.x_mitre_detection).toBe(technique1.stix.x_mitre_detection); expect(technique.stix.x_mitre_is_subtechnique).toBe(technique1.stix.x_mitre_is_subtechnique); expect(technique.stix.x_mitre_impact_type).toEqual( @@ -201,12 +204,12 @@ describe('Techniques Basic API', function () { const timestamp = new Date().toISOString(); technique1.stix.modified = timestamp; technique1.stix.description = 'This is an updated technique.'; - const body = technique1; + const body = cloneForCreate(technique1); const res = await request(app) .put('/api/techniques/' + technique1.stix.id + '/modified/' + originalModified) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -218,21 +221,18 @@ describe('Techniques Basic API', function () { }); it('POST /api/techniques does not create a technique with the same id and modified date', async function () { - const body = technique1; + const body = cloneForCreate(technique1); await request(app) .post('/api/techniques') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(409); }); let technique2; it('POST /api/techniques should create a new version of a technique with a duplicate stix.id but different stix.modified date', async function () { - technique2 = _.cloneDeep(technique1); - technique2._id = undefined; - technique2.__t = undefined; - technique2.__v = undefined; + technique2 = cloneForCreate(technique1); const timestamp = new Date().toISOString(); technique2.stix.modified = timestamp; technique2.stix.description = 'Still a technique. Purple!'; @@ -241,7 +241,7 @@ describe('Techniques Basic API', function () { .post('/api/techniques') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -252,10 +252,7 @@ describe('Techniques Basic API', function () { let technique3; it('POST /api/techniques should create a new version of a technique with a duplicate stix.id but different stix.modified date', async function () { - technique3 = _.cloneDeep(technique1); - technique3._id = undefined; - technique3.__t = undefined; - technique3.__v = undefined; + technique3 = cloneForCreate(technique1); const timestamp = new Date().toISOString(); technique3.stix.modified = timestamp; technique3.stix.description = 'Still a technique. Blue!'; @@ -264,7 +261,7 @@ describe('Techniques Basic API', function () { .post('/api/techniques') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -277,7 +274,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques/' + technique3.stix.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -295,7 +292,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques/' + technique1.stix.id + '?versions=all') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -310,7 +307,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques/' + technique1.stix.id + '/modified/' + technique1.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -326,7 +323,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques/' + technique2.stix.id + '/modified/' + technique2.stix.modified) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -342,7 +339,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques?search=blue') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -361,10 +358,12 @@ describe('Techniques Basic API', function () { }); it('GET /api/techniques uses the search parameter (ATT&CK ID) to return the latest version of the technique', async function () { + // Use the auto-generated ATT&CK ID from technique1 + const attackId = technique1.workspace.attack_id; const res = await request(app) - .get('/api/techniques?search=T9999') + .get(`/api/techniques?search=${attackId}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -380,14 +379,14 @@ describe('Techniques Basic API', function () { expect(technique.stix).toBeDefined(); expect(technique.stix.id).toBe(technique3.stix.id); expect(technique.stix.modified).toBe(technique3.stix.modified); - expect(technique.workspace.attack_id).toEqual('T9999'); + expect(technique.workspace.attack_id).toEqual(attackId); }); it('GET /api/techniques should not get the first version of the techniques when using the search parameter', async function () { const res = await request(app) .get('/api/techniques?search=orange') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -401,21 +400,21 @@ describe('Techniques Basic API', function () { it('DELETE /api/techniques/:id should not delete a technique when the id cannot be found', async function () { await request(app) .delete('/api/techniques/not-an-id') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); it('DELETE /api/techniques/:id/modified/:modified deletes a technique', async function () { await request(app) .delete('/api/techniques/' + technique1.stix.id + '/modified/' + technique1.stix.modified) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); it('DELETE /api/techniques/:id should delete all the techniques with the same stix id', async function () { await request(app) .delete('/api/techniques/' + technique2.stix.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -423,7 +422,7 @@ describe('Techniques Basic API', function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/techniques/techniques.tactics.spec.js b/app/tests/api/techniques/techniques.tactics.spec.js index 055a0bef..02442162 100644 --- a/app/tests/api/techniques/techniques.tactics.spec.js +++ b/app/tests/api/techniques/techniques.tactics.spec.js @@ -4,14 +4,14 @@ const request = require('supertest'); const { expect } = require('expect'); const login = require('../../shared/login'); - +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const collectionBundlesService = require('../../../services/collection-bundles-service'); +const collectionBundlesService = require('../../../services/stix/collection-bundles-service'); async function readJson(path) { const data = await fs.readFile(require.resolve(path)); @@ -30,6 +30,14 @@ describe('Techniques with Tactics API', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation. NOTE: this suite seeds via the collection-bundle + // import path, which records per-object ADM issues (import-fidelity contract) + // rather than rejecting them — so the bundle below is imported even though it + // is not fully ADM-compliant. This suite exercises the technique<->tactic + // relationship endpoints, not request-level ADM enforcement. + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -54,7 +62,7 @@ describe('Techniques with Tactics API', function () { const res = await request(app) .get('/api/techniques') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -71,7 +79,7 @@ describe('Techniques with Tactics API', function () { const res = await request(app) .get('/api/tactics') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -85,7 +93,7 @@ describe('Techniques with Tactics API', function () { await request(app) .get(`/api/techniques/not-an-id/modified/2022-01-01T00:00:00.000Z/tactics`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -93,7 +101,7 @@ describe('Techniques with Tactics API', function () { const res = await request(app) .get(`/api/techniques/${technique1.stix.id}/modified/${technique1.stix.modified}/tactics`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -107,7 +115,7 @@ describe('Techniques with Tactics API', function () { const res = await request(app) .get(`/api/techniques/${technique2.stix.id}/modified/${technique2.stix.modified}/tactics`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -123,7 +131,7 @@ describe('Techniques with Tactics API', function () { `/api/techniques/${technique2.stix.id}/modified/${technique2.stix.modified}/tactics?offset=0&limit=2&includePagination=true`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/user-accounts/user-accounts-invalid.spec.js b/app/tests/api/user-accounts/user-accounts-invalid.spec.js index be21b482..d7d8a155 100644 --- a/app/tests/api/user-accounts/user-accounts-invalid.spec.js +++ b/app/tests/api/user-accounts/user-accounts-invalid.spec.js @@ -1,15 +1,33 @@ const request = require('supertest'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); -const userAccounts = require('./user-accounts.invalid.json'); - const login = require('../../shared/login'); +// Invalid user account payloads — each violates a required-field, enum, type, or +// business rule. Used to assert the API rejects malformed input with a 400. +const userAccounts = [ + { email: 'user1@test.com', username: 'user missing status and role' }, + { email: 'user1@test.com', displayName: 'user missing username', status: 'pending' }, + { email: 'user2@test.com', username: 'user invalid status', status: 'abcde', role: 'editor' }, + { email: 'user3@test.com', username: 'user invalid role', status: 'active', role: 'xyzzy' }, + { + email: 'user4@test.com', + username: 'user inactive cannot have role', + status: 'inactive', + role: 'admin', + }, + { email: 5, username: 'user has number for email', status: 'active', role: 'editor' }, + { email: 'user6@test.com', username: 6, status: 'active', role: 'editor' }, + { email: 'user7@test.com', username: 'user has number for status', status: 7, role: 'editor' }, + { email: 'user8@test.com', username: 'user has number for role', status: 'active', role: 8 }, +]; + describe('User Accounts API Test Invalid Data', function () { let app; let passportCookie; @@ -22,6 +40,10 @@ describe('User Accounts API Test Invalid Data', function () { // Check for a valid database configuration await databaseConfiguration.checkSystemConfiguration(); + // Enable ADM validation; this non-STIX payload spec should not inherit a disabled flag + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -36,7 +58,7 @@ describe('User Accounts API Test Invalid Data', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); } diff --git a/app/tests/api/user-accounts/user-accounts.invalid.json b/app/tests/api/user-accounts/user-accounts.invalid.json deleted file mode 100644 index 45ac12dd..00000000 --- a/app/tests/api/user-accounts/user-accounts.invalid.json +++ /dev/null @@ -1,53 +0,0 @@ -[ - { - "email": "user1@test.com", - "username": "user missing status and role" - }, - { - "email": "user1@test.com", - "displayName": "user missing username", - "status": "pending" - }, - { - "email": "user2@test.com", - "username": "user invalid status", - "status": "abcde", - "role": "editor" - }, - { - "email": "user3@test.com", - "username": "user invalid role", - "status": "active", - "role": "xyzzy" - }, - { - "email": "user4@test.com", - "username": "user inactive cannot have role", - "status": "inactive", - "role": "admin" - }, - { - "email": 5, - "username": "user has number for email", - "status": "active", - "role": "editor" - }, - { - "email": "user6@test.com", - "username": 6, - "status": "active", - "role": "editor" - }, - { - "email": "user7@test.com", - "username": "user has number for status", - "status": 7, - "role": "editor" - }, - { - "email": "user8@test.com", - "username": "user has number for role", - "status": "active", - "role": 8 - } -] diff --git a/app/tests/api/user-accounts/user-accounts.spec.js b/app/tests/api/user-accounts/user-accounts.spec.js index f5b4257f..8b254e23 100644 --- a/app/tests/api/user-accounts/user-accounts.spec.js +++ b/app/tests/api/user-accounts/user-accounts.spec.js @@ -1,6 +1,7 @@ const request = require('supertest'); const { expect } = require('expect'); +const config = require('../../../config/config'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -8,7 +9,7 @@ const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const UserAccount = require('../../../models/user-account-model'); const Team = require('../../../models/team-model'); -const teamsService = require('../../../services/teams-service'); +const teamsService = require('../../../services/system/teams-service'); const login = require('../../shared/login'); @@ -37,6 +38,10 @@ describe('User Accounts API', function () { await UserAccount.init(); await Team.init(); + // Enable ADM validation; this non-STIX payload spec should not inherit a disabled flag + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + // Initialize the express app app = await require('../../../index').initializeApp(); @@ -48,7 +53,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -65,7 +70,7 @@ describe('User Accounts API', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); @@ -76,7 +81,7 @@ describe('User Accounts API', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -90,7 +95,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -105,7 +110,7 @@ describe('User Accounts API', function () { await request(app) .get('/api/user-accounts/not-an-id') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(404); }); @@ -113,7 +118,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts/' + userAccount1.id) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -137,7 +142,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts/' + userAccount1.id + '?includeStixIdentity=true') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -173,7 +178,7 @@ describe('User Accounts API', function () { .put('/api/user-accounts/' + userAccount1.id) .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -193,7 +198,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts?search=first') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -210,14 +215,14 @@ describe('User Accounts API', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) - .expect(400); + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(409); }); it('DELETE /api/user-accounts deletes a user account', async function () { await request(app) .delete('/api/user-accounts/' + userAccount1.id) - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(204); }); @@ -226,7 +231,7 @@ describe('User Accounts API', function () { const res = await request(app) .get('/api/user-accounts') .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -242,7 +247,7 @@ describe('User Accounts API', function () { const res = await request(app) .get(`/api/user-accounts/${anonymousUserId}/teams`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -266,7 +271,7 @@ describe('User Accounts API', function () { const res = await request(app) .get(`/api/user-accounts/${anonymousUserId}/teams`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/api/user-accounts/user-accounts.valid.json b/app/tests/api/user-accounts/user-accounts.valid.json deleted file mode 100644 index 9ac791a6..00000000 --- a/app/tests/api/user-accounts/user-accounts.valid.json +++ /dev/null @@ -1,33 +0,0 @@ -[ - { - "email": "user1@test.com", - "username": "user1@test.com", - "displayName": "User 1", - "status": "active", - "role": "visitor" - }, - { - "email": "user2@test.com", - "username": "user2@test.com", - "displayName": "User 2", - "status": "active", - "role": "editor" - }, - { - "email": "user3@test.com", - "username": "user3@test.com", - "displayName": "User 3", - "status": "active", - "role": "admin" - }, - { - "email": "user4@test.com", - "username": "user4", - "status": "inactive" - }, - { - "email": "user5@test.com", - "username": "user5", - "status": "pending" - } -] diff --git a/app/tests/authn/anonymous-authn.spec.js b/app/tests/authn/anonymous-authn.spec.js index 2ee250f0..ca787598 100644 --- a/app/tests/authn/anonymous-authn.spec.js +++ b/app/tests/authn/anonymous-authn.spec.js @@ -1,15 +1,13 @@ const request = require('supertest'); const { expect } = require('expect'); -const setCookieParser = require('set-cookie-parser'); const database = require('../../lib/database-in-memory'); const databaseConfiguration = require('../../lib/database-configuration'); +const login = require('../shared/login'); const logger = require('../../lib/logger'); logger.level = 'debug'; -const passportCookieName = 'connect.sid'; - describe('Anonymous User Authentication', function () { let app; let passportCookie; @@ -34,14 +32,8 @@ describe('Anonymous User Authentication', function () { }); it('GET /api/authn/anonymous/login successfully logs the user in', async function () { - const response = await request(app) - .get('/api/authn/anonymous/login') - .set('Accept', 'application/json') - .expect(200); - - // Save the cookie for later tests - const cookies = setCookieParser(response); - passportCookie = cookies.find((c) => c.name === passportCookieName); + // Use the shared login helper + passportCookie = await login.loginAnonymous(app); expect(passportCookie).toBeDefined(); }); @@ -49,7 +41,7 @@ describe('Anonymous User Authentication', function () { const response = await request(app) .get('/api/session') .set('Accept', 'application/json') - .set('Cookie', `${passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200); // We expect to get the current session @@ -61,7 +53,7 @@ describe('Anonymous User Authentication', function () { await request(app) .get('/api/authn/anonymous/logout') .set('Accept', 'application/json') - .set('Cookie', `${passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200); }); @@ -69,7 +61,7 @@ describe('Anonymous User Authentication', function () { await request(app) .get('/api/session') .set('Accept', 'application/json') - .set('Cookie', `${passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(401); }); diff --git a/app/tests/config/config.spec.js b/app/tests/config/config.spec.js index 9a06b12a..25e2f7c2 100644 --- a/app/tests/config/config.spec.js +++ b/app/tests/config/config.spec.js @@ -4,7 +4,7 @@ process.env.JSON_CONFIG_PATH = './app/tests/config/test-config.json'; const { expect } = require('expect'); const config = require('../../config/config'); -const markingDefinitionsService = require('../../services/marking-definitions-service'); +const markingDefinitionsService = require('../../services/stix/marking-definitions-service'); const database = require('../../lib/database-in-memory'); const databaseConfiguration = require('../../lib/database-configuration'); diff --git a/app/tests/fuzz/user-accounts-fuzz.spec.js b/app/tests/fuzz/user-accounts-fuzz.spec.js index 3f9388a4..d0997fd6 100644 --- a/app/tests/fuzz/user-accounts-fuzz.spec.js +++ b/app/tests/fuzz/user-accounts-fuzz.spec.js @@ -63,7 +63,7 @@ describe('User Accounts API Test Invalid Data', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); } @@ -84,7 +84,7 @@ describe('User Accounts API Test Invalid Data', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201); }); } @@ -107,7 +107,7 @@ describe('User Accounts API Test Invalid Data', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); } @@ -131,7 +131,7 @@ describe('User Accounts API Test Invalid Data', function () { .post('/api/user-accounts') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(400); }); } diff --git a/app/tests/import/collection-bundles-enterprise.spec.js b/app/tests/import/collection-bundles-enterprise.spec.js index a8afa6ad..3402e372 100644 --- a/app/tests/import/collection-bundles-enterprise.spec.js +++ b/app/tests/import/collection-bundles-enterprise.spec.js @@ -66,7 +66,7 @@ describe('Collection Bundles API Full-Size Test', function () { .post('/api/collection-bundles?checkOnly=true') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -85,7 +85,7 @@ describe('Collection Bundles API Full-Size Test', function () { .post('/api/collection-bundles?previewOnly=true') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -104,7 +104,7 @@ describe('Collection Bundles API Full-Size Test', function () { .post('/api/collection-bundles') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(201) .expect('Content-Type', /json/); @@ -121,7 +121,7 @@ describe('Collection Bundles API Full-Size Test', function () { const res = await request(app) .get(`/api/stix-bundles?domain=${domain}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/app/tests/integration-test/initialize-data.js b/app/tests/integration-test/initialize-data.js index b5ebbba8..e660a722 100644 --- a/app/tests/integration-test/initialize-data.js +++ b/app/tests/integration-test/initialize-data.js @@ -5,19 +5,18 @@ const superagent = require('superagent'); const setCookieParser = require('set-cookie-parser'); -const passportCookieName = 'connect.sid'; - let passportCookie; async function login(url) { const res = await superagent.get(url); const cookies = setCookieParser(res); - passportCookie = cookies.find((c) => c.name === passportCookieName); + // The cookie name may be 'connect.sid' or 'connect.XXXXXXXX.sid' depending on hostname + passportCookie = cookies.find((c) => c.name.startsWith('connect.') && c.name.endsWith('.sid')); } function post(url, data) { return superagent .post(url) - .set('Cookie', `${passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .send(data); } @@ -40,7 +39,7 @@ async function initializeData() { }; // Log into the Workbench REST API - const loginUrl = 'http://localhost:3000/api/authn/anonymous/login'; + const loginUrl = 'http://localhost:8080/api/authn/anonymous/login'; await login(loginUrl); // Import the collection index v1 into the database diff --git a/app/tests/integration-test/update-subscription.js b/app/tests/integration-test/update-subscription.js index cf9a7e90..34ff21b8 100644 --- a/app/tests/integration-test/update-subscription.js +++ b/app/tests/integration-test/update-subscription.js @@ -5,19 +5,18 @@ const superagent = require('superagent'); const setCookieParser = require('set-cookie-parser'); -const passportCookieName = 'connect.sid'; - let passportCookie; async function login(url) { const res = await superagent.get(url); const cookies = setCookieParser(res); - passportCookie = cookies.find((c) => c.name === passportCookieName); + // The cookie name may be 'connect.sid' or 'connect.XXXXXXXX.sid' depending on hostname + passportCookie = cookies.find((c) => c.name.startsWith('connect.') && c.name.endsWith('.sid')); } async function get(url) { const res = await superagent .get(url) - .set('Cookie', `${passportCookieName}=${passportCookie.value}`); + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); return res.body; } @@ -25,7 +24,7 @@ async function get(url) { function put(url, data) { return superagent .put(url) - .set('Cookie', `${passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .send(data); } diff --git a/app/tests/lib/embedded-relationships.spec.js b/app/tests/lib/embedded-relationships.spec.js new file mode 100644 index 00000000..c48921bb --- /dev/null +++ b/app/tests/lib/embedded-relationships.spec.js @@ -0,0 +1,435 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const database = require('../../lib/database-in-memory'); +const databaseConfiguration = require('../../lib/database-configuration'); +const login = require('../shared/login'); + +const logger = require('../../lib/logger'); +const { cloneForCreate } = require('../shared/clone-for-create'); +logger.level = 'debug'; + +describe('Embedded Relationships - Detection Strategies and Analytics', function () { + let app; + let passportCookie; + + before(async function () { + // Establish the database connection + await database.initializeConnection(); + + // Check for a valid database configuration + await databaseConfiguration.checkSystemConfiguration(); + + // Initialize the express app + app = await require('../../index').initializeApp(); + + // Log into the app + passportCookie = await login.loginAnonymous(app); + }); + + describe('Create Detection Strategy with Analytics', function () { + let analytic1, analytic2, detectionStrategy; + + it('Setup: Create two analytics', async function () { + const timestamp = new Date().toISOString(); + + // Create first analytic + const analytic1Data = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Analytic 1', + type: 'x-mitre-analytic', + spec_version: '2.1', + description: 'Test analytic 1', + created: timestamp, + modified: timestamp, + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['windows'], + x_mitre_version: '1.0', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res1 = await request(app) + .post('/api/analytics') + .send(analytic1Data) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + analytic1 = res1.body; + expect(analytic1.stix.id).toBeDefined(); + + // Create second analytic + const analytic2Data = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Analytic 2', + type: 'x-mitre-analytic', + spec_version: '2.1', + description: 'Test analytic 2', + created: timestamp, + modified: timestamp, + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['windows'], + x_mitre_version: '1.0', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res2 = await request(app) + .post('/api/analytics') + .send(analytic2Data) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + analytic2 = res2.body; + expect(analytic2.stix.id).toBeDefined(); + }); + + it('Should create detection strategy with analytics and build outbound embedded_relationships', async function () { + const timestamp = new Date().toISOString(); + + const detectionStrategyData = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Detection Strategy', + type: 'x-mitre-detection-strategy', + spec_version: '2.1', + description: 'Test detection strategy', + created: timestamp, + modified: timestamp, + x_mitre_domains: ['enterprise-attack'], + x_mitre_version: '1.0', + x_mitre_analytic_refs: [analytic1.stix.id, analytic2.stix.id], + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res = await request(app) + .post('/api/detection-strategies') + .send(detectionStrategyData) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + detectionStrategy = res.body; + + // Verify detection strategy was created + expect(detectionStrategy.stix.id).toBeDefined(); + expect(detectionStrategy.stix.x_mitre_analytic_refs).toHaveLength(2); + + // Verify outbound embedded_relationships were created + expect(detectionStrategy.workspace.embedded_relationships).toBeDefined(); + expect(detectionStrategy.workspace.embedded_relationships).toHaveLength(2); + + const outboundRel1 = detectionStrategy.workspace.embedded_relationships.find( + (rel) => rel.stix_id === analytic1.stix.id, + ); + expect(outboundRel1).toBeDefined(); + expect(outboundRel1.direction).toBe('outbound'); + expect(outboundRel1.attack_id).toBe(analytic1.workspace.attack_id); + + const outboundRel2 = detectionStrategy.workspace.embedded_relationships.find( + (rel) => rel.stix_id === analytic2.stix.id, + ); + expect(outboundRel2).toBeDefined(); + expect(outboundRel2.direction).toBe('outbound'); + expect(outboundRel2.attack_id).toBe(analytic2.workspace.attack_id); + }); + + it('Should add inbound embedded_relationships to analytics', async function () { + // Wait a bit for event handlers to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fetch the updated analytics + const res1 = await request(app) + .get(`/api/analytics/${analytic1.stix.id}`) + .query({ versions: 'latest' }) + .query({ includeRefs: true }) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const updatedAnalytic1 = res1.body[0]; + + // Verify inbound embedded_relationship was added + expect(updatedAnalytic1.workspace.embedded_relationships).toBeDefined(); + expect(updatedAnalytic1.workspace.embedded_relationships).toHaveLength(1); + + const inboundRel = updatedAnalytic1.workspace.embedded_relationships[0]; + expect(inboundRel.stix_id).toBe(detectionStrategy.stix.id); + expect(inboundRel.direction).toBe('inbound'); + expect(inboundRel.attack_id).toBe(detectionStrategy.workspace.attack_id); + + // Verify external_references was updated with URL + const attackRef = updatedAnalytic1.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef).toBeDefined(); + expect(attackRef.url).toBe( + `https://attack.mitre.org/detectionstrategies/${detectionStrategy.workspace.attack_id}#${analytic1.workspace.attack_id}`, + ); + }); + }); + + describe('Update Detection Strategy - Add Analytics', function () { + let analytic3, detectionStrategy2; + + it('Setup: Create an analytic and a detection strategy without analytics', async function () { + // Create analytic + const timestamp1 = new Date().toISOString(); + const analytic3Data = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Analytic 3', + type: 'x-mitre-analytic', + spec_version: '2.1', + description: 'Test analytic 3', + created: timestamp1, + modified: timestamp1, + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['windows'], + x_mitre_version: '1.0', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res1 = await request(app) + .post('/api/analytics') + .send(analytic3Data) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201); + + analytic3 = res1.body; + + // Create detection strategy without analytics + const timestamp2 = new Date().toISOString(); + const detectionStrategyData = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Detection Strategy 2', + type: 'x-mitre-detection-strategy', + spec_version: '2.1', + description: 'Test detection strategy 2', + created: timestamp2, + modified: timestamp2, + x_mitre_domains: ['enterprise-attack'], + x_mitre_version: '1.0', + x_mitre_analytic_refs: [], // Empty initially + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res2 = await request(app) + .post('/api/detection-strategies') + .send(detectionStrategyData) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201); + + detectionStrategy2 = res2.body; + + // Verify no embedded_relationships initially + expect(detectionStrategy2.workspace.embedded_relationships?.length || 0).toBe(0); + }); + + it('Should add analytic to detection strategy and create bidirectional relationships', async function () { + const timestamp = new Date().toISOString(); + + // Update detection strategy to add analytic + const updatedData = { + ...detectionStrategy2, + stix: { + ...detectionStrategy2.stix, + modified: timestamp, + x_mitre_analytic_refs: [analytic3.stix.id], + }, + }; + + // Use the actual modified timestamp from the created object + const res = await request(app) + .put( + `/api/detection-strategies/${detectionStrategy2.stix.id}/modified/${detectionStrategy2.stix.modified}`, + ) + .send(updatedData) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(200); + + const updatedDetectionStrategy = res.body; + + // Verify outbound relationship was added + expect(updatedDetectionStrategy.workspace.embedded_relationships).toHaveLength(1); + expect(updatedDetectionStrategy.workspace.embedded_relationships[0].stix_id).toBe( + analytic3.stix.id, + ); + expect(updatedDetectionStrategy.workspace.embedded_relationships[0].direction).toBe( + 'outbound', + ); + + // Wait for event handlers + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify inbound relationship was added to analytic + const res2 = await request(app) + .get(`/api/analytics/${analytic3.stix.id}`) + .query({ versions: 'latest', includeRefs: true }) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(200); + + const updatedAnalytic3 = res2.body[0]; + expect(updatedAnalytic3.workspace.embedded_relationships).toHaveLength(1); + expect(updatedAnalytic3.workspace.embedded_relationships[0].stix_id).toBe( + detectionStrategy2.stix.id, + ); + expect(updatedAnalytic3.workspace.embedded_relationships[0].direction).toBe('inbound'); + + // Verify URL was added + const attackRef = updatedAnalytic3.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef.url).toBe( + `https://attack.mitre.org/detectionstrategies/${detectionStrategy2.workspace.attack_id}#${analytic3.workspace.attack_id}`, + ); + }); + }); + + describe('Update Detection Strategy - Remove Analytics', function () { + let analytic4, detectionStrategy3; + + it('Setup: Create detection strategy with analytics', async function () { + // Create analytic + const timestamp1 = new Date().toISOString(); + const analytic4Data = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Analytic 4', + type: 'x-mitre-analytic', + spec_version: '2.1', + description: 'Test analytic 4', + created: timestamp1, + modified: timestamp1, + x_mitre_domains: ['enterprise-attack'], + x_mitre_platforms: ['windows'], + x_mitre_version: '1.0', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res1 = await request(app) + .post('/api/analytics') + .send(analytic4Data) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201); + + analytic4 = res1.body; + + // Create detection strategy with analytics + const timestamp2 = new Date().toISOString(); + const detectionStrategyData = { + workspace: { + workflow: { state: 'work-in-progress' }, + }, + stix: { + name: 'Test Detection Strategy 3', + type: 'x-mitre-detection-strategy', + spec_version: '2.1', + description: 'Test detection strategy 3', + created: timestamp2, + modified: timestamp2, + x_mitre_domains: ['enterprise-attack'], + x_mitre_version: '1.0', + x_mitre_analytic_refs: [analytic4.stix.id], + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + }, + }; + + const res2 = await request(app) + .post('/api/detection-strategies') + .send(detectionStrategyData) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201); + + detectionStrategy3 = res2.body; + + // Wait for event handlers + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + it('Should remove analytic from detection strategy and clean up bidirectional relationships', async function () { + const timestamp = new Date().toISOString(); + + // Update detection strategy to remove analytic + const updatedData = cloneForCreate(detectionStrategy3); + updatedData.stix.modified = timestamp; // Bump timestamp + updatedData.stix.x_mitre_analytic_refs = []; // Remove all analytics + + // Use the actual modified timestamp from the created object + const res = await request(app) + .post('/api/detection-strategies') + .send(updatedData) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(201); + + const updatedDetectionStrategy = res.body; + + // Verify outbound relationship was removed + expect(updatedDetectionStrategy.workspace.embedded_relationships?.length || 0).toBe(0); + + // Wait for event handlers + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify inbound relationship was removed from analytic + const res2 = await request(app) + .get(`/api/analytics/${analytic4.stix.id}`) + .query({ versions: 'latest' }) + .query({ includeRefs: true }) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .expect(200); + + const updatedAnalytic4 = res2.body[0]; + expect(updatedAnalytic4.workspace.embedded_relationships?.length || 0).toBe(0); + + // Verify URL was removed + const attackRef = updatedAnalytic4.stix.external_references.find( + (ref) => ref.source_name === 'mitre-attack', + ); + expect(attackRef.url).toBeUndefined(); + }); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/app/tests/middleware/adm-validation-middleware.spec.js b/app/tests/middleware/adm-validation-middleware.spec.js new file mode 100644 index 00000000..f2fbe5a0 --- /dev/null +++ b/app/tests/middleware/adm-validation-middleware.spec.js @@ -0,0 +1,883 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const database = require('../../lib/database-in-memory'); +const databaseConfiguration = require('../../lib/database-configuration'); +const config = require('../../config/config'); +const login = require('../shared/login'); + +const logger = require('../../lib/logger'); +logger.level = 'debug'; + +const uuid = require('uuid'); +const { createSyntheticStixObject } = require('@mitre-attack/attack-data-model/dist/generator'); +const { cloneForCreate } = require('../shared/clone-for-create'); + +/** + * Smoke tests for ATT&CK Data Model (ADM) validation middleware. + * + * These tests verify that the ADM validation middleware correctly validates + * POST and PUT requests using the Zod-based schemas from the ADM library. + * + * Test Coverage: + * - POST operations with work-in-progress workflow state (partial validation) + * - POST operations with reviewed workflow state (full validation) + * - PUT operations with work-in-progress workflow state (partial validation) + * - PUT operations with reviewed workflow state (full validation) + * - True positives: valid data should pass + * - True negatives: invalid data should fail with proper errors + * - Validation toggle (enabled/disabled) + * + * NOTE: Tests focus on techniques initially. Once validated, can be generalized to other types. + */ + +describe('ADM Validation Middleware', function () { + let app; + let passportCookie; + + const endpoint = '/api/techniques'; + const stixType = 'attack-pattern'; + + /** + * Helper function to create a synthetic STIX object with unique ID and timestamps. + * + * Uses the ADM's createSyntheticStixObject() to generate a valid baseline object, + * then customizes it for testing purposes (unique IDs, fresh timestamps, etc.). + * + * NOTE: This function also includes special handling for x_mitre_platforms and + * x_mitre_contributors to work around a Mongoose serialization issue. See the + * inline comments below for detailed explanation. + */ + function createSyntheticStix(type) { + const syntheticStix = createSyntheticStixObject(type); + if (!syntheticStix) { + throw new Error(`Failed to create synthetic STIX object for type: ${type}`); + } + + // Remove server-managed field + delete syntheticStix.x_mitre_attack_spec_version; + + // Generate unique ID to avoid conflicts between tests + syntheticStix.id = `${type}--${uuid.v4()}`; + + // Set fresh timestamps for each test to avoid conflicts + const timestamp = new Date().toISOString(); + syntheticStix.created = timestamp; + syntheticStix.modified = timestamp; + + // ============================================================================= + // SPECIAL HANDLING FOR x_mitre_platforms AND x_mitre_contributors + // ============================================================================= + // + // The synthetic generator (createSyntheticStixObject) does NOT populate these + // two fields, which causes a problem due to how Mongoose serializes documents. + // + // THE ROOT CAUSE (Mongoose Schema Behavior): + // ------------------------------------------- + // In app/models/subschemas/attack-pattern.js, these fields are defined as: + // x_mitre_platforms: [String] + // x_mitre_contributors: [String] + // + // When a field is defined this way in Mongoose (without `default: undefined`), + // Mongoose will: + // 1. Initialize the field as an empty array [] when the document is created + // (even if not provided in the request) + // 2. Serialize the field as an empty array [] when returning the document + // + // This causes a problem in our tests: + // - POST request WITHOUT these fields → Mongoose stores them as [] + // - POST response → Server returns { x_mitre_platforms: [], x_mitre_contributors: [] } + // - PUT request spreads the response → Sends empty arrays back to server + // - ADM validation FAILS because the schemas require: + // x_mitre_platforms: z.array(...).min(1, 'At least one platform is required').optional() + // x_mitre_contributors: z.array(...).nonempty().optional() + // - Empty arrays [] violate the .min(1) and .nonempty() constraints + // + // ADM SCHEMA VALIDATION RULES (Conditionally Required Fields): + // ------------------------------------------------------------- + // These fields are "conditionally required" - optional to include, but IF + // included must meet constraints: + // - If omitted entirely (key not present): ✓ VALID (field is optional) + // - If present with empty array []: ✗ INVALID (violates .min(1) / .nonempty()) + // - If present with valid items: ✓ VALID + // + // WHY WE POPULATE THEM HERE: + // -------------------------- + // By populating these fields with valid values BEFORE the initial POST request: + // 1. POST request includes valid arrays: ['Windows'], ['Test Contributor'] + // 2. Mongoose stores them with valid data (not empty arrays) + // 3. POST/GET responses return valid arrays + // 4. PUT requests spread valid arrays (not empty ones) + // 5. Validation passes throughout the entire POST → GET → PUT cycle + // + // FUTURE FIX (Recommended for separate PR): + // ------------------------------------------ + // The proper architectural fix is to update all Mongoose schemas to use: + // x_mitre_platforms: { type: [String], default: undefined } + // x_mitre_contributors: { type: [String], default: undefined } + // + // This would prevent Mongoose from initializing/serializing these fields when + // not provided, matching user expectations and avoiding unexpected behavior. + // + // NOTE: This issue affects other array fields in the schemas (external_references, + // object_marking_refs, and various workspace fields) and should be addressed + // comprehensively in a future PR to avoid scope creep. + // ============================================================================= + + if (!syntheticStix.x_mitre_platforms || syntheticStix.x_mitre_platforms.length === 0) { + syntheticStix.x_mitre_platforms = ['Windows']; + } + if (!syntheticStix.x_mitre_contributors || syntheticStix.x_mitre_contributors.length === 0) { + syntheticStix.x_mitre_contributors = ['Test Contributor']; + } + + return syntheticStix; + } + + before(async function () { + // Enable ADM validation and disable OpenAPI validation + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = false; + + // Establish the database connection + await database.initializeConnection(); + + // Check for a valid database configuration + await databaseConfiguration.checkSystemConfiguration(); + + // Initialize the express app + app = await require('../../index').initializeApp(); + + // Log into the app + passportCookie = await login.loginAnonymous(app); + }); + + after(async function () { + // Restore default config values + config.validateRequests.withAttackDataModel = false; + config.validateRequests.withOpenApi = true; + }); + + describe('POST operations - work-in-progress (partial validation)', function () { + it('should accept valid complete data in work-in-progress state', async function () { + const syntheticStix = createSyntheticStix(stixType); + + let requestBody = { + type: 'attack-pattern', + status: 'work-in-progress', + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(201); + expect(res.body).toBeDefined(); + expect(res.body.stix).toBeDefined(); + expect(res.body.stix.type).toBe(stixType); + }); + + it('should accept partial data in work-in-progress state (missing optional fields)', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Remove optional fields to test partial validation + delete syntheticStix.description; + delete syntheticStix.x_mitre_platforms; + delete syntheticStix.x_mitre_data_sources; + + let requestBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + // .expect('Content-Type', /json/); + + // Should succeed because work-in-progress uses partial validation + expect(res.status).toBe(201); + expect(res.body.stix.type).toBe(stixType); + }); + + it('should reject data with invalid field values in work-in-progress state', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Make a field invalid + syntheticStix.x_mitre_is_subtechnique = 'not-a-boolean'; // Should be boolean + + let requestBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should fail ADM validation in the service layer + expect(res.status).toBe(400); + expect(res.body.message).toBeDefined(); + expect(res.body.details).toBeDefined(); + expect(Array.isArray(res.body.details)).toBe(true); + }); + }); + + describe('POST operations - reviewed (full validation)', function () { + it('should accept valid complete data in reviewed state', async function () { + const syntheticStix = createSyntheticStix(stixType); + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(201); + expect(res.body).toBeDefined(); + expect(res.body.stix).toBeDefined(); + expect(res.body.stix.type).toBe(stixType); + }); + + it('should reject data missing required fields in reviewed state', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Remove a required field + delete syntheticStix.name; + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should fail validation because 'name' is required in full validation + expect(res.status).toBe(400); + expect(res.body.message).toBeDefined(); + expect(res.body.details).toBeDefined(); + expect(Array.isArray(res.body.details)).toBe(true); + }); + + it('should reject data with invalid field values in reviewed state', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Make a field invalid (wrong type for boolean field) + syntheticStix.x_mitre_is_subtechnique = 'not-a-boolean'; + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should fail ADM validation + expect(res.status).toBe(400); + expect(res.body.message).toBe('ADM validation failed'); + }); + + it('should reject data with wrong STIX type for endpoint', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Set wrong STIX type — caught by service before ADM validation + syntheticStix.type = 'invalid-type'; + + const requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should fail with InvalidTypeError (plain string response) + expect(res.status).toBe(400); + }); + }); + + describe('PUT operations - work-in-progress (partial validation)', function () { + let createdObject; + + beforeEach(async function () { + // Create an object to update + const syntheticStix = createSyntheticStix(stixType); + + let createBody = { + type: 'attack-pattern', + status: 'work-in-progress', + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + createBody = cloneForCreate(createBody); + + const createRes = await request(app) + .post(endpoint) + .send(createBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + createdObject = createRes.body; + }); + + it('should accept valid updates in work-in-progress state', async function () { + let updateBody = { + type: 'attack-pattern', + status: 'work-in-progress', + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + ...createdObject.stix, + name: 'Updated Technique Name', + description: 'Updated description', + }, + }; + + updateBody = cloneForCreate(updateBody); + + // Remove server-managed field (server adds this automatically) + delete updateBody.stix.x_mitre_attack_spec_version; + // Note: We keep id, created, modified because ADM schemas validate the full STIX structure + + const res = await request(app) + .put(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + if (res.status !== 200) { + logger.debug('=== REQUEST FAILED ==='); + logger.debug('Status:', res.status); + logger.debug('Errors:', JSON.stringify(res.body, null, 2)); + } + + expect(res.status).toBe(200); + expect(res.body.stix.name).toBe('Updated Technique Name'); + }); + + it('should accept updates with missing optional fields in work-in-progress state', async function () { + let updateBody = { + type: 'attack-pattern', + status: 'work-in-progress', + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + ...createdObject.stix, + name: 'Updated Name', + }, + }; + + updateBody = cloneForCreate(updateBody); + + // Remove optional fields to test partial validation + delete updateBody.stix.description; + delete updateBody.stix.x_mitre_platforms; + + // Remove server-managed field + delete updateBody.stix.x_mitre_attack_spec_version; + // Note: We keep id, created, modified because ADM schemas validate the full STIX structure + + const res = await request(app) + .put(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(200); + }); + + it('should reject updates with invalid field values in work-in-progress state', async function () { + const updateBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + ...createdObject.stix, + description: true, // <-- should trigger validation error (should be string) + }, + }; + + // Remove server-managed field + delete updateBody.stix.x_mitre_attack_spec_version; + // Note: We keep id, created, modified because ADM schemas validate the full STIX structure + + const res = await request(app) + .put(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(400); + expect(res.body.message).toBeDefined(); + }); + }); + + describe('PUT operations - reviewed (full validation)', function () { + let createdObject; + + beforeEach(async function () { + // Create an object to update + const syntheticStix = createSyntheticStix(stixType); + + let createBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + createBody = cloneForCreate(createBody); + + const createRes = await request(app) + .post(endpoint) + .send(createBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + createdObject = createRes.body; + }); + + it('should accept valid complete updates in reviewed state', async function () { + let updateBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: { + ...createdObject.stix, + name: 'Reviewed Technique Name', + }, + }; + + updateBody = cloneForCreate(updateBody); + + // Remove server-managed field + delete updateBody.stix.x_mitre_attack_spec_version; + // Note: We keep id, created, modified because ADM schemas validate the full STIX structure + + const res = await request(app) + .put(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(200); + expect(res.body.stix.name).toBe('Reviewed Technique Name'); + }); + + it('should reject updates missing required fields in reviewed state', async function () { + const updateBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: { + ...createdObject.stix, + }, + }; + + // Remove required field + delete updateBody.stix.name; + // Remove server-managed field + delete updateBody.stix.x_mitre_attack_spec_version; + // Note: We keep id, created, modified because ADM schemas validate the full STIX structure + + const res = await request(app) + .put(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(400); + expect(res.body.message).toBeDefined(); + }); + }); + + describe('Validation toggle', function () { + it('should skip validation when ADM validation is disabled', async function () { + // Temporarily disable ADM validation + config.validateRequests.withAttackDataModel = false; + + const syntheticStix = createSyntheticStix(stixType); + + // Remove a required field - this would normally fail validation + delete syntheticStix.name; + + const requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); + + // Should NOT return 400 with "ADM validation failed" error because ADM validation is disabled + // The request will likely fail at the database level (missing required field), + // but it should NOT fail with ADM validation error + if (res.status === 400 && res.headers['content-type']?.includes('json')) { + expect(res.body.message).not.toBe('ADM validation failed'); + } + + // Re-enable ADM validation + config.validateRequests.withAttackDataModel = true; + }); + + it('should enforce validation when ADM validation is enabled', async function () { + // Ensure ADM validation is enabled + config.validateRequests.withAttackDataModel = true; + + const syntheticStix = createSyntheticStix(stixType); + + // Remove required field + delete syntheticStix.name; + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should return 400 with validation error + expect(res.status).toBe(400); + expect(res.body.message).toBe('ADM validation failed'); + expect(res.body.details).toBeDefined(); + }); + }); + + describe('Server-controlled field stripping', function () { + it('should silently strip x_mitre_attack_spec_version from client input', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Explicitly set a server-controlled field — should be silently stripped + syntheticStix.x_mitre_attack_spec_version = '999.0'; + + let requestBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should succeed — the server strips the field and sets the correct value + expect(res.status).toBe(201); + expect(res.body.stix.x_mitre_attack_spec_version).toBeDefined(); + expect(res.body.stix.x_mitre_attack_spec_version).not.toBe('999.0'); + }); + + it('should silently strip ATT&CK external references from client input', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Add a client-provided ATT&CK ref and a user ref + syntheticStix.external_references = [ + { source_name: 'mitre-attack', external_id: 'T9999', url: 'https://fake.url' }, + { source_name: 'my-source', description: 'User reference' }, + ]; + + let requestBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should succeed — server strips the ATT&CK ref and generates the correct one + expect(res.status).toBe(201); + // The server-generated ATT&CK ref should be at index 0 + expect(res.body.stix.external_references[0].source_name).toBe('mitre-attack'); + // The client's fake ATT&CK ref URL should NOT be present + expect(res.body.stix.external_references[0].url).not.toBe('https://fake.url'); + // The user's custom ref should still be present + const userRef = res.body.stix.external_references.find( + (ref) => ref.source_name === 'my-source', + ); + expect(userRef).toBeDefined(); + }); + }); + + describe('dryRun support', function () { + it('should return composed object without persisting on POST with dryRun=true', async function () { + const syntheticStix = createSyntheticStix(stixType); + + let requestBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(`${endpoint}?dryRun=true`) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(200); + expect(res.body.stix).toBeDefined(); + expect(res.body.stix.type).toBe(stixType); + // Server-controlled fields should be composed + expect(res.body.stix.x_mitre_attack_spec_version).toBeDefined(); + // Mongoose internals should not be exposed + expect(res.body._id).toBeUndefined(); + expect(res.body.__v).toBeUndefined(); + expect(res.body.__t).toBeUndefined(); + }); + + it('should return validation error on POST with dryRun=true and invalid data', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Make data invalid — wrong type for a boolean field + syntheticStix.x_mitre_is_subtechnique = 'not-a-boolean'; + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(`${endpoint}?dryRun=true`) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + // Should fail ADM validation even in dry-run mode + expect(res.status).toBe(400); + expect(res.body.message).toBe('ADM validation failed'); + }); + + it('should return composed object without persisting on PUT with dryRun=true', async function () { + // First, create an object to update + const syntheticStix = createSyntheticStix(stixType); + + let createBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: syntheticStix, + }; + + createBody = cloneForCreate(createBody); + + const createRes = await request(app) + .post(endpoint) + .send(createBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + + const createdObject = createRes.body; + + // Now do a dry-run update + let updateBody = { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + ...createdObject.stix, + name: 'Dry Run Updated Name', + }, + }; + + updateBody = cloneForCreate(updateBody); + + const res = await request(app) + .put( + `${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}?dryRun=true`, + ) + .send(updateBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(200); + expect(res.body.stix).toBeDefined(); + expect(res.body.stix.name).toBe('Dry Run Updated Name'); + // Mongoose internals should not be exposed + expect(res.body._id).toBeUndefined(); + expect(res.body.__v).toBeUndefined(); + expect(res.body.__t).toBeUndefined(); + + // Verify the object was NOT actually persisted by fetching the original + const getRes = await request(app) + .get(`${endpoint}/${createdObject.stix.id}/modified/${createdObject.stix.modified}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(getRes.status).toBe(200); + // Original name should be unchanged + expect(getRes.body.stix.name).not.toBe('Dry Run Updated Name'); + }); + }); + + describe('Error response format', function () { + it('should return detailed validation errors with proper structure', async function () { + const syntheticStix = createSyntheticStix(stixType); + + // Create multiple validation errors + delete syntheticStix.name; // Missing required field + syntheticStix.x_mitre_is_subtechnique = 'invalid'; // Wrong type + + let requestBody = { + workspace: { + workflow: { + state: 'reviewed', + }, + }, + stix: syntheticStix, + }; + + requestBody = cloneForCreate(requestBody); + + const res = await request(app) + .post(endpoint) + .send(requestBody) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); + + expect(res.status).toBe(400); + expect(res.body.message).toBe('ADM validation failed'); + expect(res.body.details).toBeDefined(); + expect(Array.isArray(res.body.details)).toBe(true); + expect(res.body.details.length).toBeGreaterThan(0); + + // Verify each error has expected structure + res.body.details.forEach((detail) => { + expect(detail).toHaveProperty('message'); + expect(detail).toHaveProperty('path'); + }); + }); + }); +}); diff --git a/app/tests/middleware/error-handler.spec.js b/app/tests/middleware/error-handler.spec.js new file mode 100644 index 00000000..f84f85ae --- /dev/null +++ b/app/tests/middleware/error-handler.spec.js @@ -0,0 +1,97 @@ +'use strict'; + +const { expect } = require('expect'); +const sinon = require('sinon'); + +const logger = require('../../lib/logger'); +const errorHandler = require('../../lib/error-handler'); +const { DatabaseError, DuplicateIdError, InvalidPostOperationError } = require('../../exceptions'); + +describe('error-handler middleware', function () { + beforeEach(function () { + sinon.stub(logger, 'warn'); + sinon.stub(logger, 'error'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should not serialize string characters from InvalidPostOperationError', function () { + const err = new InvalidPostOperationError( + 'Subtechniques require a parentTechniqueId query parameter. Provide the parent technique ATT&CK ID (e.g., T1234).', + ); + const res = { + status: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + }; + const next = sinon.stub(); + + errorHandler.serviceExceptions(err, {}, res, next); + + expect(Object.keys(err).filter((key) => /^\d+$/.test(key))).toEqual([]); + expect(res.status.calledOnceWithExactly(400)).toBe(true); + expect(res.send.calledOnceWithExactly(err.message)).toBe(true); + expect(next.called).toBe(false); + }); + + it('should preserve structured error properties for InvalidPostOperationError', function () { + const err = new InvalidPostOperationError({ + details: ['created', 'modified'], + }); + const res = { + status: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + }; + const next = sinon.stub(); + + errorHandler.serviceExceptions(err, {}, res, next); + + expect(res.status.calledOnceWithExactly(400)).toBe(true); + expect( + res.send.calledOnceWithExactly({ + message: 'Cannot set the following keys:', + details: ['created', 'modified'], + }), + ).toBe(true); + expect(next.called).toBe(false); + }); + + it('should preserve custom messages for DuplicateIdError', function () { + const err = new DuplicateIdError('ATT&CK ID T1234 is already in use'); + const res = { + status: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + }; + const next = sinon.stub(); + + errorHandler.serviceExceptions(err, {}, res, next); + + expect(res.status.calledOnceWithExactly(409)).toBe(true); + expect(res.send.calledOnceWithExactly('ATT&CK ID T1234 is already in use')).toBe(true); + expect(next.called).toBe(false); + }); + + it('should preserve wrapped error details for DatabaseError', function () { + const err = new DatabaseError(new Error('Mongo connection failed')); + const res = { + status: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + }; + const next = sinon.stub(); + + errorHandler.serviceExceptions(err, {}, res, next); + + expect(res.status.calledOnceWithExactly(500)).toBe(true); + expect( + res.send.calledOnceWithExactly({ + message: 'The database operation failed.', + details: 'Mongo connection failed', + }), + ).toBe(true); + expect(err.cause).toBeInstanceOf(Error); + expect(err.cause.message).toBe('Mongo connection failed'); + expect(Object.keys(err)).not.toContain('cause'); + expect(next.called).toBe(false); + }); +}); diff --git a/app/tests/scheduler/scheduler.spec.js b/app/tests/scheduler/scheduler.spec.js index b0df0919..be90003b 100644 --- a/app/tests/scheduler/scheduler.spec.js +++ b/app/tests/scheduler/scheduler.spec.js @@ -528,7 +528,7 @@ describe('Scheduler', function () { .post('/api/collection-indexes') .send(body) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`); + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`); }); it('Scheduled job runs when initiated manually', async function () { diff --git a/app/tests/shared/clone-for-create.js b/app/tests/shared/clone-for-create.js new file mode 100644 index 00000000..ffc67f81 --- /dev/null +++ b/app/tests/shared/clone-for-create.js @@ -0,0 +1,63 @@ +'use strict'; + +const _ = require('lodash'); +const config = require('../../config/config'); + +/** + * Creates a deep clone of an ATT&CK object suitable for POST requests. + * + * This utility strips all backend-controlled and database-specific fields that would + * cause ImmutablePropertyError or other validation errors when creating new versions + * of existing objects via POST requests. + * + * Fields removed: + * - workspace.attack_id: Backend generates ATT&CK IDs; clients cannot set them + * - MITRE ATT&CK external references: Backend controls official ATT&CK citations + * - MongoDB fields: _id, __t, __v (database-specific fields) + * + * @param {Object} attackObject - The ATT&CK object to clone + * @returns {Object} A deep clone suitable for POST requests + * + * @example + * // Clone an existing technique to create a new version + * const technique2 = cloneForCreate(technique1); + * technique2.stix.modified = new Date().toISOString(); + * technique2.stix.description = 'Updated description'; + * // Now safe to POST technique2 + */ +function cloneForCreate(attackObject) { + // Perform deep clone + const cloned = _.cloneDeep(attackObject); + + // Remove MongoDB-specific fields + delete cloned._id; + delete cloned.__t; + delete cloned.__v; + + // Remove backend-controlled ATT&CK ID from workspace + if (cloned.workspace && cloned.workspace.attack_id) { + delete cloned.workspace.attack_id; + } + + // Remove server-controlled x_mitre_attack_spec_version (backend sets this) + if (cloned.stix) { + delete cloned.stix.x_mitre_attack_spec_version; + } + + // Remove MITRE ATT&CK external references (backend controls these) + if (cloned.stix && cloned.stix.external_references) { + cloned.stix.external_references = cloned.stix.external_references.filter( + (ref) => !config.attackSourceNames.includes(ref.source_name), + ); + // If the list is now empty, remove the field + if (cloned.stix.external_references.length === 0) { + delete cloned.stix.external_references; + } + } + + return cloned; +} + +module.exports = { + cloneForCreate, +}; diff --git a/app/tests/shared/login.js b/app/tests/shared/login.js index 3d5bd85b..b9032640 100644 --- a/app/tests/shared/login.js +++ b/app/tests/shared/login.js @@ -3,9 +3,6 @@ const request = require('supertest'); const setCookieParser = require('set-cookie-parser'); -const passportCookieName = 'connect.sid'; -exports.passportCookieName = passportCookieName; - exports.loginAnonymous = async function (app) { const res = await request(app) .get('/api/authn/anonymous/login') @@ -14,7 +11,11 @@ exports.loginAnonymous = async function (app) { // Save the cookie for later tests const cookies = setCookieParser(res); - const passportCookie = cookies.find((c) => c.name === passportCookieName); + // The cookie name may be 'connect.sid' or 'connect.XXXXXXXX.sid' depending on hostname + // Look for any cookie that matches the pattern connect*.sid + const passportCookie = cookies.find( + (c) => c.name.startsWith('connect.') && c.name.endsWith('.sid'), + ); return passportCookie; }; diff --git a/app/tests/shared/pagination.js b/app/tests/shared/pagination.js index 62029948..be3b28de 100644 --- a/app/tests/shared/pagination.js +++ b/app/tests/shared/pagination.js @@ -2,6 +2,7 @@ const request = require('supertest'); const { expect } = require('expect'); const _ = require('lodash'); +const config = require('../../config/config'); const logger = require('../../lib/logger'); logger.level = 'debug'; @@ -19,6 +20,9 @@ function PaginationTests(service, initialObjectAData, options) { prefix: options.prefix ?? 'test-object', baseUrl: options.baseUrl, label: options.label ?? 'TestObjects', + // Optional: pin ADM request validation for this suite. Left undefined, the + // suite inherits whatever the shared config singleton currently holds. + validateWithAdm: options.validateWithAdm, }; this.options.stateQuery = options.state ? `&state=${options.state}` : ''; @@ -51,6 +55,13 @@ PaginationTests.prototype.executeTests = function () { let passportCookie; before(async function () { + // Pin ADM validation state (when the caller specified one) so the suite + // does not depend on whichever spec ran last leaving the shared config + // singleton in a particular state. + if (self.options.validateWithAdm !== undefined) { + config.validateRequests.withAttackDataModel = self.options.validateWithAdm; + } + // Establish the database connection // Use an in-memory database that we spin up for the test await database.initializeConnection(); @@ -69,7 +80,7 @@ PaginationTests.prototype.executeTests = function () { const res = await request(app) .get(`${self.options.baseUrl}?offset=0&limit=10${self.options.stateQuery}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -84,7 +95,7 @@ PaginationTests.prototype.executeTests = function () { const res = await request(app) .get(`${self.options.baseUrl}?offset=10&limit=10${self.options.stateQuery}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -101,7 +112,7 @@ PaginationTests.prototype.executeTests = function () { `${self.options.baseUrl}?offset=0&limit=10&includePagination=true${self.options.stateQuery}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -125,7 +136,7 @@ PaginationTests.prototype.executeTests = function () { `${self.options.baseUrl}?offset=10&limit=10&includePagination=true${self.options.stateQuery}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -148,7 +159,7 @@ PaginationTests.prototype.executeTests = function () { const res = await request(app) .get(`${self.options.baseUrl}?offset=0${self.options.stateQuery}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/) .send(); @@ -169,7 +180,7 @@ PaginationTests.prototype.executeTests = function () { `${self.options.baseUrl}?offset=${offset}&limit=${pageSize}${self.options.stateQuery}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -186,7 +197,7 @@ PaginationTests.prototype.executeTests = function () { `${self.options.baseUrl}?offset=${offset}&limit=${pageSize}&includePagination=true${self.options.stateQuery}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -209,7 +220,7 @@ PaginationTests.prototype.executeTests = function () { const res = await request(app) .get(`${self.options.baseUrl}?offset=40&limit=20${self.options.stateQuery}`) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); @@ -226,7 +237,7 @@ PaginationTests.prototype.executeTests = function () { `${self.options.baseUrl}?offset=40&limit=20&includePagination=true${self.options.stateQuery}`, ) .set('Accept', 'application/json') - .set('Cookie', `${login.passportCookieName}=${passportCookie.value}`) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) .expect(200) .expect('Content-Type', /json/); diff --git a/bin/www b/bin/www index c90edb78..f09e1448 100644 --- a/bin/www +++ b/bin/www @@ -43,6 +43,17 @@ async function runServer() { throw new Error(errors.databaseMigrationFailed); } + // Ensure MongoDB views are created / up to date (idempotent) + const { createMongoViews } = require('../app/lib/create-mongo-views'); + try { + await createMongoViews(); + } catch (err) { + logger.error('Failed to create/update MongoDB views'); + logger.error(err.message); + // Non-fatal: views enhance query performance but the app can still function + logger.warn('Continuing startup despite view creation failure'); + } + // Check for valid database configuration const databaseConfiguration = require('../app/lib/database-configuration'); await databaseConfiguration.checkSystemConfiguration(); @@ -51,8 +62,8 @@ async function runServer() { const app = await require('../app').initializeApp(); // Create the scheduler - const scheduler = require('../app/scheduler/scheduler'); - scheduler.initializeScheduler(); + const taskScheduler = require('../app/scheduler'); + taskScheduler.initializeScheduler(); // Start the server logger.info('Starting the HTTP server...'); @@ -76,13 +87,15 @@ async function runServer() { // Listen for a ctrl-c process.on('SIGINT', () => { logger.info('SIGINT received, stopping HTTP server'); - server.close(); + taskScheduler.gracefulShutdown() + .then(() => server.close()); }); // Docker terminates a container with a SIGTERM process.on('SIGTERM', () => { logger.info('SIGTERM received, stopping HTTP server'); - server.close(); + taskScheduler.gracefulShutdown() + .then(() => server.close()); }); process.on('uncaughtException', (err, origin) => { @@ -91,8 +104,8 @@ async function runServer() { logger.error(err.stack); logger.error('Terminating app after uncaught exception'); - - process.exit(1); + taskScheduler.gracefulShutdown() + .then(() => process.exit(1)); }); // Wait for the server to close diff --git a/docs/README.md b/docs/README.md index 9b150ce7..c952dcba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,20 +5,66 @@ This directory contains supplementary technical documentation for the ATT&CK Wor - [USAGE.md](../USAGE.md): Comprehensive usage instructions - [CONTRIBUTING.md](../CONTRIBUTING.md): Guide for developers -## Technical Reference Documentation +## User Documentation -The following documents provide detailed technical information about specific aspects of the REST API: +Guides for consumers of the REST API — endpoints, workflows, and terminology. -- [Data Model](data-model.md): Detailed explanation of the database schema and STIX object structure +- [Revoke Workflow](user/revoke-workflow.md): How to revoke ATT&CK objects via the API -## Legacy Documentation +### Release Tracks + +- [API Reference](user/release-tracks/api-reference.md): Complete endpoint reference for Release Tracks V2 +- [Summary](user/release-tracks/summary.md): High-level design summary and problem statement +- [Terminology](user/release-tracks/terminology.md): Complete terminology guide +- [Versioning](user/release-tracks/versioning.md): Git-inspired versioning and release process +- [Virtual Tracks](user/release-tracks/virtual-tracks.md): Virtual release tracks (aggregations) +- [Release Workflow](user/release-tracks/release-workflow.md): Workflow integration and candidacy +- [Output Formats](user/release-tracks/output-formats.md): Output format specifications +- [Workflow Examples](user/release-tracks/workflow-examples.md): End-to-end workflow examples + +## Developer Documentation + +Architecture, patterns, and implementation details for contributors. + +- [Data Model](developer/data-model.md): Database schema and STIX object structure +- [Event Bus Architecture](developer/event-bus-architecture.md): Event-driven architecture for cross-document dependencies +- [Lifecycle Hooks Guide](developer/lifecycle-hooks-guide.md): Service lifecycle hooks pattern +- [Cross-Service Reads Pattern](developer/cross-service-reads-pattern.md): Cross-service communication patterns +- [Implementation Approach](developer/implementation-approach.md): Detailed implementation pattern for event-driven services +- [Service Exception Middleware](developer/service-exception-middleware.md): Global error handler middleware +- [STIX Versioning and Embedded Relationships](developer/stix-versioning-and-embedded-relationships.md): How STIX versioning interacts with embedded relationships +- [Task Scheduler](developer/task-scheduler.md): Task scheduler implementation +- [Automation Run Audit Trail](developer/automation-runs.md): Taxonomy and implementation guidance for durable automation auditing + +### Release Tracks (Internals) + +- [Entities](developer/release-tracks/entities.md): Database schemas and data models +- [Member Sync Strategies](developer/release-tracks/member-sync-strategies.md): Automatic tracking of member object revisions +- [Error Handling](developer/release-tracks/error-handling.md): Error handling patterns +- [Implementation Notes](developer/release-tracks/implementation-notes.md): Implementation notes and decisions -The following documents contain additional information that may be useful for specific scenarios but are not part of the primary documentation: +## Admin Documentation + +Configuration, deployment, and identity provider setup. + +- [Configuration](admin/configuration.md): Complete configuration guide (environment variables, JSON files) +- [Automation Run Audit Trail](admin/automation-runs.md): How to inspect migration and scheduler audit records + +### Authentication + +- [Authentication Overview](admin/authentication/README.md): Introduction and quick start guide +- [Authentication Configuration](admin/authentication/configuration.md): REST API authentication configuration +- [Authentik Setup](admin/authentication/authentik.md): Step-by-step guide for Authentik +- [Keycloak Setup](admin/authentication/keycloak.md): Step-by-step guide for Keycloak +- [Okta Setup](admin/authentication/okta.md): Step-by-step guide for Okta +- [Testing & Verification](admin/authentication/testing-verification.md): Verify authentication is working + +## Legacy Documentation - [Authentication Details](legacy/authentication.md): Technical details about authentication mechanisms - [User Management](legacy/user-management.md): Detailed information about user accounts and permissions - [Docker Deployment](legacy/docker.md): Legacy instructions for Docker deployment -- [Link-by-ID Mechanism](legacy/link-by-id.md): Technical details about object linking in the data model +- [Link-by-ID Mechanism](legacy/link-by-id.md): Technical details about object linking ## API Documentation @@ -28,4 +74,4 @@ Interactive API documentation is available when running the application in devel - [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 +- [Issue Tracker](https://github.com/center-for-threat-informed-defense/attack-workbench-rest-api/issues) diff --git a/docs/admin/authentication/README.md b/docs/admin/authentication/README.md new file mode 100644 index 00000000..67bfbc25 --- /dev/null +++ b/docs/admin/authentication/README.md @@ -0,0 +1,124 @@ +# Authentication Configuration Guide + +This directory contains comprehensive guides for configuring user authentication with the ATT&CK Workbench REST API using OpenID Connect (OIDC). + +## Overview + +The ATT&CK Workbench REST API supports two user authentication mechanisms: + +- **Anonymous**: No authentication required (default, suitable for local development) +- **OIDC**: OpenID Connect authentication with an external identity provider (recommended for production) + +This documentation focuses on OIDC configuration with popular identity providers. + +## Supported Identity Providers + +The REST API is compatible with any OIDC-compliant identity provider. We provide detailed setup guides for: + +- [**Authentik**](authentik.md) - Open-source identity provider +- [**Keycloak**](keycloak.md) - Open-source identity and access management +- [**Okta**](okta.md) - Enterprise identity and access management service + +## Quick Start + +### Basic OIDC Configuration + +All OIDC providers require the following environment variables in your `.env` file: + +```bash +# Enable OIDC authentication +AUTHN_MECHANISM=oidc + +# OIDC provider settings +AUTHN_OIDC_ISSUER_URL= +AUTHN_OIDC_CLIENT_ID= +AUTHN_OIDC_CLIENT_SECRET= +AUTHN_OIDC_REDIRECT_ORIGIN= +``` + +### Required OIDC Scopes + +The REST API requires these OIDC scopes: + +- `openid` (required) +- `email` (required) +- `profile` (required) + +### Required Claims + +The REST API expects these claims in the ID token: + +- `email` - User's email address (used as unique identifier) +- `preferred_username` - Username +- `name` - User's display name + +## Multiple Environments + +If you're running multiple instances (e.g., local development and production), each instance needs its own `AUTHN_OIDC_REDIRECT_ORIGIN` value, but can share the same Client ID and Secret. + +**Example:** + +- **Local**: `AUTHN_OIDC_REDIRECT_ORIGIN=http://localhost:3000` +- **Production**: `AUTHN_OIDC_REDIRECT_ORIGIN=https://workbench.example.com` + +In your OIDC provider, configure **all** redirect URIs: + +- `http://localhost:3000/api/authn/oidc/callback` +- `https://workbench.example.com/api/authn/oidc/callback` + +## Service Authentication + +For service-to-service communication, the REST API supports three methods: + +1. **API Key Challenge Authentication**: Services obtain a JWT using a challenge-response protocol +2. **API Key Basic Authentication**: Services authenticate using HTTP Basic Authentication +3. **OIDC Client Credentials Flow**: Services obtain a JWT from an OIDC provider + +## User Management + +After configuring OIDC, users who log in will be authenticated but will not have any permissions until you create a user account for them in the Workbench. + +### User Roles + +- `admin` - Full access to all features +- `editor` - Can create and edit objects +- `visitor` - Read-only access +- `team_lead` - Editor permissions with team management features + +### Creating User Accounts + +1. Have the user log in once (they will see "User not registered" message) +2. Create a user account via the REST API or frontend +3. Assign appropriate role +4. User logs in again and will have assigned permissions + +See [User Management](../../legacy/user-management.md) for detailed instructions. + +## Troubleshooting + +### Common Issues + +#### "Invalid redirect URI" error + +- Verify the redirect URI in your OIDC provider exactly matches: `/api/authn/oidc/callback` +- Check that the protocol (http/https) matches + +#### "Invalid issuer" error + +- Ensure `AUTHN_OIDC_ISSUER_URL` is correct and accessible from the REST API server +- Verify the issuer URL includes the correct path (some providers require specific paths) + +#### User authenticated but has no permissions + +- Create a user account in the Workbench database +- Verify the email in the user account matches the email claim from the OIDC provider + +#### Claims missing from token + +- Check that your OIDC provider is configured to include `email`, `preferred_username`, and `name` in the ID token +- Verify the scopes include `openid`, `email`, and `profile` + +## Additional Resources + +- [Authentication Technical Details](../../legacy/authentication.md) +- [User Management Guide](../../legacy/user-management.md) diff --git a/docs/admin/authentication/authentik.md b/docs/admin/authentication/authentik.md new file mode 100644 index 00000000..57958f6f --- /dev/null +++ b/docs/admin/authentication/authentik.md @@ -0,0 +1,177 @@ +# Authentik OIDC Configuration Guide + +> [!NOTE] +> **This guide is confirmed to be working as of November 10, 2025.** + +This guide provides step-by-step instructions for configuring Authentik as the OIDC identity provider for the ATT&CK Workbench REST API. + +## Prerequisites + +- Authentik server installed and accessible +- Administrator access to Authentik +- ATT&CK Workbench REST API installed + +## Overview + +This guide focuses on configuring Authentik as your OIDC provider. After completing Authentik setup: + +- Proceed to [REST API Configuration](./configuration.md) to configure the Workbench REST API +- Then follow [Testing & Verification](./testing-verification.md) to confirm everything works + +This guide covers only the Authentik-specific configuration steps. + +--- + +## Step 1: Create OAuth2/OpenID Provider + +1. **Log into Authentik** as an administrator + +2. **Navigate to Providers**: + - Go to **Applications** → **Providers** + - Click **Create** + +3. **Select Provider Type**: + - Choose **OAuth2/OpenID Provider** + +4. **Configure the Provider**: + - **Name**: `ATT&CK Workbench` (or your preferred name) + - **Authentication flow**: `default-authentication-flow` (or your custom flow) + - **Authorization flow**: `default-provider-authorization-explicit-consent` (recommended) or `default-provider-authorization-implicit-consent` + - **Client type**: `Confidential` (required) + - **Client ID**: Auto-generated (you'll need this later) + - **Client Secret**: Auto-generated (you'll need this later) + - **Redirect URIs/Origins (RegEx)**: Add your callback URL(s): + - For single environment: `https://workbench.example.com/api/authn/oidc/callback` + - For multiple environments, add each on a separate line: + + ```text + http://localhost:3000/api/authn/oidc/callback + https://workbench.example.com/api/authn/oidc/callback + ``` + + - **Signing Key**: `authentik Self-signed Certificate` (or your custom key) + +5. **Advanced Settings** (expand if needed): + - **Scopes**: Ensure these are included (usually default): + - `openid` + - `email` + - `profile` + - **Subject mode**: `Based on the User's hashed ID` (default is fine) + - **Include claims in id_token**: `true` (recommended) + +6. **Save** the provider + +7. **Note the credentials** (you'll need these for REST API configuration): + - Go back to the provider you just created + - Copy the **Client ID** + - Copy the **Client Secret** (click "Copy" button) + +## Step 2: Create Application + +1. **Navigate to Applications**: + - Go to **Applications** → **Applications** + - Click **Create** + +2. **Configure the Application**: + - **Name**: `ATT&CK Workbench` + - **Slug**: `attack-workbench` (or your preference) + - **Provider**: Select the provider you created in Step 1 + - **Policy engine mode**: `any` (or configure based on your needs) + - **UI settings** (optional): Add icon, description, launch URL + +3. **Save** the application + +## Step 3: Note the Issuer URL + +The issuer URL format for Authentik is: + +```text +https:///application/o// +``` + +For example: + +- If your Authentik is at: `https://authentik.example.com` +- And your application slug is: `attack-workbench` +- Then your issuer URL is: `https://authentik.example.com/application/o/attack-workbench/` + +**Note the trailing slash** - it's required! + +**Save this issuer URL** - you'll need it for REST API configuration. + +--- + +## Next Steps + +You've completed the Authentik configuration. Now proceed with: + +1. **[Configure the REST API](./configuration.md)** - Set up the Workbench REST API to use Authentik + + You'll need these values from the steps above: + - **Issuer URL**: `https:///application/o//` (from Step 3) + - **Client ID**: From Step 1 + - **Client Secret**: From Step 1 + +2. **[Test & Verify](./testing-verification.md)** - Confirm authentication is working correctly + +--- + +## Troubleshooting + +### Authentik Issuer URL Format + +**Issue**: Discovery fails with Authentik. + +**Solution**: Verify the issuer URL format is correct: + +```text +https:///application/o// +``` + +**Important notes:** + +- The trailing slash is **required** +- The application slug must match exactly (case-sensitive) +- Verify by accessing: `https:///.well-known/openid-configuration` + +### Authentik Scope Configuration + +**Issue**: Missing user information after authentication. + +**Solution**: In Authentik provider settings, ensure: + +- **Scopes** include: `openid`, `email`, `profile` +- "Include claims in id_token" is enabled in Advanced Settings +- Users have email addresses configured in Authentik + +## Advanced Configuration + +### Custom User Attributes + +Authentik supports custom user attributes. To use them with Workbench: + +1. Create a custom property mapping in Authentik +2. Add it to your provider's scope mappings +3. The claims will be available in the OIDC token + +### MFA / 2FA + +Authentik supports Multi-Factor Authentication: + +1. Configure MFA in Authentik authentication flow +2. No changes needed in Workbench REST API +3. Users will be prompted for MFA during Authentik login + +### Single Logout + +Currently, logging out of Workbench only logs the user out of the REST API session, not from Authentik. Users remain logged into Authentik and can re-authenticate without entering credentials. + +To implement full logout, you would need to: + +1. Redirect to Authentik's end session endpoint after logout +2. This requires custom frontend modifications + +## Additional Resources + +- [Authentik Documentation](https://docs.goauthentik.io/) +- [ATT&CK Workbench Authentication Documentation](./README.md) diff --git a/docs/admin/authentication/configuration.md b/docs/admin/authentication/configuration.md new file mode 100644 index 00000000..6d4fe778 --- /dev/null +++ b/docs/admin/authentication/configuration.md @@ -0,0 +1,186 @@ +# REST API OIDC Configuration + +This guide describes how to configure the ATT&CK Workbench REST API to use an OpenID Connect (OIDC) identity provider for authentication. + +## Prerequisites + +Before configuring the REST API, ensure you have: + +1. Completed the OIDC provider setup (Authentik, Okta, Keycloak, or other provider) +2. Obtained the following values from your identity provider: + - **Issuer URL**: The OIDC issuer/discovery endpoint + - **Client ID**: The application/client identifier + - **Client Secret**: The confidential client secret +3. Determined your **Redirect Origin**: The base URL where users access your Workbench instance + +## Configuration Steps + +### Step 1: Edit Environment Configuration + +Edit the `.env` file in your REST API installation directory and add the following OIDC settings: + +```bash +# Enable OIDC authentication +AUTHN_MECHANISM=oidc + +# OIDC Provider settings +AUTHN_OIDC_ISSUER_URL= +AUTHN_OIDC_CLIENT_ID= +AUTHN_OIDC_CLIENT_SECRET= +AUTHN_OIDC_REDIRECT_ORIGIN= +``` + +#### Configuration Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `AUTHN_MECHANISM` | Authentication method to use | `oidc` | +| `AUTHN_OIDC_ISSUER_URL` | OIDC discovery endpoint from your provider | `https://auth.example.com/realms/workbench` | +| `AUTHN_OIDC_CLIENT_ID` | Client ID from your OIDC application | `attack-workbench-rest-api` | +| `AUTHN_OIDC_CLIENT_SECRET` | Client secret from your OIDC application | `your-secret-value` | +| `AUTHN_OIDC_REDIRECT_ORIGIN` | Base URL where users access Workbench | `https://workbench.example.com` | + +**Important Notes:** + +- The callback URL is automatically constructed as: `{AUTHN_OIDC_REDIRECT_ORIGIN}/api/authn/oidc/callback` +- Ensure this callback URL matches exactly what you configured in your OIDC provider +- The issuer URL format varies by provider - see your provider's specific guide + +### Step 2: Multiple Environment Configuration + +When deploying to multiple environments (development, staging, production), each instance needs its own `.env` file with environment-specific values. + +**Local Development** (`.env`): + +```bash +AUTHN_MECHANISM=oidc +AUTHN_OIDC_ISSUER_URL=https://auth.example.com/realms/workbench +AUTHN_OIDC_CLIENT_ID=attack-workbench-rest-api +AUTHN_OIDC_CLIENT_SECRET=your-client-secret +AUTHN_OIDC_REDIRECT_ORIGIN=http://localhost:3000 +``` + +**Production** (`.env`): + +```bash +AUTHN_MECHANISM=oidc +AUTHN_OIDC_ISSUER_URL=https://auth.example.com/realms/workbench +AUTHN_OIDC_CLIENT_ID=attack-workbench-rest-api +AUTHN_OIDC_CLIENT_SECRET=your-client-secret +AUTHN_OIDC_REDIRECT_ORIGIN=https://workbench.example.com +``` + +**Best Practices:** + +- Use the same Client ID and Secret across environments (configure multiple redirect URIs in your provider) +- Use environment variables or secrets management for Client Secret in production +- Never commit the `.env` file to version control +- Keep a `.env.template` file with dummy values for documentation + +### Step 3: Restart the REST API + +After updating the configuration, restart the REST API to load the new settings: + +```bash +# If using Docker Compose +docker compose restart rest-api + +# If running directly with npm +npm start +``` + +### Step 4: Verify Configuration Load + +Check the REST API logs during startup to confirm the configuration was loaded successfully: + +```bash +# If using Docker Compose +docker compose logs rest-api + +# If running directly +# Check your console output or log files +``` + +Look for messages indicating: + +- OIDC authentication enabled +- Connection to the issuer successful +- Discovery endpoint loaded + +## Configuration File Alternative + +Instead of environment variables, you can use a JSON configuration file. This is useful for: + +- Managing multiple configuration sections in one place +- Version controlling your configuration (without secrets) +- Configuring complex structures like service accounts + +**Using JSON Configuration:** + +1. Create a `config.json` file: + + ```json + { + "userAuthn": { + "mechanism": "oidc", + "oidc": { + "issuerUrl": "https://auth.example.com/realms/workbench", + "clientId": "attack-workbench-rest-api", + "clientSecret": "your-client-secret", + "redirectOrigin": "https://workbench.example.com" + } + } + } + ``` + +2. Reference it via environment variable: + + ```bash + JSON_CONFIG_PATH=/path/to/config.json + ``` + +**Configuration Precedence:** + +When both environment variables and JSON configuration are used: + +1. Environment variables are loaded first +2. JSON configuration file (if specified) overrides environment variables + +For complete configuration documentation, including all available options and advanced scenarios, +see the [REST API Configuration Guide](../configuration.md). + +## Next Steps + +After configuring the REST API, proceed to [Testing & Verification](./testing-verification.md) to confirm your authentication setup is working correctly. + +## Troubleshooting + +### Configuration not loading + +**Symptoms**: REST API still shows anonymous authentication + +**Solutions**: + +1. Verify the `.env` file is in the correct directory (REST API root) +2. Check for typos in variable names (they are case-sensitive) +3. Ensure there are no spaces around the `=` sign +4. Restart the REST API after making changes + +### Invalid configuration values + +**Symptoms**: Errors during REST API startup + +**Solutions**: + +1. Verify the issuer URL is correct and accessible from the REST API server +2. Check that Client ID and Secret match your OIDC provider configuration +3. Ensure the redirect origin URL is correct (no trailing slash) + +## Additional Resources + +- [Authentication Overview](./README.md) +- [Testing & Verification Guide](./testing-verification.md) +- Provider-specific guides: + - [Authentik Configuration](./authentik.md) + - [Okta Configuration](./okta.md) + - [Keycloak Configuration](./keycloak.md) diff --git a/docs/admin/authentication/keycloak.md b/docs/admin/authentication/keycloak.md new file mode 100644 index 00000000..01abd77e --- /dev/null +++ b/docs/admin/authentication/keycloak.md @@ -0,0 +1,300 @@ +# Keycloak OIDC Configuration Guide + +> [!WARNING] +> This guide is in draft mode. Any feedback is appreciated! + +This guide provides step-by-step instructions for configuring Keycloak as the OIDC identity provider for the ATT&CK Workbench REST API. + +## Prerequisites + +- Keycloak server installed and accessible +- Administrator access to Keycloak Admin Console +- ATT&CK Workbench REST API installed + +## Overview + +This guide focuses on configuring Keycloak as your OIDC provider. After completing Keycloak setup: + +- Proceed to [REST API Configuration](./configuration.md) to configure the Workbench REST API +- Then follow [Testing & Verification](./testing-verification.md) to confirm everything works + +This guide covers only the Keycloak-specific configuration steps. + +--- + +## Step 1: Create a Realm (Optional) + +You can use an existing realm or create a new one for the Workbench. + +1. **Log into Keycloak Admin Console** as an administrator + +2. **Create a new realm** (or skip to use an existing one): + - Hover over the realm name in the top-left corner + - Click **Add Realm** (or **Create Realm** in newer versions) + - **Name**: `workbench-realm` (or your preferred name) + - Click **Create** + +## Step 2: Create OIDC Client + +1. **Navigate to Clients**: + - In your realm, go to **Clients** + - Click **Create** (or **Create client**) + +2. **General Settings**: + - **Client type**: `OpenID Connect` + - **Client ID**: `attack-workbench-rest-api` (or your preferred ID) + - **Name**: `ATT&CK Workbench REST API` (optional, for display) + - **Description**: `OIDC client for ATT&CK Workbench` (optional) + - Click **Next** or **Save** + +3. **Capability Config** (if prompted): + - **Client authentication**: `ON` (required - this makes it a confidential client) + - **Authorization**: `OFF` + - **Authentication flow**: + - ✓ **Standard flow** (required - this enables the authorization code flow) + - ✗ Direct access grants (not needed) + - ✗ Implicit flow (not recommended) + - Click **Next** or **Save** + +4. **Settings Tab**: + - **Client authentication**: `ON` + - **Root URL**: Leave empty or set to your Workbench URL + - **Valid Redirect URIs**: Add your callback URL(s): + - For single environment: `https://workbench.example.com/api/authn/oidc/callback` + - For multiple environments, add each separately: + + ```text + http://localhost:3000/api/authn/oidc/callback + https://workbench.example.com/api/authn/oidc/callback + ``` + + - **Valid post logout redirect URIs**: `+` (allows any valid redirect URI) + - **Web origins**: `+` (allows CORS for valid redirect URIs) or specify explicitly: + + ```text + http://localhost:3000 + https://workbench.example.com + ``` + + - Click **Save** + +5. **Get Client Credentials**: + - Go to the **Credentials** tab + - Copy the **Client Secret** + - Note: The Client ID is what you set in step 2 + +## Step 3: Configure Client Scopes + +The default scopes should work, but verify they're configured: + +1. **Navigate to Client Scopes** (in your realm) + +2. **Verify these scopes exist**: + - `openid` (required) + - `email` (required) + - `profile` (required) + +3. **Check the client's scopes**: + - Go back to your client + - Go to **Client Scopes** tab + - Verify `email` and `profile` are in **Assigned Default Client Scopes** + - `openid` is automatically included + +## Step 4: Create Users + +1. **Navigate to Users**: + - In your realm, go to **Users** + - Click **Add user** (or **Create new user**) + +2. **User Details**: + - **Username**: `admin@example.com` (or your preferred username) + - **Email**: `admin@example.com` (required) + - **Email verified**: `ON` (recommended) + - **First name**: `Admin` + - **Last name**: `User` + - Click **Create** + +3. **Set Password**: + - Go to the **Credentials** tab + - Click **Set password** + - Enter a password + - **Temporary**: `OFF` (if you don't want to force password reset) + - Click **Save** + +4. **Repeat** for additional users (editor, visitor, etc.) + +## Step 5: Get Issuer URL + +The issuer URL format for Keycloak is: + +```text +https:///realms/ +``` + +For example: + +- If your Keycloak is at: `https://keycloak.example.com` +- And your realm is: `workbench-realm` +- Then your issuer URL is: `https://keycloak.example.com/realms/workbench-realm` + +You can verify this by navigating to: + +```text +https://keycloak.example.com/realms/workbench-realm/.well-known/openid-configuration +``` + +This should return the OpenID Connect discovery document. + +--- + +## Next Steps + +You've completed the Keycloak configuration. Now proceed with: + +1. **[Configure the REST API](./configuration.md)** - Set up the Workbench REST API to use Keycloak + + You'll need these values from the steps above: + - **Issuer URL**: `https:///realms/` (from Step 5) + - **Client ID**: From Step 2 + - **Client Secret**: From Step 2 + +2. **[Test & Verify](./testing-verification.md)** - Confirm authentication is working correctly + +--- + +## Automated Configuration Script + +For development/testing environments, the REST API repository includes a configuration script: + +```bash +node ./scripts/configureKeycloak.js +``` + +This script: + +- Creates a test realm (`test-oidc-realm`) +- Creates a client (`attack-workbench-rest-api`) +- Creates test users with passwords +- Creates corresponding user accounts in Workbench + +**Note**: This is intended for development only. Do not use in production. + +## Troubleshooting + +### Keycloak Issuer URL Format + +**Issue**: Discovery fails with Keycloak. + +**Solution**: Verify the issuer URL format is correct: + +```text +https:///realms/ +``` + +Test the discovery endpoint manually: + +```bash +curl https://keycloak.example.com/realms/workbench-realm/.well-known/openid-configuration +``` + +Important notes: + +- Ensure the realm name is spelled correctly (case-sensitive) +- No trailing slash after the realm name +- The realm must exist and be active in Keycloak + +### Keycloak Client Authentication + +**Issue**: "Unauthorized client" error during authentication. + +**Solution**: Verify client configuration in Keycloak: + +1. Go to your client's **Settings** tab +2. Ensure "Client authentication" is **ON** (makes it a confidential client) +3. Verify you're using the correct Client Secret from the **Credentials** tab +4. Ensure "Standard flow" is enabled in the client settings + +### Keycloak Scope Configuration + +**Issue**: Missing user information after authentication. + +**Solution**: Ensure scopes are properly assigned: + +1. Go to your client → **Client Scopes** tab +2. Verify `email` and `profile` are in **Assigned Default Client Scopes** +3. `openid` scope is automatically included +4. Check that Keycloak users have email addresses configured +5. Verify "Email verified" is ON for users (or configure client to not require it) + +### Keycloak Redirect URI Support + +**Issue**: "Invalid redirect URI" error. + +**Solution**: Keycloak supports wildcard patterns: + +1. Check the client's "Valid Redirect URIs" setting +2. You can use wildcards like `http://localhost:*` for development +3. For production, use exact URIs for better security + +## Advanced Configuration + +### Service Account Authentication + +Keycloak supports OIDC Client Credentials flow for service-to-service authentication. + +1. **Enable service account** in your client: + - Go to **Settings** tab + - Enable **Service accounts roles** + - Save + +2. **Configure in REST API**: + + ```bash + # In .env file + SERVICE_ACCOUNT_OIDC_ENABLE=true + JWKS_URI=https://keycloak.example.com/realms/workbench-realm/protocol/openid-connect/certs + ``` + +3. **Add to JSON config** file: + + ```json + { + "serviceAuthn": { + "oidcClientCredentials": { + "enable": true, + "clients": [ + { + "clientId": "collection-manager-service", + "serviceRole": "collection-manager" + } + ] + } + } + } + ``` + +See [sample configuration](../../resources/sample-configurations/collection-manager-oidc-keycloak.json) for reference. + +### Custom Claims and Mappers + +Keycloak supports protocol mappers to add custom claims: + +1. In your client, go to **Client scopes** tab +2. Select a scope or create a dedicated scope +3. Click **Add mapper** → **By configuration** +4. Choose mapper type (User Attribute, User Property, etc.) +5. Configure the mapper to add claims to the ID token + +### Group/Role Mapping + +To map Keycloak roles to Workbench permissions: + +1. Create roles in Keycloak +2. Assign roles to users +3. Create a mapper to include roles in the token +4. Modify Workbench code to read and apply roles (requires custom development) + +## Additional Resources + +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [ATT&CK Workbench Authentication Documentation](./README.md) diff --git a/docs/admin/authentication/okta.md b/docs/admin/authentication/okta.md new file mode 100644 index 00000000..d554a209 --- /dev/null +++ b/docs/admin/authentication/okta.md @@ -0,0 +1,311 @@ +# Okta OIDC Configuration Guide + +> [!WARNING] +> This guide is in draft mode. Any feedback is appreciated! + +This guide provides step-by-step instructions for configuring Okta as the OIDC identity provider for the ATT&CK Workbench REST API. + +## Prerequisites + +- Okta account (free Developer account or enterprise) +- Administrator access to Okta Admin Console +- ATT&CK Workbench REST API installed + +## Overview + +This guide focuses on configuring Okta as your OIDC provider. After completing Okta setup: + +- Proceed to [REST API Configuration](./configuration.md) to configure the Workbench REST API +- Then follow [Testing & Verification](./testing-verification.md) to confirm everything works + +This guide covers only the Okta-specific configuration steps. + +--- + +## Step 1: Create OIDC Application in Okta + +1. **Log into Okta Admin Console**: + - Navigate to your Okta domain (e.g., `https://dev-12345.okta.com/admin`) + - Sign in as an administrator + +2. **Navigate to Applications**: + - In the Admin Console, go to **Applications** → **Applications** + - Click **Create App Integration** + +3. **Select Sign-in Method**: + - **Sign-in method**: `OIDC - OpenID Connect` + - **Application type**: `Web Application` + - Click **Next** + +4. **Configure General Settings**: + - **App integration name**: `ATT&CK Workbench REST API` + - **Logo** (optional): Upload a logo if desired + - Click **Next** or continue to Grant type + +## Step 2: Configure Application Settings + +1. **Grant Type**: + - ✓ **Authorization Code** (required) + - ✗ Refresh Token (optional, not required) + - ✗ Implicit (not recommended) + +2. **Sign-in redirect URIs**: Add your callback URL(s): + - For single environment: + + ```text + https://workbench.example.com/api/authn/oidc/callback + ``` + + - For multiple environments, add each separately: + + ```text + http://localhost:3000/api/authn/oidc/callback + https://workbench.example.com/api/authn/oidc/callback + ``` + +3. **Sign-out redirect URIs** (optional): + - Add your application's base URLs: + + ```text + http://localhost:3000 + https://workbench.example.com + ``` + +4. **Controlled access** (Assignments): + - **Allow everyone in your organization to access**: For initial setup/testing + - **Limit access to selected groups**: For production (recommended) + - Select the appropriate option for your needs + +5. **Click Save** + +## Step 3: Get Client Credentials + +After saving, you'll be taken to the application details page: + +1. **Note the following values**: + - **Client ID**: Found under "Client Credentials" + - **Client Secret**: Click to reveal and copy + - **Okta domain**: Your Okta domain (e.g., `dev-12345.okta.com`) + +2. **Determine your Issuer URL**: + - For Okta Developer accounts: `https:///oauth2/default` + - For custom authorization servers: `https:///oauth2/` + - For org authorization server: `https://` + + **To find your issuer URL**: + - Go to **Security** → **API** in the Okta Admin Console + - Find your authorization server (typically "default" for developer accounts) + - Copy the **Issuer URI** + +## Step 4: Configure OpenID Connect Scopes + +1. **Navigate to your Authorization Server**: + - Go to **Security** → **API** + - Click on your authorization server (e.g., "default") + +2. **Verify Scopes**: + - Go to the **Scopes** tab + - Ensure these scopes exist and are enabled: + - `openid` (required) + - `email` (required) + - `profile` (required) + +3. **Configure Claims** (verify defaults): + - Go to the **Claims** tab + - Verify these claims are configured for ID Token: + - `sub` (subject - default) + - `email` (from user.email) + - `preferred_username` (from user.login or user.email) + - `name` (from user.displayName or concatenated firstName/lastName) + + If missing, add them: + - Click **Add Claim** + - **Name**: `email` + - **Include in token type**: `ID Token`, `Always` + - **Value type**: `Expression` + - **Value**: `user.email` + - Save + +## Step 5: Create or Assign Users + +### Option A: Create New Users + +1. **Navigate to Users**: + - Go to **Directory** → **People** + - Click **Add Person** + +2. **Fill in User Details**: + - **First name**: `Admin` + - **Last name**: `User` + - **Username**: `admin@example.com` + - **Primary email**: `admin@example.com` + - **Password**: Choose how to set: + - Set by admin + - Set by user (email activation) + - Click **Save** + +3. **Assign to Application**: + - On the user's profile, go to **Applications** tab + - Click **Assign Applications** + - Find "ATT&CK Workbench REST API" + - Click **Assign** → **Save and Go Back** + +### Option B: Assign Existing Users + +1. **From the Application**: + - Go to **Applications** → **Applications** + - Click on "ATT&CK Workbench REST API" + - Go to **Assignments** tab + - Click **Assign** → **Assign to People** or **Assign to Groups** + - Select users/groups and click **Assign** + +--- + +## Next Steps + +You've completed the Okta configuration. Now proceed with: + +1. **[Configure the REST API](./configuration.md)** - Set up the Workbench REST API to use Okta + + You'll need these values from the steps above: + - **Issuer URL**: From Step 3 (e.g., `https://dev-12345.okta.com/oauth2/default`) + - **Client ID**: From Step 3 + - **Client Secret**: From Step 3 + +2. **[Test & Verify](./testing-verification.md)** - Confirm authentication is working correctly + +--- + +## Troubleshooting + +### Okta Issuer URL Format + +**Issue**: Discovery fails with Okta. + +**Solution**: Verify the issuer URL format is correct for your Okta setup: + +- **With authorization server**: `https:///oauth2/` +- **Default auth server**: `https:///oauth2/default` +- **Org auth server**: `https://` + +Test the discovery endpoint manually: + +```bash +curl https://dev-12345.okta.com/oauth2/default/.well-known/openid-configuration +``` + +### Okta User Assignment + +**Issue**: Error "User is not assigned to the client application" during authentication. + +**Solution**: Okta requires explicit user/group assignment to applications: + +1. Go to your application in Okta Admin Console +2. Go to **Assignments** tab +3. Assign the user or their group to the application + +### Okta Claims Configuration + +**Issue**: Missing user information after authentication. + +**Solution**: Ensure claims are properly configured in Okta: + +1. Go to **Security** → **API** → your authorization server → **Claims** tab +2. Verify claims for `email`, `preferred_username`, and `name` exist +3. Ensure claims are configured to be included in ID Token (not just Access Token) +4. Check that Okta users have email addresses configured in their profiles + +### Okta Redirect URI Restrictions + +**Issue**: "Invalid redirect URI" error. + +**Solution**: Okta requires exact URI matches (no wildcards): + +1. In Okta application settings, check "Sign-in redirect URIs" +2. URIs must be exact matches - no wildcard patterns allowed +3. Add each environment's callback URL separately + +## Advanced Configuration + +### Service Account Authentication + +Okta supports Client Credentials flow for service-to-service authentication. + +1. **Create a Machine-to-Machine application**: + - In Okta, create a new app integration + - Choose **API Services** application type + - Note the Client ID and Client Secret + +2. **Configure in REST API**: + + ```bash + # In .env file + SERVICE_ACCOUNT_OIDC_ENABLE=true + JWKS_URI=https://dev-12345.okta.com/oauth2/default/v1/keys + ``` + +3. **Add to JSON config** file: + + ```json + { + "serviceAuthn": { + "oidcClientCredentials": { + "enable": true, + "clients": [ + { + "clientId": "0oa3xb9oz3QLY1avc5d7", + "serviceRole": "collection-manager" + } + ] + } + } + } + ``` + +See [sample configuration](../../resources/sample-configurations/collection-manager-oidc-okta.json) for reference. + +### Custom Authorization Server + +For production deployments, consider creating a custom authorization server: + +1. Go to **Security** → **API** +2. Click **Add Authorization Server** +3. Configure your custom authorization server +4. Use the custom server's Issuer URI in your configuration + +Benefits: + +- Isolated from default server +- Custom claims and scopes +- Better control over token lifetimes +- Separate audience values + +### Group-Based Access Control + +To restrict access based on Okta groups: + +1. **Create groups** in Okta (**Directory** → **Groups**) +2. **Assign users** to groups +3. **Configure application assignment**: + - Assign groups to your application instead of individual users +4. **Add group claim** to tokens (optional): + - Go to your authorization server → **Claims** + - Add a `groups` claim to include group memberships in tokens +5. **Custom logic** in Workbench to read and apply groups (requires code modification) + +### Multi-Factor Authentication (MFA) + +Okta supports comprehensive MFA options: + +1. **Configure Sign-On Policy**: + - In your application, go to **Sign On** tab + - Edit or create a sign-on policy + - Add a rule that requires MFA +2. **No changes needed** in Workbench +3. Users will be prompted for MFA during Okta login + +## Additional Resources + +- [Okta Developer Documentation](https://developer.okta.com/) +- [Okta OpenID Connect](https://developer.okta.com/docs/concepts/oauth-openid/) +- [ATT&CK Workbench Authentication Documentation](./README.md) diff --git a/docs/admin/authentication/testing-verification.md b/docs/admin/authentication/testing-verification.md new file mode 100644 index 00000000..a58da3a5 --- /dev/null +++ b/docs/admin/authentication/testing-verification.md @@ -0,0 +1,143 @@ +# Testing & Verification + +This guide provides steps to verify your OIDC authentication configuration is working correctly with the ATT&CK Workbench REST API. + +## Prerequisites + +Before testing, ensure you have: + +1. Completed OIDC provider configuration (Authentik, Okta, Keycloak, etc.) +2. Configured the REST API with OIDC settings +3. Restarted the REST API +4. Created at least one user in your OIDC provider + +## Verification Steps + +### Step 1: Check REST API Logs + +When the REST API starts, it should log information about the authentication configuration. + +**View logs:** + +```bash +# If using Docker Compose +docker compose logs rest-api + +# If running directly +# Check your console output or log files +``` + +### Step 2: Test Configuration Endpoint + +The REST API exposes an endpoint that returns the configured authentication mechanism. + +**Test the endpoint:** + +```bash +curl http://localhost:3000/api/config/authn +``` + +**Expected response:** + +```json +{ + "mechanisms": [{"authnType":"oidc"}] +} +``` + +**If you see a different response:** + +- `{"mechanisms":[{"authnType":"anonymous"}]}` - OIDC is not enabled; check your configuration +- Error or timeout - REST API is not running or not accessible + +### Step 3: Test Authentication Flow + +Now test the complete authentication flow from the frontend. + +1. **Navigate to the Workbench frontend** in your browser: + - If running locally: + - If deployed: Your Workbench URL (e.g., ) + +2. **Click "Log In"** (or navigate to the login page) + +3. **Observe the redirect**: + - You should be automatically redirected to your OIDC provider's login page + - The URL should match your provider's domain (not the Workbench domain) + +4. **Log in with credentials**: + - Enter the username and password for a user in your OIDC provider + - Complete any MFA/2FA prompts if configured + +5. **Observe the callback**: + - After successful authentication, you should be redirected back to the Workbench + - The URL should temporarily show `/api/authn/oidc/callback` before redirecting to the main page + +6. **Verify authenticated state**: + - You should now be logged into the Workbench + - Your username should appear in the navigation bar + - You should have access based on your user's role + +### Step 4: Test Logout + +Test the logout functionality: + +1. **Click your username** in the navigation bar +2. **Select "Logout"** +3. **Verify:** + - You are logged out of the Workbench + - Attempting to access protected pages redirects you to login + - Note: You may still be logged into your OIDC provider (single logout varies by provider) + +## Common Issues and Solutions + +### Issue: "Users authenticated but have no permissions" + +**Symptoms:** + +- Users can log in successfully +- Users cannot view or edit any content +- Error messages about insufficient permissions + +**Cause:** User accounts exist in OIDC provider but not in the Workbench database. + +**Solutions:** + +1. **Create user accounts in Workbench:** + - OIDC only handles authentication, not authorization + - You must create corresponding user accounts in the Workbench database + - See the [User Management documentation](./README.md#user-management) for details + +2. **Verify username matching:** + - The username in Workbench must match the OIDC claim (usually `preferred_username` or `email`) + - Check the REST API logs to see what username is being extracted from the OIDC token + +## Debugging Tips + +### Test with curl + +You can test the OIDC endpoints directly: + +```bash +# Test the auth initiation endpoint +curl -v http://localhost:3000/api/authn/oidc/login + +# This should return a redirect (302) to your OIDC provider +``` + +## Next Steps + +Once authentication is working correctly: + +1. **Set up user accounts** - Create users in the Workbench database with appropriate roles +2. **Configure authorization** - Set up role-based access control +3. **Review security settings** - Ensure production-ready security configuration +4. **Set up monitoring** - Monitor authentication failures and session issues + +## Additional Resources + +- [Authentication Overview](./README.md) +- [REST API Configuration](./configuration.md) +- Provider-specific guides: + - [Authentik Configuration](./authentik.md) + - [Okta Configuration](./okta.md) + - [Keycloak Configuration](./keycloak.md) diff --git a/docs/admin/automation-runs.md b/docs/admin/automation-runs.md new file mode 100644 index 00000000..1ed1905a --- /dev/null +++ b/docs/admin/automation-runs.md @@ -0,0 +1,163 @@ +# Automation Run Audit Trail + +## Overview + +Workbench persists a durable audit trail for non-human-driven +automation. This currently includes the new migration workflow and is +intended to support scheduler backfills and future repair tasks. + +Two MongoDB collections are used: + +- `automationRuns`: one summary row per automation execution +- `automationRunItems`: per-item detail linked to a parent run by + `run_id` + +These collections are operational diagnostics and audit records. They +do not replace the version history stored on ATT&CK objects +themselves. + +## When these collections are created + +The collections are created lazily the first time an automation uses +the recorder. The recorder also creates indexes needed for common +queries. + +As of now, the first built-in consumer is the +`20260507130000-normalize-x-mitre-platforms` migration. + +## What to expect in `automationRuns` + +Each run document includes: + +- `run_id`: unique identifier for the execution +- `automation_type`: coarse class such as `migration` +- `name`: specific job name +- `status`: `running`, `completed`, `partial`, or `failed` +- `started_at` and `finished_at` +- `scope`: what the job intended to operate on +- `counts`: numeric counters from the job +- `warnings`: warning counters from the job +- `verification`: post-run checks +- `summary`: human-readable outcome +- `error_summary`: terminal failure detail, if any + +## What to expect in `automationRunItems` + +Each item document includes: + +- `run_id`: parent run identifier +- `sequence`: item ordering within the run +- `status`: per-item result such as `changed`, `unchanged`, or `failed` +- `action`: operation-specific verb +- `target`: identity envelope for the processed unit +- `warnings`: optional warning codes +- `details`: automation-specific payload such as before/after values +- `error`: serialized failure detail when the item failed + +Not every automation will use exactly the same `details` payload. That +field is intentionally extensible. + +## Common operator workflows + +### Inspect the latest automation runs + +```javascript +db.automationRuns.find().sort({ started_at: -1 }).limit(10).pretty() +``` + +### Inspect the latest platform-normalization migration run + +```javascript +db.automationRuns.find( + { name: '20260507130000-normalize-x-mitre-platforms' } +).sort({ started_at: -1 }).limit(1).pretty() +``` + +### Fetch all item records for a run + +```javascript +const run = db.automationRuns.findOne( + { name: '20260507130000-normalize-x-mitre-platforms' }, + { sort: { started_at: -1 } } +); + +db.automationRunItems.find({ run_id: run.run_id }).sort({ sequence: 1 }).pretty(); +``` + +### Inspect failures only + +```javascript +db.automationRunItems.find({ + run_id: run.run_id, + status: 'failed' +}).sort({ sequence: 1 }).pretty(); +``` + +### Inspect all runs that touched a specific STIX object + +```javascript +db.automationRunItems.find({ + 'target.stix_id': 'attack-pattern--01234567-89ab-cdef-0123-456789abcdef' +}).sort({ recorded_at: -1 }).pretty(); +``` + +## Interpreting run status + +- `running`: the automation started but has not recorded terminal + state yet +- `completed`: the automation finished without item failures +- `partial`: the automation made some progress but also encountered + one or more failures +- `failed`: the automation did not complete successfully and did not + produce a usable result set + +The exact threshold between `partial` and `failed` is determined by +the automation implementation. Always inspect `counts`, +`verification`, and `error_summary` before deciding whether manual +intervention is required. + +## Interpreting verification + +`verification` contains post-run checks claimed by the automation. +For example, the platform-normalization migration records +`remaining_latest_active_objects_with_legacy_platforms`. + +This is the fastest way to answer, “Did the automation actually finish +the intended repair?” + +## Logs vs persisted records + +Container logs remain useful for real-time observation, but they are +not the system of record. The durable record is MongoDB: + +- logs are best for live troubleshooting +- `automationRuns` and `automationRunItems` are best for audit and + post-hoc analysis + +When an automation emits a `run_id` in the logs, use that `run_id` to +retrieve the persisted record in MongoDB. + +## Retention and growth + +There is currently no TTL or automatic pruning for these collections. +That is intentional: operators may want to retain a complete history +of database repairs and other automation. + +If you later decide to prune: + +1. confirm any local retention or audit requirements +2. prune old `automationRunItems` only alongside their parent + `automationRuns` +3. never confuse these records with the authoritative STIX object + history stored in `attackObjects` and `relationships` + +## Relationship to migration startup + +If automatic migrations are enabled, migrations run on server startup. +See [Configuration](configuration.md) for the +`database.migration.enable` setting. + +The automation audit trail is especially useful in environments where +migrations run automatically, because it provides durable visibility +into what happened after startup rather than relying only on container +logs. diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md new file mode 100644 index 00000000..ba47748b --- /dev/null +++ b/docs/admin/configuration.md @@ -0,0 +1,653 @@ +# ATT&CK Workbench REST API Configuration Guide + +This guide explains how to configure the ATT&CK Workbench REST API using environment variables, +JSON configuration files, or a combination of both. + +## Table of Contents + +- [ATT\&CK Workbench REST API Configuration Guide](#attck-workbench-rest-api-configuration-guide) + - [Table of Contents](#table-of-contents) + - [Configuration System Overview](#configuration-system-overview) + - [Configuration Methods](#configuration-methods) + - [Environment Variables](#environment-variables) + - [JSON Configuration File](#json-configuration-file) + - [Configuration Precedence](#configuration-precedence) + - [Configuration Options](#configuration-options) + - [Server](#server) + - [Database](#database) + - [Application](#application) + - [Logging](#logging) + - [Session](#session) + - [User Authentication](#user-authentication) + - [OIDC Configuration](#oidc-configuration) + - [Service Authentication](#service-authentication) + - [OIDC Client Credentials](#oidc-client-credentials) + - [Challenge API Key](#challenge-api-key) + - [Basic API Key](#basic-api-key) + - [Multiple Service Authentication Methods](#multiple-service-authentication-methods) + - [Scheduler](#scheduler) + - [Validation](#validation) + - [Collection Indexes](#collection-indexes) + - [Configuration Files](#configuration-files) + - [ATT\&CK Specific](#attck-specific) + - [Additional Resources](#additional-resources) + +--- + +## Configuration System Overview + +The REST API uses [Convict](https://github.com/mozilla/node-convict), a configuration management library. +All configuration is defined in `app/config/config.js` with sensible defaults. +You only need to override values specific to your environment. + +--- + +## Configuration Methods + +### Environment Variables + +Environment variables are the recommended method for configuring the REST API in containerized deployments and for simple configurations. + +A `template.env` file is included in the repository for you to make use of this option. + +### JSON Configuration File + +JSON configuration files are recommended for complex configurations, especially when defining service accounts or OIDC clients. + +**Setting up JSON configuration:** + +1. Create a configuration file (e.g., `config.json`): + + ```json + { + "server": { + "port": 3000, + "corsAllowedOrigins": ["https://workbench.example.com", "https://staging.example.com"] + }, + "database": { + "url": "mongodb://localhost:27017/attack-workspace" + }, + "userAuthn": { + "mechanism": "oidc", + "oidc": { + "issuerUrl": "https://auth.example.com/realms/workbench", + "clientId": "attack-workbench-rest-api", + "clientSecret": "your-client-secret", + "redirectOrigin": "https://workbench.example.com" + } + }, + "serviceAuthn": { + "basicApikey": { + "enable": true, + "serviceAccounts": [ + { + "name": "navigator", + "apikey": "your-navigator-apikey", + "serviceRole": "read-only" + }, + { + "name": "collection-manager", + "apikey": "your-collection-manager-apikey", + "serviceRole": "collection-manager" + } + ] + } + } + } + ``` + +2. Reference the file via environment variable: + + ```bash + export JSON_CONFIG_PATH=/path/to/config.json + npm start + ``` + + Or in `.env`: + + ```bash + JSON_CONFIG_PATH=/path/to/config.json + ``` + +### Configuration Precedence + +When both environment variables and JSON configuration are used: + +1. **Environment variables** are loaded first with their defaults +2. **JSON configuration file** (if specified) is loaded second and overrides environment variables +3. **Validation** occurs after all configuration is loaded + +**Example:** + +```bash +# .env file +PORT=3000 +DATABASE_URL=mongodb://localhost:27017/attack-workspace +JSON_CONFIG_PATH=./config.json +``` + +```json +// config.json +{ + "server": { + "port": 8080 + } +} +``` + +**Result:** Port will be `8080` (JSON overrides environment variable) + +--- + +## Configuration Options + +### Server + +Configuration for the HTTP server. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|----------------------|-------------------------|----------------------------|---------|---------|-----------------------------------------------------------------------------------------------------------------| +| Port | `PORT` | `server.port` | integer | `3000` | HTTP server port | +| CORS Allowed Origins | `CORS_ALLOWED_ORIGINS` | `server.corsAllowedOrigins`| domains | `*` | Allowed origins for CORS. Use `*` for all, `disable` to disable CORS, or comma-separated list of origins | + +**CORS Allowed Origins** accepts: + +- `*` - Allow any origin (not recommended for production) +- `disable` - Disable CORS entirely +- Comma-separated list of origins (with protocol): + - `https://workbench.example.com` + - `http://localhost:4200,https://workbench.example.com` +- Supports localhost, private IPs (10.x, 172.16-31.x, 192.168.x), and FQDNs + +**Examples:** + +```bash +# Environment variable +CORS_ALLOWED_ORIGINS=https://workbench.example.com,https://staging.example.com +``` + +```json +// JSON +{ + "server": { + "corsAllowedOrigins": ["https://workbench.example.com", "https://staging.example.com"] + } +} +``` + +### Database + +MongoDB database configuration. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|--------------|-------------------------------------|------------------------------|---------|-------------|-------------------------------------------| +| URL | `DATABASE_URL` | `database.url` | string | *(empty)* | MongoDB connection string (REQUIRED) | +| Auto-migrate | `WB_REST_DATABASE_MIGRATION_ENABLE` | `database.migration.enable` | boolean | `true` | Run migrations automatically on startup | + +**Examples:** + +```bash +# Local MongoDB +DATABASE_URL=mongodb://localhost:27017/attack-workspace + +# Docker Compose +DATABASE_URL=mongodb://attack-workbench-database/attack-workspace +``` + +**Migration Notes:** + +- When `database.migration.enable` is `true`, migrations run automatically at startup +- Set to `false` if you manage migrations separately (e.g., in a Kubernetes init container) +- Migrations are idempotent and safe to run multiple times +- Automation-enabled migrations may also write durable audit records to `automationRuns` and `automationRunItems`; see [Automation Run Audit Trail](automation-runs.md) + +### Application + +General application settings. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|---------------------|----------------------|-------------------------|--------|-----------------------------|-----------------------------------------------------------| +| Name | *(none)* | `app.name` | string | `attack-workbench-rest-api` | Application name | +| Environment | `NODE_ENV` | `app.env` | string | `development` | Environment name (`development`, `production`, `test`) | +| Version | *(none)* | `app.version` | string | *(from package.json)* | Application version | +| ATT&CK Spec Version | *(none)* | `app.attackSpecVersion` | string | *(from package.json)* | ATT&CK specification version | + +**Example:** + +```bash +NODE_ENV=production +``` + +### Logging + +Logging configuration using Winston. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|-----------|----------------------|--------------------|--------|---------|---------------------| +| Log Level | `LOG_LEVEL` | `logging.logLevel` | string | `info` | Console log level | + +**Log Levels** (from least to most verbose): + +- `error` - Only errors +- `warn` - Warnings and errors +- `http` - HTTP requests, warnings, and errors +- `info` - General information (recommended for production) +- `verbose` - Detailed information +- `debug` - Debug messages (recommended for development) + +**Example:** + +```bash +LOG_LEVEL=debug +``` + +### Session + +Session management for user authentication. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|----------------------|--------------------------------|----------------------------------|--------|--------------------------|-------------------------------------------| +| Secret | `SESSION_SECRET` | `session.secret` | string | *(generated at startup)* | Secret used to sign session cookies | +| Mongo Session Secret | `MONGOSTORE_CRYPTO_SECRET` | `session.mongoStoreCryptoSecret` | string | *(generated at startup)* | Secret to encrypt session data in MongoDB | + +**Important Notes:** + +- If not set, a secret is generated randomly at startup +- Random secrets are regenerated on restart, forcing users to re-login +- Random secrets cannot be shared across multiple server instances +- **Production:** Always set `SESSION_SECRET` to a fixed, secure value + +**Generating a secure secret:** + +```bash +node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" +``` + +**Example:** + +```bash +SESSION_SECRET=your-secure-secret-here +MONGOSTORE_CRYPTO_SECRET=your-secure-secret-here +``` + +### User Authentication + +Configuration for user authentication (how end-users log in). + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|-----------|----------------------|-----------------------|------|-------------|----------------------------------| +| Mechanism | `AUTHN_MECHANISM` | `userAuthn.mechanism` | enum | `anonymous` | Authentication mechanism to use | + +**Mechanism Options:** + +- `anonymous` - No authentication required (development only) +- `oidc` - OpenID Connect (recommended for production) + +#### OIDC Configuration + +Required when `mechanism` is set to `oidc`. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|-----------------|------------------------------|---------------------------------|--------|-------------------------|-----------------------------| +| Issuer URL | `AUTHN_OIDC_ISSUER_URL` | `userAuthn.oidc.issuerUrl` | string | *(empty)* | OIDC provider's issuer URL | +| Client ID | `AUTHN_OIDC_CLIENT_ID` | `userAuthn.oidc.clientId` | string | *(empty)* | OIDC client identifier | +| Client Secret | `AUTHN_OIDC_CLIENT_SECRET` | `userAuthn.oidc.clientSecret` | string | *(empty)* | OIDC client secret | +| Redirect Origin | `AUTHN_OIDC_REDIRECT_ORIGIN` | `userAuthn.oidc.redirectOrigin` | string | `http://localhost:3000` | Base URL for redirect URI | + +**Example:** + +```bash +AUTHN_MECHANISM=oidc +AUTHN_OIDC_ISSUER_URL=https://auth.example.com/realms/workbench +AUTHN_OIDC_CLIENT_ID=attack-workbench-rest-api +AUTHN_OIDC_CLIENT_SECRET=your-client-secret +AUTHN_OIDC_REDIRECT_ORIGIN=https://workbench.example.com +``` + +For detailed OIDC setup, see [Authentication Documentation](./authentication/README.md). + +### Service Authentication + +Configuration for service-to-service authentication (APIs, automation tools). + +The REST API supports three service authentication methods: + +1. **OIDC Client Credentials** - OAuth2 client credentials flow +2. **Challenge API Key** - Token exchange with challenge/response +3. **Basic API Key** - Simple API key authentication + +All methods support role-based access control with three service roles: + +- `read-only` - Read-only access to endpoints +- `collection-manager` - Read/write access for collection management +- `stix-export` - Access to STIX export endpoints + +#### OIDC Client Credentials + +Uses OAuth2 Client Credentials flow with JWT validation. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|----------|-------------------------------|----------------------------------------------|---------|-----------|------------------------------------------------| +| Enable | `SERVICE_ACCOUNT_OIDC_ENABLE` | `serviceAuthn.oidcClientCredentials.enable` | boolean | `false` | Enable OIDC client credentials authentication | +| JWKS URI | `JWKS_URI` | `serviceAuthn.oidcClientCredentials.jwksUri` | string | *(empty)* | JWKS endpoint for IdP public keys | +| Clients | *(JSON only)* | `serviceAuthn.oidcClientCredentials.clients` | array | `[]` | Array of authorized OIDC clients | + +**Clients Array Schema:** + +```json +{ + "clientId": "string", // OIDC client ID + "serviceRole": "enum" // Service role (read-only, collection-manager, stix-export) +} +``` + +**Example:** + +```bash +# .env +SERVICE_ACCOUNT_OIDC_ENABLE=true +JWKS_URI=https://auth.example.com/realms/workbench/protocol/openid-connect/certs +JSON_CONFIG_PATH=./config.json +``` + +```json +// config.json +{ + "serviceAuthn": { + "oidcClientCredentials": { + "enable": true, + "clients": [ + { + "clientId": "collection-manager-service", + "serviceRole": "collection-manager" + } + ] + } + } +} +``` + +See sample configurations: + +- [collection-manager-oidc-keycloak.json](../resources/sample-configurations/collection-manager-oidc-keycloak.json) +- [collection-manager-oidc-okta.json](../resources/sample-configurations/collection-manager-oidc-okta.json) + +#### Challenge API Key + +Token exchange authentication with challenge/response mechanism. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|----------------------|---------------------------------------------------|-----------------------------------------------|---------|--------------------------|---------------------------------------| +| Enable | `WB_REST_SERVICE_ACCOUNT_CHALLENGE_APIKEY_ENABLE` | `serviceAuthn.challengeApikey.enable` | boolean | `false` | Enable challenge API key authentication | +| Token Signing Secret | `WB_REST_TOKEN_SIGNING_SECRET` | `serviceAuthn.challengeApikey.secret` | string | *(generated at startup)* | Secret used to sign access tokens | +| Token Timeout | `WB_REST_TOKEN_TIMEOUT` | `serviceAuthn.challengeApikey.tokenTimeout` | integer | `300` | Access token lifetime in seconds | +| Service Accounts | *(JSON only)* | `serviceAuthn.challengeApikey.serviceAccounts`| array | `[]` | Array of service accounts | + +**Service Accounts Array Schema:** + +```json +{ + "name": "string", // Service account name + "apikey": "string", // Shared secret (API key) + "serviceRole": "enum" // Service role +} +``` + +**Example:** + +```bash +# .env +WB_REST_SERVICE_ACCOUNT_CHALLENGE_APIKEY_ENABLE=true +WB_REST_TOKEN_SIGNING_SECRET=your-secure-secret +WB_REST_TOKEN_TIMEOUT=600 +JSON_CONFIG_PATH=./config.json +``` + +```json +// config.json +{ + "serviceAuthn": { + "challengeApikey": { + "enable": true, + "serviceAccounts": [ + { + "name": "collection-manager", + "apikey": "your-secure-apikey", + "serviceRole": "collection-manager" + } + ] + } + } +} +``` + +See sample: [test-service-challenge-apikey.json](../resources/sample-configurations/test-service-challenge-apikey.json) + +#### Basic API Key + +Simple API key authentication (no challenge). + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|------------------|------------------------------------------------|--------------------------------------------|---------|---------|--------------------------------------| +| Enable | `WB_REST_SERVICE_ACCOUNT_BASIC_APIKEY_ENABLE` | `serviceAuthn.basicApikey.enable` | boolean | `false` | Enable basic API key authentication | +| Service Accounts | *(JSON only)* | `serviceAuthn.basicApikey.serviceAccounts` | array | `[]` | Array of service accounts | + +**Service Accounts Array Schema:** + +```json +{ + "name": "string", // Service account name + "apikey": "string", // API key + "serviceRole": "enum" // Service role +} +``` + +**Example:** + +```bash +# .env +WB_REST_SERVICE_ACCOUNT_BASIC_APIKEY_ENABLE=true +JSON_CONFIG_PATH=./config.json +``` + +```json +// config.json +{ + "serviceAuthn": { + "basicApikey": { + "enable": true, + "serviceAccounts": [ + { + "name": "navigator", + "apikey": "your-navigator-apikey", + "serviceRole": "read-only" + } + ] + } + } +} +``` + +See sample: [navigator-basic-apikey.json](../resources/sample-configurations/navigator-basic-apikey.json) + +#### Multiple Service Authentication Methods + +You can enable multiple service authentication methods simultaneously: + +```json +{ + "serviceAuthn": { + "oidcClientCredentials": { + "enable": true, + "clients": [ + { + "clientId": "automated-collection-manager", + "serviceRole": "collection-manager" + } + ] + }, + "challengeApikey": { + "enable": true, + "serviceAccounts": [ + { + "name": "legacy-service", + "apikey": "legacy-apikey", + "serviceRole": "read-only" + } + ] + }, + "basicApikey": { + "enable": true, + "serviceAccounts": [ + { + "name": "navigator", + "apikey": "navigator-apikey", + "serviceRole": "read-only" + } + ] + } + } +} +``` + +See sample: [multiple-apikey-services.json](../resources/sample-configurations/multiple-apikey-services.json) + +### Scheduler + +Background job scheduler configuration. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|----------------|----------------------------|----------------------------------|---------|---------|--------------------------------------| +| Enable | `ENABLE_SCHEDULER` | `scheduler.enableScheduler` | boolean | `true` | Enable background job scheduler | +| Check Interval | `CHECK_WORKBENCH_INTERVAL` | `scheduler.checkWorkbenchInterval` | integer | `10` | Scheduler check interval in seconds | + +**Scheduler Functions:** + +- Checks for collection index updates +- Downloads collection bundles from remote URLs +- Processes subscription update policies + +**Example:** + +```bash +ENABLE_SCHEDULER=true +CHECK_WORKBENCH_INTERVAL=30 +``` + +### Validation + +Configuration for ATT&CK Data Model (ADM) request validation and the scheduled re-validation task. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|-------------------|-----------------------------|----------------------------------------|---------|-------------|----------------------------------------------------------------------------------------------| +| Validate Requests | `VALIDATE_WITH_ADM_SCHEMAS` | `validateRequests.withAttackDataModel` | boolean | `false` | Run incoming POST/PUT bodies through the ADM schemas before persisting | +| Re-validate Cron | `VALIDATE_OBJECTS_CRON` | `scheduler.validateObjectsCron` | string | `0 3 * * *` | Cron pattern for the background task that refreshes `workspace.validation` on every document | +| ADM Log Level | `ADM_LOG_LEVEL` | *(env only)* | enum | `warn` | Verbosity of the ADM library's internal logger | + +**`VALIDATE_WITH_ADM_SCHEMAS`** + +When enabled, every POST and PUT validates the composed STIX payload against the ADM schemas before saving. Failed validations cause the request to throw with an HTTP error and the document is not persisted. When disabled, the write pipeline does not gate on ADM compliance and downstream tools are responsible for detecting non-compliant content. + +This setting does not affect the legacy OpenAPI request validation (`VALIDATE_WITH_LEGACY_SCHEMAS`), which can be enabled or disabled independently. + +**`VALIDATE_OBJECTS_CRON`** + +The Workbench scheduler periodically re-validates every SDO and SRO in the database against the current ADM and refreshes each document's `workspace.validation` field. This combats *concept drift*: documents that passed validation under an older ADM version may become non-compliant after a Workbench upgrade. + +The cron pattern follows standard 5-field syntax (`minute hour day-of-month month day-of-week`). The default `0 3 * * *` runs the task daily at 3:00 AM. The task is skipped entirely if the global scheduler is disabled (`ENABLE_SCHEDULER=false`). + +For the lifecycle of `workspace.validation` itself, see [Stateful Validation Tracking](../developer/workspace-validation.md). + +**`ADM_LOG_LEVEL`** + +Read by the `@mitre-attack/attack-data-model` library directly — *not* a Convict-managed setting and not configurable via `JSON_CONFIG_PATH`. It controls the verbosity of the ADM library's own logger, which is independent of the Workbench `LOG_LEVEL`. + +This was introduced primarily to suppress the deprecation warning that the ADM emits for every relationship in the database during a re-validation run. Setting `ADM_LOG_LEVEL=error` (or `silent`) keeps the scheduled task quiet without affecting Workbench's own logs. + +| Level | Description | +|----------|--------------------------------------------------------------------| +| `debug` | Verbose diagnostic output | +| `info` | Informational status messages (data retrieval, parse counts, etc.) | +| `warn` | Validation issues in `relaxed` mode and deprecation warnings | +| `error` | Errors only | +| `silent` | Disables all output | + +Levels are inclusive: setting `info` enables `info`, `warn`, and `error`. The default is `warn`. + +**Examples:** + +```bash +# Enable ADM-based request validation +VALIDATE_WITH_ADM_SCHEMAS=true + +# Run re-validation hourly instead of daily at 3 AM +VALIDATE_OBJECTS_CRON=0 * * * * + +# Suppress noisy ADM deprecation warnings during scheduler runs +ADM_LOG_LEVEL=error +``` + +### Collection Indexes + +Configuration for ATT&CK collection index subscriptions. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|------------------|----------------------|----------------------------------|---------|---------|-------------------------------------------| +| Default Interval | `DEFAULT_INTERVAL` | `collectionIndex.defaultInterval`| integer | `300` | Default update check interval in seconds | + +**Notes:** + +- Only applies to new collection indexes added after configuration change +- Does not affect existing collection indexes (they retain their configured interval) + +### Configuration Files + +Paths to additional configuration and data files. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|-----------------------------------|--------------------------------------|-----------------------------------------------------|--------|--------------------------------------------------|--------------------------------------------------| +| JSON Config Path | `JSON_CONFIG_PATH` | `configurationFiles.jsonConfigFile` | string | *(empty)* | Path to JSON configuration file | +| Allowed Values Path | `ALLOWED_VALUES_PATH` | `configurationFiles.allowedValues` | string | `./app/config/allowed-values.json` | Path to allowed values configuration | +| Static Marking Definitions Path | `WB_REST_STATIC_MARKING_DEFS_PATH` | `configurationFiles.staticMarkingDefinitionsPath` | string | `./app/lib/default-static-marking-definitions/` | Directory containing static marking definitions | + +**Allowed Values:** + +The allowed values file defines valid enum values for STIX object properties (platforms, permissions, etc.). +See `app/config/allowed-values.json` for the schema. + +**Static Marking Definitions:** + +Directory containing JSON files with STIX marking definitions that are automatically loaded into the system on startup. + +### ATT&CK Specific + +ATT&CK-specific configuration values. + +| Option | Environment Variable | JSON Path | Type | Default | Description | +|--------------------------|----------------------|------------------------|--------|------------|-----------------------------------------------------------------| +| Attack Source Names | *(JSON only)* | `attackSourceNames` | array | See below | Valid `source_name` values in ATT&CK `external_references` | +| Domain to Kill Chain Map | *(JSON only)* | `domainToKillChainMap` | object | See below | Maps domain names to kill chain phase names | + +**Default Attack Source Names:** + +```json +["mitre-attack", "mitre-mobile-attack", "mobile-attack", "mitre-ics-attack"] +``` + +**Default Domain to Kill Chain Map:** + +```json +{ + "enterprise-attack": "mitre-attack", + "mobile-attack": "mitre-mobile-attack", + "ics-attack": "mitre-ics-attack" +} +``` + +--- + +## Additional Resources + +- [Authentication Documentation](./authentication/README.md) +- [Sample Configurations](../resources/sample-configurations/) +- [Template Environment File](../template.env) diff --git a/docs/developer/automation-runs.md b/docs/developer/automation-runs.md new file mode 100644 index 00000000..1659cd7a --- /dev/null +++ b/docs/developer/automation-runs.md @@ -0,0 +1,300 @@ +# Automation Run Audit Trail + +## Overview + +Workbench now persists a durable audit trail for non-human-driven +write workflows such as database migrations, scheduler backfills, and +future repair tasks. + +The audit trail uses two MongoDB collections: + +- `automationRuns`: one summary document per automation execution +- `automationRunItems`: zero or more per-item outcome documents linked + to a parent run by `run_id` + +This document defines the taxonomy, the design rationale, and the +rules for extending it. + +## Design goals + +The automation-run taxonomy is meant to satisfy four requirements at +once: + +1. Provide operators with a durable record of what automation did. +2. Stay generic enough for many automation classes, not just + migrations. +3. Preserve stable, queryable top-level fields for dashboards and + tooling. +4. Avoid forcing every automation into one rigid per-item schema. + +## Design rationale + +The obvious starting point is a strict, fully-normalized schema. It +makes querying easy and forces strong conventions, and it is tempting +to give every item first-class fields like `stix_id`, `stix_type`, +`previous_modified`, `new_modified`, `changes`, and so on. + +That fits STIX-object migrations cleanly, but it starts to chafe as +soon as the next automation looks slightly different. Scheduler jobs +may operate on many collections. Admin repair tasks may act on one +system document. Future jobs may not be versioned STIX objects at +all. A taxonomy that hard-codes the shape of a STIX migration forces +every later automation to either violate the schema or trigger +another round of schema changes. + +The reflex when that becomes painful is to retreat to the other +extreme: store only opaque JSON blobs — a `payload` or `details` +field at both the run and item levels — and let each automation +describe itself however it likes. That removes schema churn entirely, +but it also removes the parts operators and tooling actually rely on: +run status, start and end timestamps, automation class, counts and +warnings, per-item status, and the linkage between a run and its +items. A purely free-form audit trail is durable but not operationally +useful. + +The split that falls out of that tension is the design used here. The +parts every automation must expose for querying and dashboards — the +**envelope** — stay stable and structured. The parts that legitimately +vary by automation class — the **payload** — stay extensible and +free-form. Concretely: + +- `automationRuns` has stable top-level fields like `run_id`, + `automation_type`, `status`, `started_at`, `finished_at`, `scope`, + `counts`, `warnings`, and `verification`. +- `automationRunItems` has stable top-level fields like `run_id`, + `sequence`, `status`, `action`, `target`, `warnings`, and `error`. +- Automation-specific detail lives under `metadata` on the run and + under `details` on the item. + +That keeps the taxonomy durable for tooling while still absorbing new +classes of automation without a schema redesign. + +## Collection taxonomy + +### `automationRuns` + +Each document represents one execution of an automation workflow. + +Stable fields: + +| Field | Purpose | +|---|---| +| `schema_version` | Version of the automation-run document taxonomy. Increment only when the persisted shape changes incompatibly. | +| `run_id` | Unique identifier shared by the run and all its items. | +| `automation_type` | Coarse automation class, such as `migration`, `scheduler`, `backfill`, or `repair`. | +| `name` | Specific automation name, such as a migration filename or scheduler task name. | +| `status` | Current terminal or in-flight run status. Common values are `running`, `completed`, `partial`, and `failed`. | +| `started_at` | UTC start timestamp. | +| `finished_at` | UTC completion timestamp, or `null` while in flight. | +| `trigger` | How the run was initiated, for example `{ source: "startup", runner: "migrate-mongo" }`. | +| `scope` | Queryable description of what the run was meant to operate on. | +| `runtime` | Host/runtime information captured at execution time. | +| `counts` | Numeric counters recorded by the automation. | +| `warnings` | Run-level warning counters or categories. | +| `verification` | Post-run verification checks. | +| `summary` | Human-readable outcome summary. | +| `error_summary` | Terminal failure detail when the run is `partial` or `failed`. | +| `items.collection` | Collection name storing per-item detail. | + +Extensible fields: + +| Field | Purpose | +|---|---| +| `metadata` | Automation-specific configuration or context that does not belong in the stable envelope. | + +### `automationRunItems` + +Each document represents the outcome for one processed unit of work. +That unit is intentionally generic: it can be a STIX object, a Mongo +document, a collection-level operation, or a system-level step. + +Stable fields: + +| Field | Purpose | +|---|---| +| `schema_version` | Version of the item taxonomy. | +| `run_id` | Foreign key to the parent run. | +| `automation_type` | Copied from the parent run for easier filtering. | +| `name` | Copied from the parent run for easier filtering. | +| `recorded_at` | UTC timestamp for when the item outcome was recorded. | +| `sequence` | Monotonic sequence number within the run. | +| `status` | Per-item outcome status. Common values are `changed`, `unchanged`, `skipped`, and `failed`. | +| `action` | Operation-specific verb, such as `normalize_x_mitre_platforms`. | +| `target` | Stable identity envelope for what the item refers to. | +| `warnings` | Optional array of warning codes affecting this item. | +| `error` | Serialized error detail when the item failed. | + +Extensible fields: + +| Field | Purpose | +|---|---| +| `details` | Automation-specific payload, such as before/after values, attempted versions, or extra counters. | + +## Target envelope + +`target` is intentionally generic. It should contain stable identity +information for the processed unit, but not automation-specific +payload. + +Common shape: + +```json +{ + "kind": "stix-object", + "collection": "attackObjects", + "stix_id": "attack-pattern--...", + "stix_type": "attack-pattern" +} +``` + +Other valid future shapes may use: + +- `document_id` for generic Mongo documents +- `collection` only for collection-level work +- `kind: "system"` for process-wide or singleton operations + +Consumers should treat `target` as the identity envelope and `details` +as the execution payload. + +## Why `scope` and `metadata` are separate + +`scope` exists for stable, queryable descriptors of what a run was +intended to touch. For example: + +```json +{ + "collections": ["attackObjects"], + "object_kinds": ["stix-object"], + "target_types": ["attack-pattern", "tool"] +} +``` + +`metadata` is for contextual detail that may vary widely from one +automation to the next, such as a migration’s replacement mapping or +the options passed to a scheduler task. + +Rule of thumb: + +- Put stable filtering dimensions in `scope`. +- Put automation-specific context in `metadata`. + +## Example documents + +Run document: + +```json +{ + "schema_version": 1, + "run_id": "2c43b1e4-0ef4-4f3d-9e77-0dd3a5d2f8cb", + "automation_type": "migration", + "name": "20260507130000-normalize-x-mitre-platforms", + "status": "completed", + "started_at": "2026-05-07T23:31:48.800Z", + "finished_at": "2026-05-07T23:31:49.300Z", + "trigger": { + "source": "startup", + "runner": "migrate-mongo" + }, + "scope": { + "collections": ["attackObjects"], + "object_kinds": ["stix-object"], + "target_types": ["x-mitre-asset", "attack-pattern"] + }, + "counts": { + "scanned_candidates": 4, + "attempted_reposts": 4, + "updated": 4, + "unchanged": 0, + "failed": 0, + "removed_platform_field": 0 + }, + "warnings": { + "existing_validation_issues": 4 + }, + "verification": { + "remaining_latest_active_objects_with_legacy_platforms": 0 + }, + "summary": { + "message": "Normalized x_mitre_platforms on 4 active latest object(s) after scanning 4 candidate(s); removed the field entirely on 0 object(s)." + }, + "items": { + "collection": "automationRunItems" + } +} +``` + +Item document: + +```json +{ + "schema_version": 1, + "run_id": "2c43b1e4-0ef4-4f3d-9e77-0dd3a5d2f8cb", + "automation_type": "migration", + "name": "20260507130000-normalize-x-mitre-platforms", + "recorded_at": "2026-05-07T23:31:49.100Z", + "sequence": 1, + "status": "changed", + "action": "normalize_x_mitre_platforms", + "target": { + "kind": "stix-object", + "collection": "attackObjects", + "stix_id": "x-mitre-asset--68388d4f-8138-420b-be2b-5a7dfe9ff6b4", + "stix_type": "x-mitre-asset" + }, + "warnings": ["existing_validation_issues"], + "details": { + "previous_modified": "2026-05-01T12:00:00.000Z", + "new_modified": "2026-05-07T23:31:49.001Z", + "existing_validation_error_count": 3, + "changes": [ + { + "field": "stix.x_mitre_platforms", + "before": ["Network"], + "after": ["Network Devices"] + } + ] + } +} +``` + +## Authoring rules for new automation + +When adding a new migration or scheduler task: + +1. Create one run document per execution. +2. Use `automation_type` for the coarse class and `name` for the + specific job. +3. Put stable filtering dimensions in `scope`. +4. Put job-specific configuration in `metadata`. +5. Use `target` for stable identity only. +6. Put operation-specific payload in `details`. +7. Prefer `counts` and `warnings` as numeric maps instead of encoding + those metrics only in free-form text. +8. Record item rows for changed and failed units of work at minimum. + Recording unchanged items is optional and should be justified by + the operational value versus collection growth. +9. Add a post-run `verification` check whenever the automation makes a + correctness claim that can be measured. + +## Evolution policy + +The taxonomy is expected to evolve, but the stable envelope should +change slowly. + +Rules: + +1. Additive fields inside `metadata`, `details`, `counts`, + `warnings`, and `verification` are safe. +2. Additive fields in the stable envelope are acceptable when broadly + useful. +3. Renaming or deleting stable envelope fields requires bumping + `schema_version`. +4. Consumers must not assume that `details` has the same shape across + different automation names. + +## Current implementation + +The reusable recorder lives in +[`app/lib/automation-run-recorder.js`](../../app/lib/automation-run-recorder.js). +The first consumer is the platform-normalization migration in +[`migrations/20260507130000-normalize-x-mitre-platforms.js`](../../migrations/20260507130000-normalize-x-mitre-platforms.js). diff --git a/docs/developer/cross-service-reads-pattern.md b/docs/developer/cross-service-reads-pattern.md new file mode 100644 index 00000000..d238a20a --- /dev/null +++ b/docs/developer/cross-service-reads-pattern.md @@ -0,0 +1,153 @@ +# Cross-Service Communication Pattern + +## Core Principle + +All cross-service communication — both reads and writes — MUST go through the EventBus. + +| Operation | Allowed? | Pattern | +|-----------|----------|---------| +| Service A reads from Service B's repository | ❌ NO | Use events instead | +| Service A writes to Service B's repository | ❌ NO | Use events instead | +| Service A emits event → Service B reads/writes its own data and returns results | ✅ YES | Event-driven pattern | + +## Why Cross-Service Reads Are No Longer Permitted + +Previously, direct cross-service reads were allowed because `EventBus.emit()` discarded +handler return values, making it impossible to request data from another service over the bus. + +With the updated EventBus (commit `9b62521`), `emit()` now collects and returns fulfilled +handler values via `Promise.allSettled`. This means any service can request data from another +service by emitting an event and receiving the result — eliminating the need for the +cross-service reads exception. + +**Benefits of routing reads through events:** + +1. **Uniform boundary enforcement** — one rule (use events) instead of two (events for writes, direct access for reads) +2. **Traceability** — all cross-service interactions are visible in the event log +3. **Decoupling** — services don't import each other's repositories or modules +4. **Testability** — event handlers are easier to mock than scattered repository imports + +## Design Patterns + +### ✅ Pattern: Request Data via Event + +**When to use:** A service needs data owned by another service (validation, denormalization, etc.) + +```javascript +// BaseService needs to check validation bypass rules (owned by ValidationBypassesService) +const EventBus = require('../../lib/event-bus'); +const Events = require('../../lib/event-constants'); + +const results = await EventBus.emit(Events.VALIDATION_BYPASS_CHECK_REQUESTED, { + errors: allErrors, + stixType, +}); + +const filteredErrors = results?.[0] ?? allErrors; +``` + +The owning service registers a handler that returns the requested data: + +```javascript +class ValidationBypassesService { + static initializeEventListeners() { + const EventBus = require('../../lib/event-bus'); + const Events = require('../../lib/event-constants'); + + EventBus.on( + Events.VALIDATION_BYPASS_CHECK_REQUESTED, + ValidationBypassesService.handleBypassCheckRequested.bind(ValidationBypassesService), + ); + } + + static async handleBypassCheckRequested(payload) { + const { errors, stixType } = payload; + // ... filter errors using own repository ... + return nonBypassedErrors; + } +} +``` + +### ✅ Pattern: Denormalize via Event + +**When to use:** Building cached metadata (embedded_relationships, computed fields) + +```javascript +class DetectionStrategiesService extends BaseService { + async beforeCreate(data, options) { + // Request analytic metadata via event + const results = await EventBus.emit('x-mitre-analytic::metadata-requested', { + analyticIds: data.stix.x_mitre_analytic_refs, + }); + + const analyticsMetadata = results?.[0] ?? []; + for (const meta of analyticsMetadata) { + data.workspace.embedded_relationships.push({ + stix_id: meta.stixId, + attack_id: meta.attackId, + name: meta.name, + direction: 'outbound', + }); + } + } + + async afterCreate(document, options) { + // Emit event so AnalyticsService can update its own documents + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategy: document, + analyticIds: document.stix.x_mitre_analytic_refs, + }); + } +} +``` + +### ❌ Anti-Pattern: Direct Cross-Service Import + +**Never do this:** + +```javascript +class BaseService { + async validateComposedObject(data) { + // WRONG: Direct import of another service + const validationBypassesService = require('../system/validation-bypasses-service'); + const bypassed = await validationBypassesService.isErrorBypassed(error, stixType); + } +} +``` + +**Why this is wrong:** +- Creates a hidden dependency between services +- Not visible in the event log +- Harder to test (must mock the imported module) +- Violates the single communication channel principle + +### ❌ Anti-Pattern: Direct Cross-Service Repository Read + +**Never do this:** + +```javascript +class DetectionStrategiesService extends BaseService { + async beforeCreate(data) { + // WRONG: Directly reading from another service's repository + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + } +} +``` + +## Event Return Value Convention + +When emitting an event that expects a return value: + +1. **Single handler expected** — access `results[0]` +2. **Always provide a fallback** — use `results?.[0] ?? fallback` in case no handler is registered +3. **Handler returns data directly** — no wrapper objects needed; the EventBus filters out `null`/`undefined` results + +## Migration Checklist + +When converting a cross-service read to the event-driven pattern: + +1. Define a new event constant in `app/lib/event-constants.js` +2. Register an event handler in the owning service's `initializeEventListeners()` +3. Have the handler return the requested data +4. Replace the direct import/read with `EventBus.emit()` and use the returned value +5. Add a fallback for when no handler is registered (defensive coding) diff --git a/docs/data-model.md b/docs/developer/data-model.md similarity index 100% rename from docs/data-model.md rename to docs/developer/data-model.md diff --git a/docs/developer/event-bus-architecture.md b/docs/developer/event-bus-architecture.md new file mode 100644 index 00000000..5ce78bcf --- /dev/null +++ b/docs/developer/event-bus-architecture.md @@ -0,0 +1,476 @@ +# Event Bus Architecture Design + +## Overview + +This document defines the event-driven architecture for managing cross-document dependencies and derived properties in the ATT&CK Workbench REST API. + +## Core Principles + +### 1. Lifecycle Hooks Pattern + +Each CRUD operation has exactly **three lifecycle stages**: + +``` +beforeX → X → afterX → emitXEvent +``` + +For example: +- `beforeCreate` → `create` → `afterCreate` → `emitCreatedEvent` +- `beforeUpdate` → `update` → `afterUpdate` → `emitUpdatedEvent` +- `beforeDelete` → `delete` → `afterDelete` → `emitDeletedEvent` + +**Execution Order:** + +1. **`beforeX` hook** - Child service can modify data before persistence +2. **`X` operation** - BaseService persists the Mongoose document to database +3. **`afterX` hook** - Child service can perform side-effects after persistence +4. **`emitXEvent` method** - BaseService emits event to EventBus for cross-service coordination + +**Key Rules:** + +- The terms "before" and "after" refer explicitly to **database persistence** +- Child services override `beforeX` and `afterX` hooks, NOT the main operation +- BaseService orchestrates the execution order automatically +- Child services do NOT call other lifecycle hooks explicitly +- Side-effects that modify OTHER documents belong in `afterX` hooks or in event listeners + +### 2. Service-to-Service Communication via Domain Events + +**Architecture Pattern:** + +``` +ServiceA (modifies its own documents) + ↓ emits domain event +EventBus + ↓ delivers to listeners +ServiceB (modifies its own documents in response) +``` + +**Each service:** +- ✅ **Owns its complete document** (both `stix` and `workspace` fields) +- ✅ **Only modifies documents of its own type** +- ✅ **Emits domain events** when changes affect other services +- ✅ **Listens to domain events** from other services and responds + +**Cross-Service Communication Rules:** + +| Operation | Allowed? | Pattern | +|-----------|----------|---------| +| Cross-service WRITES | ❌ NO | Use events instead | +| Cross-service READS | ❌ NO | Use events instead (handlers can return data) | + +All cross-service communication — reads and writes — MUST go through the EventBus. +See [cross-service-reads-pattern.md](cross-service-reads-pattern.md) for details on how +`EventBus.emit()` returns fulfilled handler values, enabling request/response over events. + +**Example:** +```javascript +// BaseService requests validation bypass filtering via event +const results = await EventBus.emit(Events.VALIDATION_BYPASS_CHECK_REQUESTED, { + errors: allErrors, + stixType, +}); +const errors = results?.[0] ?? allErrors; + +// DetectionStrategiesService emits event so AnalyticsService updates its own documents +async afterCreate(document) { + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { ... }); +} +``` + +**No Generic Manager Services:** +- ❌ No `EmbeddedRelationshipsManager` - services handle their own relationships +- ❌ No `ExternalReferencesManager` - services handle their own references +- ✅ Services communicate directly via domain-specific events + +### 3. Separation of Concerns + +Each STIX document has two top-level keys: +- `stix` - STIX 2.1 specification fields +- `workspace` - ATT&CK Workbench metadata + +**Ownership Model:** + +| Component | Responsibility | Authority | +|-----------|---------------|-----------| +| **SDO Services** (e.g., DetectionStrategiesService, AnalyticsService) | Manage both `stix` and `workspace` fields for their own document type | Full authority over own document properties | +| **Cross-Service Communication** | Via domain events on EventBus | Services request actions, don't directly modify other documents | + +**Rationale:** + +- **SDO services own their complete document** - both `stix` and `workspace` fields +- **Cross-document coordination** happens via events, not direct method calls +- **Each service is responsible** for maintaining its own metadata, including `workspace.embedded_relationships` +- **Keep it simple** - direct service-to-service communication is easier to trace than generic managers + +### 4. Event Bus Messaging + +**Event Naming Convention:** + +``` +::[.] +``` + +Where: +- `subject` = The entity type (lowercase STIX type, hyphenated for custom types) +- `action` = Past tense verb describing what happened +- `detail` = Optional qualifier for specialized events + +**Examples:** +- `x-mitre-detection-strategy::created` - Base CRUD event +- `x-mitre-detection-strategy::analytics-referenced` - Domain event +- `x-mitre-detection-strategy::analytics-removed` - Domain event +- `x-mitre-analytic::parent-changed` - Domain event + +**Event Payload Structure:** + +```javascript +{ + // Core identifiers + stixId: 'x-mitre-detection-strategy--...', + stixModified: '2024-01-15T10:30:00.000Z', + + // The affected document (plain object) + document: { ... }, + + // Domain-specific context + analyticIds: [...], // For analytics-referenced events + + // Additional context + options: { ... } +} +``` + +## Event Catalog + +### Core CRUD Events (Emitted by BaseService) + +| Event | When Emitted | Payload | Use Cases | +|-------|--------------|---------|-----------| +| `{type}::created` | After `afterCreate` hook | `{ stixId, document, type, options }` | Audit logging, notifications | +| `{type}::updated` | After `afterUpdate` hook | `{ stixId, stixModified, document, previousDocument, type }` | Track changes, propagate updates | +| `{type}::deleted` | After `afterDelete` hook | `{ stixId, document, options }` | Cleanup, cascade deletes | + +Where `{type}` is the STIX type (e.g., `attack-pattern`, `x-mitre-analytic`, `x-mitre-detection-strategy`). + +### Domain Events (Emitted by SDO Services) + +| Event | Emitted By | When | Payload | Listeners | +|-------|------------|------|---------|-----------| +| `x-mitre-detection-strategy::analytics-referenced` | DetectionStrategiesService | When detection strategy references analytics (create/update) | `{ detectionStrategyId, detectionStrategy, analyticIds }` | AnalyticsService | +| `x-mitre-detection-strategy::analytics-removed` | DetectionStrategiesService | When analytics removed from detection strategy | `{ detectionStrategyId, analyticIds }` | AnalyticsService | +| `x-mitre-analytic::parent-changed` | AnalyticsService | When analytic's parent detection strategy changes | `{ analyticId, oldParentId, newParentId, analytic }` | (Future: for cascading updates) | + +## Workflow Examples + +### Workflow 1: Create Detection Strategy with Analytics + +**User Action:** `POST /api/detection-strategies` with `x_mitre_analytic_refs: ['x-mitre-analytic--123']` + +**Execution Flow:** + +1. **DetectionStrategiesService.beforeCreate(data, options)** + - Initialize `data.workspace.embedded_relationships = []` + - For each analytic ref, build outbound embedded_relationship: + ```javascript + { + stix_id: 'x-mitre-analytic--123', + attack_id: 'DA-0001', // fetched from analytic + direction: 'outbound' + } + ``` + - Add to `data.workspace.embedded_relationships` + +2. **BaseService.create()** - Persist document to database + +3. **DetectionStrategiesService.afterCreate(document, options)** + - Emit domain event: `x-mitre-detection-strategy::analytics-referenced` + ```javascript + { + detectionStrategyId: 'x-mitre-detection-strategy--abc', + detectionStrategy: { ... }, + analyticIds: ['x-mitre-analytic--123'] + } + ``` + +4. **BaseService.emitCreatedEvent()** - Emit `x-mitre-detection-strategy::created` + +5. **AnalyticsService** listener receives `analytics-referenced` event + - For each analyticId: + - Fetch the analytic document + - Add inbound embedded_relationship: + ```javascript + { + stix_id: 'x-mitre-detection-strategy--abc', + attack_id: 'DS0001', + direction: 'inbound' + } + ``` + - Update analytic's `external_references` with URL: `https://attack.mitre.org/detectionstrategies/DS0001#DA-0001` + - Save the analytic + +### Workflow 2: Update Detection Strategy - Add Analytic + +**User Action:** `PUT /api/detection-strategies/{id}/{modified}` +- Change `x_mitre_analytic_refs` from `[]` to `['x-mitre-analytic--123']` + +**Execution Flow:** + +1. **DetectionStrategiesService.beforeUpdate(stixId, stixModified, data, existingDocument)** + - Detect change: `oldRefs = []`, `newRefs = ['x-mitre-analytic--123']` + - Store: `this._addedAnalyticRefs = ['x-mitre-analytic--123']` + - Rebuild outbound embedded_relationships for new refs + - Update `data.workspace.embedded_relationships` + +2. **BaseService.updateFull()** - Persist document to database + +3. **DetectionStrategiesService.afterUpdate(updatedDocument, previousDocument)** + - If `_addedAnalyticRefs` not empty: + - Emit `x-mitre-detection-strategy::analytics-referenced` + - Clean up: `delete this._addedAnalyticRefs` + +4. **BaseService.emitUpdatedEvent()** - Emit `x-mitre-detection-strategy::updated` + +5. **AnalyticsService** listener receives event and updates analytics + +### Workflow 3: Update Detection Strategy - Remove Analytic + +**User Action:** `PUT /api/detection-strategies/{id}/{modified}` +- Change `x_mitre_analytic_refs` from `['x-mitre-analytic--123']` to `[]` + +**Execution Flow:** + +1. **DetectionStrategiesService.beforeUpdate(...)** + - Detect change: `removedRefs = ['x-mitre-analytic--123']` + - Store: `this._removedAnalyticRefs = ['x-mitre-analytic--123']` + - Rebuild outbound embedded_relationships (now empty) + +2. **BaseService.updateFull()** - Persist document + +3. **DetectionStrategiesService.afterUpdate(...)** + - If `_removedAnalyticRefs` not empty: + - Emit `x-mitre-detection-strategy::analytics-removed` + ```javascript + { + detectionStrategyId: 'x-mitre-detection-strategy--abc', + analyticIds: ['x-mitre-analytic--123'] + } + ``` + - Clean up: `delete this._removedAnalyticRefs` + +4. **AnalyticsService** listener receives event + - For each analyticId: + - Fetch the analytic + - Remove inbound embedded_relationship with `stix_id: 'x-mitre-detection-strategy--abc'` + - Remove ATT&CK external reference (no parent) + - Save the analytic + +## Implementation Guidelines + +### For SDO Services + +**DO:** +- Override `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate` as needed +- Manage both `stix` and `workspace` fields for your own document type +- Emit domain events when changes affect other services +- Listen to domain events from other services +- Only modify documents of your own type + +**DON'T:** +- Override the main CRUD methods (`create`, `updateFull`, `delete`) +- Directly modify documents managed by other services +- Access repositories for other document types just to read - use events +- Implement cross-document logic without events + +**Example Service Structure:** + +```javascript +class DetectionStrategiesService extends BaseService { + // Build outbound relationships before save + async beforeCreate(data, options) { + // Modify data.workspace.embedded_relationships + } + + // Emit domain event after save + async afterCreate(document, options) { + if (document.stix.x_mitre_analytic_refs?.length > 0) { + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: document.stix.id, + detectionStrategy: document.toObject(), + analyticIds: document.stix.x_mitre_analytic_refs + }); + } + } + + // Similar pattern for beforeUpdate/afterUpdate +} +``` + +### For Event Listeners + +**DO:** +- Initialize listeners in a static `initializeEventListeners()` method +- Handle events asynchronously +- Modify only documents of your own type +- Log errors and continue (don't throw) +- Emit follow-up events if needed + +**DON'T:** +- Emit CRUD events (those come from BaseService) +- Assume event ordering +- Perform blocking operations +- Throw errors that would break the event chain + +**Example Event Listener:** + +```javascript +class AnalyticsService extends BaseService { + static initializeEventListeners() { + const EventBus = require('../lib/event-bus'); + + EventBus.on( + 'x-mitre-detection-strategy::analytics-referenced', + this.handleAnalyticsReferenced.bind(this) + ); + + EventBus.on( + 'x-mitre-detection-strategy::analytics-removed', + this.handleAnalyticsRemoved.bind(this) + ); + + logger.info('AnalyticsService: Event listeners initialized'); + } + + static async handleAnalyticsReferenced(payload) { + const { detectionStrategy, analyticIds } = payload; + + for (const analyticId of analyticIds) { + try { + const analytic = await analyticsRepository.retrieveOneLatestByStixId(analyticId); + + // Add inbound relationship + if (!analytic.workspace.embedded_relationships) { + analytic.workspace.embedded_relationships = []; + } + analytic.workspace.embedded_relationships.push({ + stix_id: detectionStrategy.stix.id, + attack_id: detectionStrategy.workspace?.attack_id, + direction: 'inbound' + }); + + // Update external_references + // ... rebuild with new URL ... + + await analyticsRepository.saveDocument(analytic); + } catch (error) { + logger.error(`Error handling analytics-referenced for ${analyticId}:`, error); + // Continue processing other analytics + } + } + } +} +``` + +### Event Bus Best Practices + +**DO:** +- Use descriptive domain event names +- Include all relevant context in event payloads +- Use `await` for event emissions to ensure completion before response +- Log events at appropriate levels +- Handle Promise rejections in event handlers + +**DON'T:** +- Emit events from within event handlers (can cause cycles) +- Include sensitive data in event payloads +- Rely on handler execution order +- Use events for synchronous validation (use lifecycle hooks) + +## EventBus Implementation + +Our EventBus extends Node.js's native `EventEmitter` with additional features: + +1. **Built on Node.js Standard Library** - Leverages `events.EventEmitter` for reliability +2. **Async/Await Support** - Overrides `emit()` to handle async listeners with `Promise.allSettled` +3. **Handler Return Values** - `emit()` collects and returns fulfilled handler values, enabling request/response patterns over the bus +4. **Logging & Debugging** - Logs all event registrations and emissions +5. **Event Audit Trail** - Maintains circular buffer of recent events for debugging +6. **Singleton Pattern** - Single shared bus instance across the application +7. **Increased Max Listeners** - Set to 50 to accommodate multiple services subscribing to common events + +The implementation is minimal - we only add what's necessary beyond the standard EventEmitter. + +## Architecture Benefits + +### 1. **Traceability** +- Domain event names clearly show what's happening in the business logic +- Logs show: "DetectionStrategiesService referenced analytics" → "AnalyticsService handling reference" +- Easy to follow the flow through the system + +### 2. **Ownership & Responsibility** +- Each service owns its complete document (stix + workspace) +- Clear boundaries: DetectionStrategiesService modifies detection strategies, AnalyticsService modifies analytics +- No shared generic services that modify multiple document types + +### 3. **Simplicity** +- Direct service-to-service communication via events +- No intermediate manager/coordinator services +- Fewer moving parts to understand + +### 4. **Maintainability** +- Changes to relationship logic are localized in the relevant services +- Event names document the domain interactions +- Easy to add new relationship types without infrastructure changes + +### 5. **Flexibility** +- Each service can implement domain-specific logic +- Validation rules specific to each document type +- No need to fit into generic patterns + +## Design Decisions + +### 1. **Direct Service-to-Service Communication** + - ✅ DetectionStrategiesService emits `analytics-referenced` event + - ✅ AnalyticsService listens and responds + - ❌ No generic `EmbeddedRelationshipsManager` intermediary + - **Rationale:** Simpler, more traceable, respects service ownership + +### 2. **Domain Events over Generic Events** + - ✅ `x-mitre-detection-strategy::analytics-referenced` (clear business meaning) + - ❌ `embedded-relationships::add-requested` (generic infrastructure) + - **Rationale:** Domain events tell the story of what happened in business terms + +### 3. **Services Own Complete Documents** + - ✅ Each service manages both `stix` and `workspace` fields + - ✅ Services only modify their own document types + - ❌ No shared services that modify workspace across document types + - **Rationale:** Clear ownership, single source of truth per document type + +### 4. **YAGNI - Only Build What's Needed** + - We have 1-2 embedded relationship use cases + - Generic infrastructure would be over-engineering + - If we need more patterns later, we can extract commonalities then + - **Rationale:** Optimize for the current reality, not hypothetical future + +### 5. **Request/Response Blocking** + - Services `await` event emissions + - All event handlers complete before HTTP response + - Ensures data consistency from user's perspective + - **Rationale:** REST API semantics - operations complete before response + +## Migration Status + +### Completed ✅ +- EventBus implementation (based on Node.js EventEmitter) +- Lifecycle hooks in BaseService (beforeX, afterX patterns) +- Event constants enumeration +- Architecture documentation + +### In Progress 🚧 +- Refactoring DetectionStrategiesService to emit domain events +- Implementing AnalyticsService event listeners + +### Remaining +- End-to-end testing +- Remove temporary test listeners +- Update other services if they need similar patterns diff --git a/docs/developer/implementation-approach.md b/docs/developer/implementation-approach.md new file mode 100644 index 00000000..7c7530d7 --- /dev/null +++ b/docs/developer/implementation-approach.md @@ -0,0 +1,283 @@ +# Implementation Approach: Service-to-Service Event-Driven Architecture + +## Overview + +This document describes an implementation approach for managing cross-document relationships using an event-driven architecture with direct service-to-service communication. + +## Architecture: Direction Service-to-Service Communication + +### Why This Approach? + +1. **Simpler** - No intermediate manager services +2. **More Traceable** - Domain event names clearly show business logic +3. **Better Ownership** - Each service owns its complete document +4. **YAGNI Principle** - Don't build generic infrastructure for 1-2 use cases +5. **Easier to Debug** - Direct communication path is clearer + +## Pattern + +``` +ServiceA (owns Document Type A) + │ + ├─ beforeCreate: Modify own document before save + ├─ create: [BaseService saves to database] + ├─ afterCreate: Emit domain event about what changed + │ + ↓ EventBus + │ +ServiceB (owns Document Type B) + │ + ├─ Event Listener: Receives domain event + └─ Response: Modifies own documents accordingly +``` + +## Example: Detection Strategies ↔ Analytics + +### Scenario + +When a detection strategy references analytics via `x_mitre_analytic_refs`: +- Detection strategy needs **outbound** embedded_relationships +- Analytics need **inbound** embedded_relationships +- Analytics' `external_references` need URLs pointing to parent detection strategy + +### Implementation + +```javascript +// ============================================================================ +// DetectionStrategiesService +// ============================================================================ + +class DetectionStrategiesService extends BaseService { + /** + * beforeCreate: Build outbound relationships on detection strategy + */ + async beforeCreate(data, options) { + data.workspace.embedded_relationships = []; + + const analyticRefs = data.stix?.x_mitre_analytic_refs || []; + + for (const analyticId of analyticRefs) { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + data.workspace.embedded_relationships.push({ + stix_id: analyticId, + attack_id: analytic?.workspace?.attack_id, + direction: 'outbound' + }); + } + } + + /** + * afterCreate: Emit domain event to notify AnalyticsService + */ + async afterCreate(document, options) { + const analyticRefs = document.stix?.x_mitre_analytic_refs || []; + + if (analyticRefs.length > 0) { + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: document.stix.id, + detectionStrategy: document.toObject(), + analyticIds: analyticRefs + }); + } + } + + /** + * beforeUpdate: Detect changes and rebuild outbound relationships + */ + async beforeUpdate(stixId, stixModified, data, existingDocument) { + const oldRefs = existingDocument.stix?.x_mitre_analytic_refs || []; + const newRefs = data.stix?.x_mitre_analytic_refs || []; + + // Store for afterUpdate + this._addedRefs = newRefs.filter(ref => !oldRefs.includes(ref)); + this._removedRefs = oldRefs.filter(ref => !newRefs.includes(ref)); + + // Rebuild outbound relationships + // ... (same logic as beforeCreate) + } + + /** + * afterUpdate: Emit events for added/removed analytics + */ + async afterUpdate(updatedDocument, previousDocument) { + if (this._addedRefs?.length > 0) { + await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { + detectionStrategyId: updatedDocument.stix.id, + detectionStrategy: updatedDocument.toObject(), + analyticIds: this._addedRefs + }); + } + + if (this._removedRefs?.length > 0) { + await EventBus.emit('x-mitre-detection-strategy::analytics-removed', { + detectionStrategyId: updatedDocument.stix.id, + analyticIds: this._removedRefs + }); + } + + delete this._addedRefs; + delete this._removedRefs; + } +} + +// ============================================================================ +// AnalyticsService +// ============================================================================ + +class AnalyticsService extends BaseService { + /** + * Initialize event listeners on app startup + */ + static initialize() { + EventBus.on( + 'x-mitre-detection-strategy::analytics-referenced', + this.handleAnalyticsReferenced.bind(this) + ); + + EventBus.on( + 'x-mitre-detection-strategy::analytics-removed', + this.handleAnalyticsRemoved.bind(this) + ); + } + + /** + * Handle analytics being referenced by a detection strategy + */ + static async handleAnalyticsReferenced(payload) { + const { detectionStrategy, analyticIds } = payload; + + for (const analyticId of analyticIds) { + const analytic = await analyticsRepository.retrieveOneLatestByStixId(analyticId); + + // Add inbound embedded_relationship + if (!analytic.workspace.embedded_relationships) { + analytic.workspace.embedded_relationships = []; + } + + analytic.workspace.embedded_relationships.push({ + stix_id: detectionStrategy.stix.id, + attack_id: detectionStrategy.workspace?.attack_id, + direction: 'inbound' + }); + + // Update external_references with URL to parent + const attackRef = createAttackExternalReference(analytic.toObject()); + if (attackRef) { + analytic.stix.external_references = + removeAttackExternalReferences(analytic.stix.external_references); + analytic.stix.external_references.unshift(attackRef); + } + + await analyticsRepository.saveDocument(analytic); + } + } + + /** + * Handle analytics being removed from a detection strategy + */ + static async handleAnalyticsRemoved(payload) { + const { detectionStrategyId, analyticIds } = payload; + + for (const analyticId of analyticIds) { + const analytic = await analyticsRepository.retrieveOneLatestByStixId(analyticId); + + // Remove inbound embedded_relationship + analytic.workspace.embedded_relationships = + analytic.workspace.embedded_relationships.filter( + rel => !(rel.stix_id === detectionStrategyId && rel.direction === 'inbound') + ); + + // Remove external_reference (no parent anymore) + analytic.stix.external_references = + removeAttackExternalReferences(analytic.stix.external_references); + + await analyticsRepository.saveDocument(analytic); + } + } +} +``` + +## Key Points + +### 1. **Each Service Owns Its Documents** +- DetectionStrategiesService modifies `detection-strategy.workspace.embedded_relationships` +- AnalyticsService modifies `analytic.workspace.embedded_relationships` +- AnalyticsService modifies `analytic.stix.external_references` + +### 2. **Communication via Domain Events** +- `x-mitre-detection-strategy::analytics-referenced` - Clear what happened +- `x-mitre-detection-strategy::analytics-removed` - Clear what happened +- NOT generic like `embedded-relationships::add-requested` + +### 3. **Lifecycle Hooks for Timing** +- `beforeCreate/beforeUpdate` - Modify own document **before** save +- `afterCreate/afterUpdate` - Emit events **after** save +- Event listeners - Modify own documents **in response** to other services + +### 4. **Initialization** +- Services with event listeners must call `initialize()` on app startup +- In `app/index.js`: + ```javascript + const AnalyticsService = require('./services/analytics-service'); + AnalyticsService.initialize(); + ``` + +## Benefits + +### ✅ Traceability +Logs show clear business flow: +``` +DetectionStrategiesService: Emitting 'x-mitre-detection-strategy::analytics-referenced' +AnalyticsService: Handling analytics being referenced +AnalyticsService: Added inbound relationship to analytic x-mitre-analytic--123 +AnalyticsService: Updated external_references URL +``` + +### ✅ Simplicity +- Two services communicating directly +- No intermediate manager/coordinator +- Easy to understand the flow + +### ✅ Ownership +- DetectionStrategiesService is responsible for detection strategies +- AnalyticsService is responsible for analytics +- Clear boundaries + +### ✅ Flexibility +- Each service can implement domain-specific logic +- No need to fit into generic patterns +- Easy to add validation specific to each type + +### ✅ Testability +- Mock EventBus +- Test DetectionStrategiesService emits correct events +- Test AnalyticsService handles events correctly +- Clear test boundaries + +## Comparison with Generic Manager Approach + +| Aspect | Direct Communication ✅ | Generic Manager ❌ | +|--------|------------------------|-------------------| +| **Event Names** | `detection-strategy::analytics-referenced` | `embedded-relationships::add-requested` | +| **Traceability** | Clear business domain flow | Generic infrastructure flow | +| **Ownership** | Services own their documents | Manager modifies multiple types | +| **Complexity** | 2 services | 3 services (+ manager) | +| **Reusability** | Each pair custom | Generic for all | +| **When to Use** | Known use cases (1-2) | Many similar patterns (5+) | + +## Current Status + +- ✅ EventBus implemented +- ✅ Lifecycle hooks in BaseService +- ✅ Architecture documented +- 🚧 Refactoring DetectionStrategiesService to emit domain events +- 🚧 Implementing AnalyticsService event listeners +- ⏳ Testing end-to-end + +## Next Steps + +1. Remove `EmbeddedRelationshipsService` (no longer needed) +2. Refactor `DetectionStrategiesService` to use lifecycle hooks + domain events +3. Implement `AnalyticsService.initialize()` and event handlers +4. Test end-to-end workflows +5. Clean up temporary test listeners diff --git a/docs/developer/import-fidelity-contract.md b/docs/developer/import-fidelity-contract.md new file mode 100644 index 00000000..d087ba65 --- /dev/null +++ b/docs/developer/import-fidelity-contract.md @@ -0,0 +1,203 @@ +# Import-Fidelity Contract + +When a STIX bundle is imported, the persisted objects must be +**byte-faithful** to the bundle's `stix` content. Workbench may +populate its private `workspace` metadata on each document, but +must not deviate any field under `stix`. + +This document defines the contract, explains why it exists, and +tells you how to author hooks, event listeners, and any new code +that runs during a bundle import. + +For the broader bundle-import pipeline, see +[`stix-bundle-import-pipeline.md`](./stix-bundle-import-pipeline.md). + +## Why the contract exists + +Workbench has a richer object model than raw STIX. Lifecycle hooks +and event listeners legitimately normalize and enrich STIX fields +on user-driven flows. A few examples that ship today: + +| Service | Hook / listener | Stix mutation | +|---|---|---| +| AnalyticsService | `beforeCreate` | Stamps `stix.name = "Analytic "` | +| AnalyticsService | `handleAnalyticsReferenced` listener | Rewrites `stix.external_references` to embed a URL to the parent detection strategy | +| CampaignsService | `beforeCreate` | Forces `stix.aliases[0]` to equal `stix.name` | +| GroupsService | `beforeCreate` | Same alias normalization as campaigns | +| SoftwareService | `beforeCreate` | Defaults `stix.is_family = true` for malware; normalizes `stix.x_mitre_aliases` | + +All five are correct on POST/PUT, where Workbench is the authority +on the object's display values. None of them are correct during an +import: the bundle is the source of truth for `stix.*`, and a +silent rewrite breaks round-trip fidelity and obscures the +provenance of the imported content. + +The framework therefore enforces a hard rule: + +> **During an import (`options.import === true`), `stix.*` is +> read-only. Workspace fields are still mutable.** + +## How the contract is enforced + +[`app/lib/import-safety.js`](../../app/lib/import-safety.js) exports +`deepFreezeStix(doc)`. It calls `Object.freeze` on `doc.stix` and on +the immediate children (nested objects, nested arrays, and the +array elements). In Node strict mode (`'use strict'` at the top of +every service file), an attempted write to any frozen property +throws `TypeError` immediately, pointing at the violating line. + +The framework calls `deepFreezeStix` at every point where untrusted +code (a hook, a listener) is about to run during an import: + +| Location | When | +|---|---| +| `BaseService._createFromImport` | Before `beforeCreate(composed, options)` and before `afterCreate(doc, options)` / `emitCreatedEvent(doc, options)`. | +| `collection-bundles-service/import-bundle.js` (compose worker) | Before each call to `service.beforeCreate(composed, composeOptions)`. | +| `collection-bundles-service/import-bundle.js` (post-insert worker) | Before each call to `service.afterCreate(doc, composeOptions)` and `service.emitCreatedEvent(doc, composeOptions)`. | + +Listeners that fetch a related document and mutate it then call +`deepFreezeStix(fetchedDoc)` themselves on entry when their +incoming payload indicates an import is in progress. The pattern +is one line: + +```js +if (payload.options?.import) deepFreezeStix(fetchedDoc); +``` + +The freeze is invisible to non-import paths: `deepFreezeStix` is +only called when the framework or a listener has confirmed +`options.import === true`. + +### Why deep, not shallow + +`Object.freeze` is shallow. Common stix mutations target nested +structures: + +- `analytic.stix.external_references.unshift(...)` (rewrites an array) +- `analytic.stix.external_references[0].external_id = '...'` (mutates an array element) + +A shallow freeze would let both succeed silently. `deepFreezeStix` +walks one level into objects and arrays (including array elements) +to cover the cases STIX content actually exhibits. Reads remain +unaffected at any depth. + +### Why freeze instead of clone-and-restore + +A snapshot-and-restore approach (clone `stix` before each hook, +restore after) would also enforce fidelity, but it requires the +framework to know which side effects to undo and risks leaving +half-written state when a hook mutates nested objects. A freeze +fails closed at the violating line, points the developer at +exactly the code that needs a gate, and adds zero runtime +overhead after the freeze is applied. + +## Author rules — writing hooks and listeners + +The contract translates into one rule for hook authors: + +> Workspace mutations are always allowed. Wrap any `stix.*` +> mutation in `if (!options?.import) { ... }`. + +A correctly-shaped `beforeCreate` looks like this: + +```js +async beforeCreate(data, options) { + // Workspace mutations — always allowed. + data.workspace = data.workspace || {}; + data.workspace.embedded_relationships = buildOutboundRels(data); + + // STIX mutations — gated. The framework freezes data.stix + // during import, so a missing gate throws a TypeError pointing + // at the line below on the first import test. + if (!options?.import) { + data.stix.name = deriveNameFromAttackId(data.workspace.attack_id); + } +} +``` + +And a correctly-shaped listener: + +```js +static async handleAnalyticsReferenced(payload) { + const { detectionStrategy, analyticIds, options } = payload; + + for (const analyticId of analyticIds) { + const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); + if (!analytic) continue; + + // Import-fidelity guard. The framework freezes the doc the + // emitter saw, but listeners fetch their own related docs — + // so each listener takes responsibility for freezing what + // it fetched. + if (options?.import) deepFreezeStix(analytic); + + // Workspace mutations — always allowed. + addInboundEmbeddedRelationship(analytic, detectionStrategy); + + // STIX mutations — gated, just like in beforeCreate. + if (!options?.import) { + refreshExternalReferencesUrl(analytic, detectionStrategy); + } + + await analyticsRepository.saveDocument(analytic); + } +} +``` + +## Forwarding `options` to listeners + +Listeners can only honor the contract if the originating service +forwards its create-options into the emitted event payload. The +three afterCreate emit sites that drive metadata cascades all do +this: + +- `DetectionStrategiesService.afterCreate(document, options)` + passes `options` into every `'x-mitre-detection-strategy::*'` + emit. +- `AnalyticsService.afterCreate(createdDocument, options)` + passes `options` into `'x-mitre-analytic::data-components-referenced'`. +- `DataComponentsService.afterCreate(createdDocument, options)` + passes `options` into `'x-mitre-data-component::data-source-*'`. + +If you add a new domain event that may fire during import, do the +same — include `options` in the payload. + +## Adding a new hook or listener + +Checklist for an author adding code that runs during a bundle +import: + +1. **Default to workspace.** If your work can be expressed as + workspace metadata (an embedded relationship, a derived index, + a denormalized cache), keep it under `workspace.*` — no gate + needed. + +2. **Gate stix writes.** If you genuinely need to mutate + `stix.*`, wrap the block in `if (!options?.import) { ... }`. + Forgetting the gate will not silently break things: the next + import test will crash with a TypeError pointing at your line. + +3. **Listeners freeze what they fetch.** If your listener fetches + a related document via a repository and may write to its + `stix.*`, add `if (options?.import) deepFreezeStix(fetched);` + at the top of the per-document block. + +4. **Emit `options` in event payloads.** If your service emits a + domain event from `afterCreate` / `afterUpdate`, include + `options` in the payload so downstream listeners can see when + an import is in progress. + +5. **Test it.** Round-trip a bundle through import — export, hash + the persisted `stix` content of a sample of objects, compare + to the bundle. If you forgot a gate, you'll have crashed on + the import attempt before you ever reach the comparison. + +## Files + +| Path | Role | +|---|---| +| [`app/lib/import-safety.js`](../../app/lib/import-safety.js) | `deepFreezeStix` helper and contract documentation. | +| [`app/services/meta-classes/base.service.js`](../../app/services/meta-classes/base.service.js) | Framework-level freeze in `_createFromImport`. | +| [`app/services/stix/collection-bundles-service/import-bundle.js`](../../app/services/stix/collection-bundles-service/import-bundle.js) | Framework-level freeze in the bulk pipeline. | +| [`app/services/stix/analytics-service.js`](../../app/services/stix/analytics-service.js) | Example of both forms of gate (`beforeCreate` and listener). | +| [`app/services/stix/detection-strategies-service.js`](../../app/services/stix/detection-strategies-service.js) | Example of forwarding `options` into event payloads. | diff --git a/docs/developer/lifecycle-hooks-guide.md b/docs/developer/lifecycle-hooks-guide.md new file mode 100644 index 00000000..b3d6239b --- /dev/null +++ b/docs/developer/lifecycle-hooks-guide.md @@ -0,0 +1,396 @@ +# Lifecycle Hooks Pattern Guide + +## Overview + +The BaseService lifecycle hooks pattern provides a structured way for child services to customize CRUD operations without overriding entire methods. This guide clarifies what the BaseService handles vs what child services should override. + +## The Three-Stage Lifecycle + +Every CRUD operation follows this pattern: + +``` +beforeX → X → afterX → emitXEvent +``` + +## What BaseService Handles (The "X" Stage) + +### During `create()`: + +**BaseService ALWAYS handles:** + +1. **Type validation** - Ensures `data.stix.type` matches service type +2. **ATT&CK ID generation** - Generates and assigns `workspace.attack_id` (if applicable) +3. **ATT&CK ID immutability checks** - Prevents users from manually setting ATT&CK IDs +4. **External references validation** - Ensures users don't manually set ATT&CK external references +5. **External reference generation** - Creates and adds ATT&CK external reference +6. **ATT&CK spec version** - Sets `x_mitre_attack_spec_version` +7. **Workflow metadata** - Records `created_by_user_account` +8. **Default marking definitions** - Applies default TLP markings +9. **Organization identity** - Sets `created_by_ref` and `x_mitre_modified_by_ref` +10. **STIX ID generation** - Generates UUID-based STIX ID if not provided +11. **Database persistence** - Calls `repository.save(data)` + +### During `updateFull()`: + +**BaseService ALWAYS handles:** + +1. **Fetch existing document** - Retrieves document by version +2. **ATT&CK ID immutability checks** - Ensures `workspace.attack_id` hasn't changed +3. **External references validation** - Validates client-provided ATT&CK references match expected values +4. **External reference repair** - Adds missing URLs or entire references if needed +5. **Database persistence** - Calls `repository.updateAndSave()` + +### During `delete()`: + +**BaseService ALWAYS handles:** + +1. **Fetch and delete** - Retrieves and removes document +2. **Database persistence** - Calls `repository.delete()` + +## What Child Services Should Override + +### Use `beforeX` hooks when you need to: + +#### `beforeCreate(data, options)` + +**MODIFY DATA before it's saved:** + +- ✅ Set default values for STIX properties +- ✅ Validate domain-specific rules +- ✅ Compute derived properties that go INTO the document being created +- ✅ Build `workspace.embedded_relationships` (outbound) +- ✅ Transform user input into canonical format + +**Examples:** +```javascript +async beforeCreate(data, options) { + // Example 1: Set defaults + if (data.stix.type === 'malware' && typeof data.stix.is_family !== 'boolean') { + data.stix.is_family = true; + } + + // Example 2: Build embedded relationships + const analyticRefs = data.stix?.x_mitre_analytic_refs || []; + if (analyticRefs.length > 0) { + const embeddedRels = await EmbeddedRelationshipsManager.buildOutboundRelationships( + analyticRefs, + (id) => this.repository.retrieveLatestByStixId(id) + ); + data.workspace.embedded_relationships.push(...embeddedRels); + } + + // Example 3: Validate domain rules + if (data.stix.x_mitre_is_subtechnique && !options.parentTechniqueId) { + throw new Error('Subtechniques require parentTechniqueId'); + } +} +``` + +**DO NOT:** +- ❌ Modify OTHER documents (use `afterX` or event listeners) +- ❌ Call `super.beforeCreate()` (it's a no-op) +- ❌ Emit events (BaseService handles this) + +#### `beforeUpdate(stixId, stixModified, data, existingDocument)` + +**MODIFY DATA before it's saved:** + +- ✅ Validate changes are allowed +- ✅ Compare old vs new values to detect changes +- ✅ Update `workspace.embedded_relationships` based on changes +- ✅ Store change detection results in instance variables for use in `afterUpdate` + +**Examples:** +```javascript +async beforeUpdate(stixId, stixModified, data, existingDocument) { + // Example 1: Detect changes + const oldRefs = existingDocument.stix.x_mitre_analytic_refs || []; + const newRefs = data.stix?.x_mitre_analytic_refs || []; + + this._addedRefs = newRefs.filter(ref => !oldRefs.includes(ref)); + this._removedRefs = oldRefs.filter(ref => !newRefs.includes(ref)); + + // Example 2: Update embedded relationships + const nonAnalyticRels = (data.workspace.embedded_relationships || []).filter( + rel => !rel.stix_id?.startsWith('x-mitre-analytic--') + ); + const analyticRels = await EmbeddedRelationshipsManager.buildOutboundRelationships( + newRefs, + (id) => this.repository.retrieveLatestByStixId(id) + ); + data.workspace.embedded_relationships = [...nonAnalyticRels, ...analyticRels]; +} +``` + +**DO NOT:** +- ❌ Modify OTHER documents +- ❌ Perform async operations that modify database (save those for `afterUpdate`) + +### Use `afterX` hooks when you need to: + +#### `afterCreate(document, options)` + +**SIDE-EFFECTS after document is saved:** + +- ✅ Update OTHER documents (e.g., add inbound relationships) +- ✅ Trigger background jobs +- ✅ Send notifications +- ✅ Update caches +- ✅ Perform cleanup + +**Examples:** +```javascript +async afterCreate(document, options) { + // Example: Update related documents + const analyticRefs = document.stix?.x_mitre_analytic_refs || []; + if (analyticRefs.length > 0) { + // Add inbound relationships to analytics + for (const analyticId of analyticRefs) { + await EmbeddedRelationshipsManager.addInboundRelationship( + analyticId, + document, + (id) => analyticsRepository.retrieveOneLatestByStixId(id), + (doc) => analyticsRepository.saveDocument(doc) + ); + } + } +} +``` + +**DO NOT:** +- ❌ Modify the `document` parameter (it's already saved) +- ❌ Return a value (return value is ignored) +- ❌ Throw errors unless you want to fail the entire operation + +#### `afterUpdate(updatedDocument, previousDocument)` + +**SIDE-EFFECTS after document is saved:** + +- ✅ Update OTHER documents based on what changed +- ✅ Propagate changes to related objects +- ✅ Trigger background jobs +- ✅ Update derived properties in other documents + +**Examples:** +```javascript +async afterUpdate(updatedDocument, previousDocument) { + // Use instance variables from beforeUpdate + if (this._addedRefs?.length > 0) { + for (const analyticId of this._addedRefs) { + await EmbeddedRelationshipsManager.addInboundRelationship( + analyticId, + updatedDocument, + (id) => analyticsRepository.retrieveOneLatestByStixId(id), + (doc) => analyticsRepository.saveDocument(doc) + ); + } + } + + if (this._removedRefs?.length > 0) { + for (const analyticId of this._removedRefs) { + await EmbeddedRelationshipsManager.removeInboundRelationship( + analyticId, + updatedDocument.stix.id, + (id) => analyticsRepository.retrieveOneLatestByStixId(id), + (doc) => analyticsRepository.saveDocument(doc) + ); + } + } + + // Clean up instance variables + delete this._addedRefs; + delete this._removedRefs; +} +``` + +### Use Event Listeners when you need to: + +**REACT to changes in OTHER documents:** + +- ✅ Update derived properties when dependencies change +- ✅ Maintain consistency across document boundaries +- ✅ Decouple cross-service dependencies + +**Examples:** +```javascript +// In AnalyticsService initialization +class AnalyticsService extends BaseService { + static initialize() { + const EventBus = require('../lib/event-bus'); + const EventConstants = require('../lib/event-constants'); + + EventBus.on( + EventConstants.EMBEDDED_RELATIONSHIP_ADDED, + this.handleEmbeddedRelationshipAdded.bind(this) + ); + + EventBus.on( + EventConstants.EMBEDDED_RELATIONSHIP_REMOVED, + this.handleEmbeddedRelationshipRemoved.bind(this) + ); + } + + static async handleEmbeddedRelationshipAdded(payload) { + const { targetId, target } = payload; + + // Only handle analytics + if (!targetId.startsWith('x-mitre-analytic--')) { + return; + } + + // Rebuild external_references with updated URL + // ... + } +} +``` + +## Services That Should Be Refactored + +Based on analysis of existing code, here are services that currently override `create()` or `updateFull()` and should be evaluated for migration to lifecycle hooks: + +### High Priority (Embedded Relationships) + +1. **DetectionStrategiesService** ⚠️ ALREADY PARTIALLY MIGRATED + - Currently: Overrides `create()` and `updateFull()` + - Should: Use `beforeCreate/afterCreate` and `beforeUpdate/afterUpdate` + - Reason: Manages embedded relationships with analytics + +2. **AnalyticsService** ⚠️ ALREADY PARTIALLY MIGRATED + - Currently: Overrides `updateFull()` + - Should: Use `beforeUpdate` + event listeners for relationship changes + - Reason: External references depend on parent detection strategy + +### Medium Priority (Domain-Specific Defaults) + +3. **SoftwareService** + - Currently: Overrides `create()` to set `is_family` defaults for malware vs tools + - Should: Use `beforeCreate()` to set defaults + - Pattern: + ```javascript + async beforeCreate(data, options) { + if (data.stix.type === 'malware' && typeof data.stix.is_family !== 'boolean') { + data.stix.is_family = true; + } + if (data.stix.type === 'tool' && data.stix.is_family !== undefined) { + throw new PropertyNotAllowedError(); + } + } + ``` + +4. **CollectionsService** + - Currently: Overrides `create()` and `updateFull()` for custom logic + - Should: Analyze and potentially use lifecycle hooks + - Needs investigation: What custom logic does it have? + +5. **CollectionIndexesService** + - Currently: Overrides methods for indexing logic + - Should: Investigate if this can use `afterCreate/afterUpdate` + events + +### Low Priority (Non-SDO Services) + +6. **IdentitiesService** - System object, may need special handling +7. **MarkingDefinitionsService** - System object, may need special handling +8. **ReferencesService** - Not a STIX object, different pattern +9. **TeamsService** - Not a STIX object, different pattern +10. **UserAccountsService** - Not a STIX object, different pattern + +## Migration Checklist + +For each service being migrated: + +- [ ] Identify what happens BEFORE database save → Move to `beforeX` +- [ ] Identify what modifies the document being saved → Move to `beforeX` +- [ ] Identify what happens AFTER database save → Move to `afterX` +- [ ] Identify what modifies OTHER documents → Move to `afterX` or event listeners +- [ ] Remove the `async create()` or `async updateFull()` override +- [ ] Test that all functionality still works +- [ ] Verify events are emitted correctly +- [ ] Check that error handling is preserved + +## Anti-Patterns to Avoid + +### ❌ DON'T override the main CRUD method + +```javascript +// BAD +async create(data, options) { + // Custom logic + data.stix.custom_field = 'value'; + + // Call parent + const doc = await super.create(data, options); + + // More custom logic + await this.updateRelatedDocuments(doc); + + return doc; +} +``` + +### ✅ DO use lifecycle hooks + +```javascript +// GOOD +async beforeCreate(data, options) { + // Modify data before save + data.stix.custom_field = 'value'; +} + +async afterCreate(document, options) { + // Side effects after save + await this.updateRelatedDocuments(document); +} +``` + +### ❌ DON'T modify other documents in `beforeX` + +```javascript +// BAD - modifying other documents before save +async beforeCreate(data, options) { + await otherRepository.updateSomething(); // ❌ Too early! +} +``` + +### ✅ DO modify other documents in `afterX` + +```javascript +// GOOD - side effects happen after save +async afterCreate(document, options) { + await otherRepository.updateSomething(); // ✅ Safe! +} +``` + +### ❌ DON'T emit events manually + +```javascript +// BAD +async afterCreate(document, options) { + await EventBus.emit('custom-event', { ... }); + await EventBus.emit(`${this.type}.created`, { ... }); // ❌ BaseService does this! +} +``` + +### ✅ DO let BaseService emit CRUD events + +```javascript +// GOOD - BaseService automatically emits CRUD events +// Only emit specialized domain events if needed +async afterCreate(document, options) { + if (someSpecialCondition) { + await EventBus.emit(EventConstants.DETECTION_STRATEGY_ANALYTICS_CHANGED, { + stixId: document.stix.id, + addedRefs: [...], + document + }); + } +} +``` + +## Benefits of This Pattern + +1. **Separation of Concerns** - BaseService handles infrastructure, child services handle domain logic +2. **Consistency** - All services follow the same pattern +3. **Testability** - Easy to test hooks in isolation +4. **Maintainability** - Clear boundaries between responsibilities +5. **Event-Driven** - Natural integration with EventBus +6. **Debuggability** - Lifecycle stages are explicit and logged diff --git a/docs/developer/release-tracks/entities.md b/docs/developer/release-tracks/entities.md new file mode 100644 index 00000000..0c374bf2 --- /dev/null +++ b/docs/developer/release-tracks/entities.md @@ -0,0 +1,421 @@ +## Entities/Schemas/Data Models + +This document tracks new database schemas, interfaces, etc.; as well as changes to any such existing entities. + +### Release Track + +`ReleaseTrack` instances will be tracked as independent MongoDB Collections. The reason for this is because the volume of snapshot permutations is expected to be very high given the frequency of changes that typically occur between releases. + +#### Naming Conventions + +**Release Track Names:** +- Must contain only alphanumeric characters and spaces: `[a-zA-Z0-9 ]` +- No special characters allowed (no hyphens, underscores, or other punctuation) +- Examples: `Enterprise`, `Groups Monthly`, `Techniques Quarterly` + +**Release Track IDs:** +MongoDB Collections and release track IDs follow a simple naming convention: +``` +release-track--$uuid +``` + +Where: +- `release-track--` is a fixed prefix +- `$uuid` is a dynamically generated UUIDv4 identifier (must be unique) + +**Example:** +A user creates a release track named `Groups Monthly`: +1. Name: `Groups Monthly` (user-specified, stored in the `name` field) +2. UUID: `8b0ff8f9-27fd-4d7e-bbc9-8fe9465342af` (generated) +3. Final ID: `release-track--8b0ff8f9-27fd-4d7e-bbc9-8fe9465342af` + +This ID is used for: +- MongoDB Collection name +- The `id` field in release track snapshots +- API endpoint references (`/api/release-tracks/:id`) + + +### Release Track Types + +Release tracks can be one of two types: + +1. **Standard Release Tracks**: Traditional release tracks that directly manage objects through the candidate → staged → released workflow +2. **Virtual Release Tracks**: Computed aggregations of other release tracks, used to compose releases from multiple source tracks + +The type is identified by the `stix.type` field: +- Standard tracks: `stix.type` is omitted or set to `"standard"` +- Virtual tracks: `stix.type = "virtual"` + +### Standard Release Track Snapshot Schema + +Each release track snapshot will be tracked as an individual MongoDB Document in its respective `ReleaseTrack` Collection. + +```javascript +{ + // Identity + id: "release-track--123", + type: "standard", // or "virtual" + + // Snapshot metadata + modified: "2024-01-15T16:20:00.000Z", // when the snapshot was created + version: "18.0", // null if draft release + + // Release track metadata + name: "ATT&CK Enterprise", + description: "...", + created: "2024-01-01T10:00:00.000Z", // when the release track was created + created_by_ref: "identity--uuid", + object_marking_refs: ["marking-definition--uuid"], + + // Objects in this snapshot + members: [ + // Objects included in the current/latest release + // These are in the published STIX bundle + { + object_ref: "attack-pattern--aaa", + object_modified: "2024-01-10T10:00:00.000Z" + }, + { + object_ref: "malware--bbb", + object_modified: "2024-01-11T14:30:00.000Z" + }, + { + object_ref: "tool--ccc", + object_modified: "2024-01-12T09:15:00.000Z" + } + ], + + // Staged for next release + staged: [ + // Objects that are reviewed (in THIS release track) and ready for next bump + // Automatically promoted from candidates when track-scoped status → "reviewed" + { + object_ref: "attack-pattern--ddd", + object_modified: "2024-01-14T10:00:00Z", // VERSION PIN: specific object version + object_status: "reviewed", // Track-scoped status + object_staged_at: "2024-01-14T11:00:00Z", + object_staged_by: "reviewer@example.com" + } + ], + + // Work in progress + candidates: [ + // Objects being worked on (in THIS release track), not yet ready for release + { + object_ref: "attack-pattern--eee", + object_modified: "2024-01-12T09:00:00Z", // VERSION PIN: specific object version + object_status: "work-in-progress", // Track-scoped status + object_added_at: "2024-01-10T10:00:00Z", + object_added_by: "alice@example.com" + }, + { + object_ref: "attack-pattern--fff", + object_modified: "2024-01-13T14:00:00Z", // VERSION PIN: specific object version + object_status: "awaiting-review", // Track-scoped status + object_added_at: "2024-01-12T14:30:00Z", + object_added_by: "bob@example.com" + } + ], + + // Configuration + config: { + candidacy_threshold: "awaiting-review", // "work-in-progress" | "awaiting-review" | "reviewed" + auto_promote: true, // Auto-promote reviewed objects to staged + include_secondary_objects: { + enabled: true, + status_threshold: "reviewed" + }, + promotion_conflicts: { + candidates_to_staged: "prefer_latest", // "always_overwrite" | "always_reject" | "prefer_latest" + staged_to_members: "abort" // "always_overwrite" | "always_reject" | "prefer_latest" | "abort" + }, + // Member sync strategy - controls auto-enrollment of new member object revisions + // See 08_MEMBER_SYNC_STRATEGIES.md for comprehensive documentation + member_sync: { + strategy: "track_latest", // "track_latest" | "manual" + supplant: { + behavior: "replace", // "replace" | "queue" | "ignore" + status_policy: "reset" // "reset" | "preserve" + } + } + }, + + // Version history + version_history: [ + { + version: "1.1", + tagged_at: "2024-01-15T17:00:00Z", + tagged_by: "admin@example.com", + snapshot_id: "2024-01-15T16:20:00.000Z", + summary: { + members_count: 3, // Objects in members + promoted_count: 1, // Objects promoted from staged to members + staged_count: 0, // Objects left in staged (if any) + candidate_count: 2 // Objects left in candidates (if any) + } + } + ] +} +``` + +### Version History + +The `version_history` array tracks all tagged releases in reverse chronological order (newest first): + +```javascript +version_history: [ + { + version: "2.0", // Version (MAJOR.MINOR) + tagged_at: "2024-02-01T...", // When the tagging occurred + tagged_by: "user@example.com", // Who performed the tagging + snapshot_id: "2024-02-01T10:00:00.000Z", // Which snapshot was tagged + summary: { + members_count: 3000, + promoted_count: 150 + } + }, + // ... older versions +] +``` + +This provides: +- Complete audit trail of tagged releases +- Attribution for each tagged release +- Chronological release history + +### Object (SDO/SRO/SMO) Document Schema + +Objects maintain a simple reference to which release tracks reference them: + +```javascript +{ + stix: { + id: "attack-pattern--eee", + modified: "2024-01-12T09:00:00Z", // This version's timestamp + type: "attack-pattern", + name: "New Technique", + // ... other STIX properties + }, + workspace: { + // NO global workflow status - status is tracked per-release-track + + // Simple reverse reference for efficient queries + referenced_by: [ + { + release_track_id: "release-track--123", + snapshot_id: "2024-12-15T16:20:00.000Z", + membership_tier: "members", // "members" | "staged" | "candidates" + review_status: "reviewed" // "work-in-progress" | "awaiting-review" | "reviewed" + }, + { + release_track_id: "release-track--456", + snapshot_id: "2025-01-10T11:00:00.000Z", + membership_tier: "candidates", + review_status: "work-in-progress" + } + ], + + // Attribution metadata + workflow_history: [ + { + timestamp: "2024-01-12T09:00:00Z", + modified_by: "alice@example.com", + action: "created" + } + ] + } +} +``` + +**Key Points:** +- **No global `workflow.status`** - status is release-track-specific +- `referenced_by` provides reverse lookup for queries like "show me all release tracks containing this object" +- Same object version can have different statuses in different release tracks +- Multiple versions of same object can exist, each potentially referenced by different release tracks + +### Virtual Release Track Snapshot Schema + +Virtual release tracks compute their contents by aggregating objects from component release tracks. Each virtual track snapshot stores composition rules and resolution metadata. + +```javascript +{ + // Identity + id: "release-track--virtual-uuid", + type: "virtual", // Distinguishes from standard tracks + + // Snapshot metadata + snapshot_id: "2024-03-01T10:00:00.000Z", + modified: "2024-03-01T10:00:00Z", + version: null, // null for draft, or "14.0" for tagged release + + // Release track metadata + name: "Enterprise ATT&CK", + description: "Virtual aggregation of Enterprise content across multiple source tracks", + created: "2024-01-01T10:00:00.000Z", + created_by_ref: "identity--uuid", + object_marking_refs: ["marking-definition--uuid"], + + // Objects in this snapshot (Virtual tracks use 2-tier system) + members: [ + { + object_ref: "intrusion-set--APT1", + object_modified: "2024-02-01T10:00:00Z" + } + // ... 870 total objects synced from component tracks + ], + quarantine: [], // Conflicting objects requiring manual resolution + + // Composition rules - defines how this virtual track is built + composition: { + component_tracks: [ + { + track_id: "release-track--groups-monthly", + resolution_strategy: "latest_tagged", // "latest_tagged" | "specific_version" | "specific_snapshot" + priority: 1, // Required for prioritize_higher_priority strategy (lower number = higher priority) + + // Optional: version/snapshot specification for non-latest strategies + version: "5.0", // Used with "specific_version" strategy + snapshot: "2024-02-01T10:00:00Z", // Used with "specific_snapshot" strategy + + // Optional: filters to limit which objects are included + filters: { + object_types: ["intrusion-set"], + domains: ["enterprise"], + stix_pattern: {} // Advanced STIX filtering + } + }, + { + track_id: "release-track--techniques-quarterly", + resolution_strategy: "latest_tagged", + priority: 2, + filters: { + object_types: ["attack-pattern"] + } + } + ], + + // Deduplication strategy when same object appears in multiple component tracks + deduplication: { + strategy: "prioritize_latest_object" // "prioritize_latest_object" | "prioritize_latest_snapshot" | "prioritize_higher_priority" | "quarantine" + } + }, + + // Composition resolution - computed at snapshot creation time, immutable + composition_resolution: { + resolved_at: "2024-03-01T10:00:00Z", + + component_snapshots: [ + { + track_id: "release-track--groups-monthly", + track_name: "Groups Monthly", + track_type: "standard", + + // Which snapshot was resolved + resolved_snapshot_id: "2024-02-15T10:00:00.000Z", + resolved_version: "5.2", + + // How it was resolved + strategy_used: "latest_tagged", + filters_applied: { + object_types: ["intrusion-set"] + }, + + // Statistics + total_objects_in_source: 47, + objects_after_filter: 47, + objects_contributed: 47 // After deduplication + }, + { + track_id: "release-track--techniques-quarterly", + track_name: "Techniques Quarterly", + track_type: "standard", + resolved_snapshot_id: "2024-01-15T10:00:00.000Z", + resolved_version: "2.1", + strategy_used: "latest_tagged", + filters_applied: { + object_types: ["attack-pattern"] + }, + total_objects_in_source: 823, + objects_after_filter: 823, + objects_contributed: 823 + } + ], + + // Deduplication report + deduplication: { + total_objects_before: 870, + total_objects_after: 870, + duplicates_found: 0, + conflicts_resolved: [] + }, + + // Native objects (if virtual track has its own objects in addition to composed) + native_objects: { + members_count: 0 // Virtual tracks can optionally have native members + }, + + // Final statistics + summary: { + total_objects: 870, + by_type: { + "intrusion-set": 47, + "attack-pattern": 823 + }, + by_tier: { + "members": 870, + "quarantine": 0 + } + } + }, + + // Optional: Virtual tracks can schedule automatic snapshot creation + snapshot_schedule: { + mode: "manual", // "manual" | "cron" | "dates" + cron: "0 0 1 1,7 *", // Cron expression (e.g., Jan 1 and July 1 at midnight) + dates: [ // Or specific dates + "2024-01-01T00:00:00Z", + "2024-07-01T00:00:00Z" + ] + }, + + // Configuration + config: { + notification_email: "enterprise-team@example.com" + }, + + // Version history (same as standard tracks) + version_history: [ + { + version: "14.0", + tagged_at: "2024-03-05T14:00:00Z", + tagged_by: "admin@example.com", + snapshot_id: "2024-03-01T10:00:00.000Z", // When snapshot was created + component_versions: { + "GroupsMonthly": "5.2", + "TechniquesQuarterly": "2.1" + } + } + ] +} +``` + +**Key Differences from Standard Tracks:** + +1. **Type Identification**: `stix.type = "virtual"` +2. **Two-Tier System**: Only `members` and `quarantine` (no `candidates` or `staged` tiers) +3. **Composition Rules**: Defines which component tracks to aggregate and how +4. **Composition Resolution**: Immutable metadata about how snapshot was computed +5. **Sync from Members Only**: Always pulls from component tracks' `members` tier (never staged or candidates) +6. **No Workflow States**: No work-in-progress, awaiting-review, or reviewed states +7. **Scheduled Snapshots**: Can auto-generate snapshots on schedule +8. **Component Version Tracking**: Version history records which component versions were included + +**Virtual Track Constraints:** + +- Can only reference **tagged snapshots** from component tracks (not drafts) +- Can only sync from component tracks' **`members` tier** (released objects only) +- Can only compose from **standard release tracks** (not other virtual tracks - no nesting allowed) +- Snapshots are created **manually or on schedule** (never event-driven) +- All snapshots start as **drafts** and must be explicitly tagged +- Component tracks must exist and have at least one tagged release +- Each component track must have a unique **priority** value (no duplicates) \ No newline at end of file diff --git a/docs/developer/release-tracks/error-handling.md b/docs/developer/release-tracks/error-handling.md new file mode 100644 index 00000000..36358d7e --- /dev/null +++ b/docs/developer/release-tracks/error-handling.md @@ -0,0 +1,53 @@ +## Error Handling + +### AlreadyReleasedError + +**Thrown when:** Attempting to bump a snapshot that already has `x_mitre_version` set. + +**HTTP Status:** 409 Conflict + +**Example:** +```json +{ + "error": "This snapshot has already been tagged as version 1.0" +} +``` + +**Solution:** Create a new snapshot by modifying the collection, then bump the new snapshot. + +### InvalidVersionError + +**Thrown when:** +- Explicit version is not valid MAJOR.MINOR format +- Explicit version is not greater than the previous highest version +- Version bump would result in regression + +**HTTP Status:** 400 Bad Request + +**Examples:** +```json +{ + "error": "Version must be greater than current version 1.5" +} +``` + +```json +{ + "error": "Invalid version format. Must match pattern: X.Y (e.g., 1.0, 2.3)" +} +``` + +**Solution:** Provide a valid version that is greater than all previous versions. + +### NotFoundError + +**Thrown when:** Collection with specified ID does not exist. + +**HTTP Status:** 404 Not Found + +**Example:** +```json +{ + "error": "Collection not found" +} +``` \ No newline at end of file diff --git a/docs/developer/release-tracks/implementation-notes.md b/docs/developer/release-tracks/implementation-notes.md new file mode 100644 index 00000000..472bb038 --- /dev/null +++ b/docs/developer/release-tracks/implementation-notes.md @@ -0,0 +1,121 @@ +# Implementation Notes + +## Database Indexes + +```javascript +// Collection candidates lookup +db.collections.createIndex({ 'workspace.candidates.object_ref': 1 }); +db.collections.createIndex({ 'workspace.candidates.status': 1 }); + +// Object collection membership +db.objects.createIndex({ 'workspace.collections.candidates': 1 }); +db.objects.createIndex({ 'workspace.collections.staged': 1 }); +db.objects.createIndex({ 'workspace.workflow.status': 1 }); +``` + +## Validation Rules + +- **Same object version** can only be in one tier per collection (candidates OR staged OR released) +- **Different versions** of same object CAN exist in multiple tiers simultaneously +- Status transitions must be valid: WIP → Awaiting → Reviewed (no backwards transitions) +- Candidacy threshold must be valid enum value +- Object version must exist before adding as candidate (validate `stix.id` and `stix.modified` exist) +- Version pin (`object_modified`) is immutable once set for a tier entry + +## Performance Considerations + +- Bulk operations should use batch updates +- Event handlers should be async and non-blocking +- Large collections (>10k objects) may need pagination +- Consider caching for `bump/preview` on large collections + +## Integrating with the Event-Driven Architecture + +### Events Published + +```javascript +// When object status changes within a collection (collection-scoped) +eventBus.emit('release-track:status-changed', { + collectionId: 'x-mitre-collection--123', + objectId: 'attack-pattern--eee', + objectModified: '2024-01-12T09:00:00Z', // Version pin + oldStatus: 'work-in-progress', + newStatus: 'awaiting-review', + changedBy: 'user@example.com', + changedAt: '2024-01-15T10:00:00Z' +}); + +// When object version is added to collection candidates +eventBus.emit('release-track:candidate-added', { + collectionId: 'x-mitre-collection--123', + objectId: 'attack-pattern--eee', + objectModified: '2024-01-12T09:00:00Z', // Version pin + status: 'work-in-progress', + addedBy: 'user@example.com' +}); + +// When object is promoted to staged +eventBus.emit('release-track:object-staged', { + collectionId: 'x-mitre-collection--123', + objectId: 'attack-pattern--ddd', + objectModified: '2024-01-14T10:00:00Z', // Version pin + status: 'reviewed', + promotedBy: 'auto' // or user email +}); + +// When collection is bumped +eventBus.emit('release-track:released', { + collectionId: 'x-mitre-collection--123', + version: '1.2', + promotedCount: 1, + promotedObjects: [ + { + objectId: 'attack-pattern--ddd', + objectModified: '2024-01-14T10:00:00Z' // Version included in release + } + ], + releasedBy: 'admin@example.com' +}); +``` + +### Event Handlers + +```javascript +// Auto-promote on status change (collection-scoped) +eventBus.on('release-track:status-changed', async (event) => { + if (event.newStatus === 'reviewed') { + const collection = await Collection.findById(event.collectionId); + + if (collection.workspace.config.auto_promote) { + // Move this specific version from candidates to staged + await promoteToStaged( + collection, + event.objectId, + event.objectModified // Preserve version pin + ); + } + } +}); + +// Update object's referenced_by tracking +eventBus.on('release-track:object-staged', async (event) => { + // Update the specific object version + await updateObject( + { 'stix.id': event.objectId, 'stix.modified': event.objectModified }, + { + $set: { + 'workspace.referenced_by.$[elem].tier': 'staged', + 'workspace.referenced_by.$[elem].status': event.status + } + }, + { + arrayFilters: [ + { + 'elem.collection_id': event.collectionId, + 'elem.tier': 'candidates' + } + ] + } + ); +}); +``` diff --git a/docs/developer/release-tracks/member-sync-strategies.md b/docs/developer/release-tracks/member-sync-strategies.md new file mode 100644 index 00000000..267dc305 --- /dev/null +++ b/docs/developer/release-tracks/member-sync-strategies.md @@ -0,0 +1,873 @@ +# Member Sync Strategies + +## Overview + +This document describes the **Member Sync Strategy** system, which governs how release tracks respond when new revisions of member objects are created. This feature addresses a critical gap in the release track workflow: ensuring that future revisions of already-released objects are automatically queued for subsequent releases. + +**Related Documentation:** +- [api-reference.md](../../user/release-tracks/api-reference.md) - Complete API reference +- [terminology.md](../../user/release-tracks/terminology.md) - Core terminology (candidates, staged, members) +- [release-workflow.md](../../user/release-tracks/release-workflow.md) - Workflow states and promotion +- [entities.md](./entities.md) - Database schemas and data models + +--- + +## Problem Statement + +### The Post-Release Gap + +Consider a typical release workflow: + +1. A release track is created and objects are added as candidates +2. Objects progress through the workflow: `candidates` → `staged` → `members` +3. A release is tagged, and all staged objects are merged into `members` +4. The `staged` array is emptied + +At this point, users continue editing objects that are now in `members`. They create new revisions of techniques, groups, and other STIX objects. However, because `staged` has been emptied and there are no longer any dynamic references tracking these objects, **new revisions do not automatically appear in the release track**. + +This creates a frustrating user experience. Intuitively, users expect that once an object is enrolled in a release track's `members` list, all future revisions will automatically be queued for the next release. Instead, users must remember to manually hit the "Add Candidates" endpoint for every object they edit after each release. This is tedious, error-prone, and counterintuitive. + +### Illustrative Example + +```yaml +# Initial state: Release track after v1.0 has been tagged +release-track: + version: "1.0" + candidates: [] + staged: [] + members: + - object_ref: attack-pattern--abc + object_modified: 2025-01-01 # The v1.0 version + +# User edits attack-pattern--abc, creating a new revision +# In the database, there are now TWO versions: +objects: + - id: attack-pattern--abc + modified: 2025-01-01 # v1.0 (released) + - id: attack-pattern--abc + modified: 2025-06-15 # v1.1 (new revision) + +# PROBLEM: The release track has no idea about v1.1! +# The new revision is NOT automatically tracked. +# User must manually add it as a candidate. +``` + +### The Solution: Member Sync Strategies + +Member Sync Strategies provide configurable behavior that automatically enrolls new object revisions as candidates when the object is already a member of the release track. This eliminates the manual re-enrollment burden and aligns the system with user expectations. + +--- + +## Core Concepts + +### What is a Member Sync Strategy? + +A **Member Sync Strategy** is a configuration setting on a release track that determines how the system responds when a new revision of a member object is created. The strategy answers several questions: + +1. **Should the new revision be automatically added to candidates?** +2. **If a previous revision already exists in candidates or staged, what should happen?** +3. **What workflow status should the new revision start with?** + +### When Does Member Sync Apply? + +Member sync logic is triggered by **object modification events**. Specifically, when a STIX object is created or updated (resulting in a new `modified` timestamp), the system checks whether that object is a member of any release tracks. For each release track where the object is a member, the configured member sync strategy determines what action (if any) to take. + +**Important:** Member sync only applies to objects that are currently in the `members` array of a release track. It does not apply to objects that are only in `candidates` or `staged`. The rationale is that objects in `candidates` or `staged` are still progressing through the workflow and have not yet been "committed" to the release track as official members. + +### Relationship to Existing Features + +Member sync strategies integrate with several existing release track features: + +- **Candidacy Threshold:** When a new revision is auto-enrolled as a candidate, it may be immediately promoted to `staged` if its status meets the candidacy threshold. +- **Conflict Resolution Policies:** When member sync adds a new revision and a previous revision already exists in `candidates` or `staged`, the configured conflict resolution policy (from `config.promotion_conflicts`) determines how to handle the overlap. +- **Snapshot Creation:** Any change to a release track's object lists (`candidates`, `staged`, `members`) results in a new draft snapshot being created. Member sync follows this convention. + +--- + +## Configuration + +Member sync behavior is configured at the **release track level** via the `config.member_sync` object. This configuration applies uniformly to all member objects in the release track. + +### Configuration Schema + +```javascript +{ + config: { + // Existing configuration... + candidacy_threshold: "reviewed", + auto_promote: true, + promotion_conflicts: { + candidates_to_staged: "prefer_latest", + staged_to_members: "abort" + }, + + // Member Sync Strategy Configuration + member_sync: { + // The core sync strategy + strategy: "track_latest", // "track_latest" | "manual" + + // Supplant behavior when a new revision is created + // and an older revision already exists in candidates or staged + supplant: { + behavior: "replace", // "replace" | "queue" | "ignore" + status_policy: "reset" // "reset" | "preserve" + } + } + } +} +``` + +### Configuration Options Explained + +#### `member_sync.strategy` + +The `strategy` field determines the primary behavior of member sync. + +##### `"track_latest"` (Default for New Release Tracks) + +When a new revision of a member object is created, **automatically add it to `candidates`**. + +This is the recommended setting for most release tracks. It provides the intuitive "once enrolled, always tracked" behavior that users expect. With this strategy enabled, users can focus on editing objects without worrying about manually re-enrolling them after each release. + +**Example:** + +```yaml +# Configuration +config: + member_sync: + strategy: "track_latest" + +# Initial state after v1.0 release +members: + - object_ref: attack-pattern--abc + object_modified: 2025-01-01 + +candidates: [] +staged: [] + +# User creates a new revision of attack-pattern--abc (modified: 2025-06-15) + +# Resulting state (new draft snapshot): +members: + - object_ref: attack-pattern--abc + object_modified: 2025-01-01 # Still the released version + +candidates: + - object_ref: attack-pattern--abc + object_modified: 2025-06-15 # Automatically enrolled! + object_status: "work-in-progress" + object_added_at: "2025-06-15T10:30:00Z" + object_added_by: "system" # Indicates auto-enrollment + +staged: [] +``` + +##### `"manual"` + +Do **not** automatically enroll new revisions. Users must explicitly add new revisions via the Add Candidates endpoint (`POST /api/release-tracks/:id/candidates`). + +This setting preserves the traditional behavior and provides maximum control. It is appropriate for release tracks where only hand-picked revisions should be included, or where the team prefers explicit enrollment over automatic tracking. + +**Example:** + +```yaml +# Configuration +config: + member_sync: + strategy: "manual" + +# Initial state after v1.0 release +members: + - object_ref: attack-pattern--abc + object_modified: 2025-01-01 + +# User creates a new revision of attack-pattern--abc (modified: 2025-06-15) + +# Resulting state: NO CHANGE +# The release track is unaware of the new revision. +# User must manually add it as a candidate if they want it tracked. +``` + +#### `member_sync.supplant` + +The `supplant` configuration controls what happens when a new revision is created **and** an older revision of the same object already exists in `candidates` or `staged`. This scenario is common when users make multiple edits to an object before a release occurs. + +##### `supplant.behavior` + +Determines how to handle the coexistence of old and new revisions. + +###### `"replace"` (Default) + +Remove the older revision and add the newer revision in its place. + +This is the recommended setting for most workflows. It keeps the release track focused on the latest work and prevents accumulation of stale revisions. When combined with `status_policy: "reset"`, it ensures that significant changes trigger a re-review. + +**Example:** + +```yaml +# Configuration +config: + member_sync: + strategy: "track_latest" + supplant: + behavior: "replace" + status_policy: "reset" + +# Initial state: v26 is in staged (already reviewed) +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 # v26 + object_status: "reviewed" + +# User creates v27 (modified: 2027-01-01) + +# Resulting state: +# v26 is REMOVED from staged +# v27 is ADDED to candidates with reset status + +candidates: + - object_ref: attack-pattern--abc + object_modified: 2027-01-01 # v27 + object_status: "work-in-progress" # Status reset + +staged: [] # v26 removed +``` + +###### `"queue"` + +Keep the older revision where it is and add the newer revision to `candidates` alongside it. + +This setting allows both revisions to coexist and progress through the workflow independently. It is useful when a previous revision needs to ship in an imminent release while a newer revision is still being developed for a subsequent release. + +**Example:** + +```yaml +# Configuration +config: + member_sync: + strategy: "track_latest" + supplant: + behavior: "queue" + +# Initial state: v26 is in staged (ready for next release) +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 # v26 + object_status: "reviewed" + +# User creates v27 (modified: 2027-01-01) + +# Resulting state: +# v26 REMAINS in staged (will ship in next release) +# v27 is ADDED to candidates (for a future release) + +candidates: + - object_ref: attack-pattern--abc + object_modified: 2027-01-01 # v27 + object_status: "work-in-progress" + +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 # v26 unchanged + object_status: "reviewed" +``` + +**Note:** When using `queue`, multiple versions of the same object can exist across `candidates` and `staged`. The existing conflict resolution policies (configured via `config.promotion_conflicts`) will handle conflicts when these versions are eventually promoted. For example, if the release track is configured with `staged_to_members: "abort"`, the system will prevent releasing if both v26 and v27 somehow end up competing for promotion to `members`. + +###### `"ignore"` + +Do not add the new revision if an older revision already exists in `candidates` or `staged`. + +This setting respects explicit version decisions. If someone has deliberately staged or queued a specific revision, the system will not override that decision with a newer revision. Users must manually remove the old revision and add the new one if they want to switch. + +**Example:** + +```yaml +# Configuration +config: + member_sync: + strategy: "track_latest" + supplant: + behavior: "ignore" + +# Initial state: v26 is in staged +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 # v26 + object_status: "reviewed" + +# User creates v27 (modified: 2027-01-01) + +# Resulting state: NO CHANGE +# v27 is NOT added because v26 already exists in staged. +# The system assumes v26 was deliberately chosen and should not be overridden. +``` + +##### `supplant.status_policy` + +Determines the workflow status assigned to a new revision when `supplant.behavior` is `"replace"`. This setting is ignored when `behavior` is `"queue"` or `"ignore"`. + +###### `"reset"` (Default) + +Assign the new revision `work-in-progress` status and place it in `candidates`, regardless of where the old revision was or what status it had. + +This is the safer option. It ensures that any new revision undergoes the full review workflow, even if the previous revision had already been reviewed. The rationale is that new changes might introduce issues that require fresh review. + +**Example:** + +```yaml +# Old revision was reviewed and staged +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 + object_status: "reviewed" + +# With status_policy: "reset", the new revision: +candidates: + - object_ref: attack-pattern--abc + object_modified: 2027-01-01 + object_status: "work-in-progress" # Starts fresh +``` + +###### `"preserve"` + +Assign the new revision the same status as the old revision and place it in the same tier. + +This option trusts that new revisions are at least as complete as previous ones. It accelerates the workflow by avoiding redundant reviews. This is appropriate for release tracks with trusted contributors or where changes are typically incremental refinements. + +**Example:** + +```yaml +# Old revision was reviewed and staged +staged: + - object_ref: attack-pattern--abc + object_modified: 2026-01-01 + object_status: "reviewed" + +# With status_policy: "preserve", the new revision: +staged: + - object_ref: attack-pattern--abc + object_modified: 2027-01-01 + object_status: "reviewed" # Preserved from old revision +``` + +**Caution:** Using `preserve` means that significant changes (including potentially breaking ones) could skip review. Only use this setting in release tracks where all contributors are trusted and where changes are typically low-risk. + +--- + +## Detailed Behavior Scenarios + +This section walks through various scenarios to illustrate how member sync strategies behave in practice. + +### Scenario 1: Simple Auto-Enrollment + +**Setup:** +- Release track has `track_latest` strategy +- Object `attack-pattern--T1` is in `members` (version v25) +- No pending revisions in `candidates` or `staged` + +**Event:** User creates a new revision of `attack-pattern--T1` (v26) + +**Result:** +```yaml +# Before +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: [] +staged: [] + +# After +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "work-in-progress" } +staged: [] +``` + +**Explanation:** The new revision v26 is automatically enrolled as a candidate. The released version v25 remains in `members`. This is the most common scenario and demonstrates the core value of member sync. + +### Scenario 2: Replacement with Status Reset + +**Setup:** +- Release track has `track_latest` strategy with `replace` + `reset` +- Object `attack-pattern--T1` has v25 in `members` +- v26 is already in `staged` with status `reviewed` + +**Event:** User creates v27 + +**Result:** +```yaml +# Before +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: [] +staged: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "reviewed" } + +# After +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: + - { object_ref: attack-pattern--T1, object_modified: v27, object_status: "work-in-progress" } +staged: [] +``` + +**Explanation:** v26 is removed from `staged` and v27 is added to `candidates` with reset status. The user will need to re-review v27 before it can be staged again. This ensures that the new changes receive proper scrutiny. + +### Scenario 3: Replacement with Status Preserved + +**Setup:** +- Release track has `track_latest` strategy with `replace` + `preserve` +- Object `attack-pattern--T1` has v25 in `members` +- v26 is in `staged` with status `reviewed` + +**Event:** User creates v27 + +**Result:** +```yaml +# Before +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +staged: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "reviewed" } + +# After +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +staged: + - { object_ref: attack-pattern--T1, object_modified: v27, object_status: "reviewed" } +``` + +**Explanation:** v26 is replaced by v27, but v27 inherits the `reviewed` status and remains in `staged`. This is faster but assumes the new changes don't require re-review. + +### Scenario 4: Queueing Alongside Existing Revision + +**Setup:** +- Release track has `track_latest` strategy with `queue` +- Object `attack-pattern--T1` has v25 in `members` +- v26 is in `staged` (ready for imminent release) + +**Event:** User creates v27 (for a future release) + +**Result:** +```yaml +# Before +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +staged: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "reviewed" } +candidates: [] + +# After +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +staged: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "reviewed" } +candidates: + - { object_ref: attack-pattern--T1, object_modified: v27, object_status: "work-in-progress" } +``` + +**Explanation:** Both v26 and v27 coexist. v26 will ship in the next release while v27 progresses through the workflow for a subsequent release. This is useful for parallel development across release cycles. + +### Scenario 5: Ignoring When Revision Already Exists + +**Setup:** +- Release track has `track_latest` strategy with `ignore` +- Object `attack-pattern--T1` has v25 in `members` +- v26 is in `candidates` (being worked on) + +**Event:** User creates v27 + +**Result:** +```yaml +# Before +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "work-in-progress" } + +# After: NO CHANGE +members: + - { object_ref: attack-pattern--T1, object_modified: v25 } +candidates: + - { object_ref: attack-pattern--T1, object_modified: v26, object_status: "work-in-progress" } +``` + +**Explanation:** v27 is not added because v26 already exists in `candidates`. The user must manually remove v26 and add v27 if they want to switch. This setting respects deliberate version choices. + +### Scenario 6: Multiple Objects with Mixed States + +**Setup:** +- Release track has `track_latest` with `replace` + `reset` +- Three objects in `members`: T1 (v25), T2 (v25), T3 (v25) +- T1 also has v26 in `staged` +- T2 has no pending revisions +- T3 has v26 in `candidates` + +**Event:** User creates new revisions: T1-v27, T2-v26, T3-v27 + +**Result:** +```yaml +# Before +members: + - { object_ref: T1, object_modified: v25 } + - { object_ref: T2, object_modified: v25 } + - { object_ref: T3, object_modified: v25 } +staged: + - { object_ref: T1, object_modified: v26, object_status: "reviewed" } +candidates: + - { object_ref: T3, object_modified: v26, object_status: "awaiting-review" } + +# After +members: + - { object_ref: T1, object_modified: v25 } + - { object_ref: T2, object_modified: v25 } + - { object_ref: T3, object_modified: v25 } +staged: [] # T1-v26 removed +candidates: + - { object_ref: T1, object_modified: v27, object_status: "work-in-progress" } # Replaced T1-v26 + - { object_ref: T2, object_modified: v26, object_status: "work-in-progress" } # New enrollment + - { object_ref: T3, object_modified: v27, object_status: "work-in-progress" } # Replaced T3-v26 +``` + +**Explanation:** Each object is handled according to the strategy: +- T1: v26 in `staged` is replaced by v27 in `candidates` with reset status +- T2: No existing revision, so v26 is simply enrolled in `candidates` +- T3: v26 in `candidates` is replaced by v27 in `candidates` with reset status + +### Scenario 7: Auto-Promotion After Enrollment + +**Setup:** +- Release track has: + - `track_latest` with `replace` + `reset` + - `candidacy_threshold: "work-in-progress"` (very permissive) + - `auto_promote: true` +- Object `attack-pattern--T1` has v25 in `members` + +**Event:** User creates v26 + +**Result:** +```yaml +# Before +members: + - { object_ref: T1, object_modified: v25 } +candidates: [] +staged: [] + +# After (auto-enrollment + auto-promotion) +members: + - { object_ref: T1, object_modified: v25 } +candidates: [] # Immediately promoted! +staged: + - { object_ref: T1, object_modified: v26, object_status: "work-in-progress" } +``` + +**Explanation:** v26 is auto-enrolled to `candidates`, but because the candidacy threshold is `work-in-progress` and auto-promote is enabled, v26 is immediately promoted to `staged`. This demonstrates how member sync integrates with existing promotion logic. + +--- + +## Integration with Existing Features + +### Interaction with Candidacy Threshold + +When a new revision is auto-enrolled as a candidate, the standard candidacy threshold logic applies. If the new revision's status meets or exceeds the configured threshold, and `auto_promote` is enabled, the revision will be immediately promoted to `staged`. + +This can lead to interesting scenarios: +- With `reset` status policy, the new revision starts as `work-in-progress`, which typically does not meet the default threshold of `reviewed`. +- With `preserve` status policy, a revision that replaces a `reviewed` entry in `staged` will retain `reviewed` status and could theoretically be immediately re-staged. + +### Interaction with Conflict Resolution Policies + +When `supplant.behavior` is `queue`, multiple revisions of the same object can coexist across `candidates` and `staged`. This creates potential for conflicts during promotion: + +1. **Candidates to Staged:** If v26 is in `candidates` and v27 is also in `candidates`, promoting one may conflict with the other. The `candidates_to_staged` conflict policy determines resolution. + +2. **Staged to Members:** If v26 and v27 are both in `staged` (which can happen with `queue` + subsequent manual promotions), the `staged_to_members` policy applies during release. + +The existing conflict resolution policies (`always_overwrite`, `always_reject`, `prefer_latest`, `abort`) handle these situations. No changes to conflict resolution are required for member sync to function correctly. + +### Snapshot Creation + +Any change to a release track's `candidates`, `staged`, or `members` arrays results in a new draft snapshot. Member sync follows this convention. When a new revision is auto-enrolled or an existing revision is supplanted, the system creates a new draft snapshot with the updated arrays. + +This means: +- Auto-enrollment generates a new snapshot +- Supplanting (whether `replace` or `queue`) generates a new snapshot +- Multiple objects being updated simultaneously (e.g., bulk import) generates a single snapshot reflecting all changes + +### Event-Driven Architecture + +Member sync requires listening for object modification events. When a STIX object is created or modified: + +1. The system identifies all release tracks where this object appears in `members` +2. For each relevant release track, the configured member sync strategy is evaluated +3. If the strategy dictates action (e.g., auto-enrollment), the appropriate snapshot modifications are made + +This event-driven approach ensures that member sync is reactive and automatic, requiring no manual intervention from users. + +--- + +## Default Configuration + +Release tracks use the following default member sync configuration: + +```javascript +{ + member_sync: { + strategy: "track_latest", + supplant: { + behavior: "replace", + status_policy: "reset" + } + } +} +``` + +This default provides: +- **Automatic tracking** of new revisions (solves the core problem) +- **Clean replacement** of outdated revisions (prevents accumulation) +- **Safe re-review** requirement (ensures quality control) + +### Rationale for Defaults + +The defaults were chosen to balance convenience with safety: + +1. **`track_latest`** is the default because it matches user expectations. Users intuitively expect enrolled objects to be tracked continuously. + +2. **`replace`** is the default because most teams want to focus on the latest work, not accumulate stale revisions that clutter the workflow. + +3. **`reset`** is the default because it's safer. New revisions might introduce issues that weren't present in the previous revision. Requiring re-review ensures that changes receive appropriate scrutiny. + +--- + +## API Reference + +### Updating Member Sync Configuration + +Member sync settings are managed via the existing configuration endpoint: + +``` +PUT /api/release-tracks/:id/config +``` + +**Request Body:** +```json +{ + "member_sync": { + "strategy": "track_latest", + "supplant": { + "behavior": "replace", + "status_policy": "reset" + } + } +} +``` + +**Response:** Returns the updated configuration. + +**Note:** Updating the member sync configuration does not retroactively process existing member objects. It only affects how the system responds to future object modification events. + +### Retrieving Configuration + +``` +GET /api/release-tracks/:id/config +``` + +**Response:** +```json +{ + "candidacy_threshold": "reviewed", + "auto_promote": true, + "promotion_conflicts": { + "candidates_to_staged": "prefer_latest", + "staged_to_members": "abort" + }, + "member_sync": { + "strategy": "track_latest", + "supplant": { + "behavior": "replace", + "status_policy": "reset" + } + } +} +``` + +--- + +## Decision Matrix + +The following matrix summarizes the behavior for each combination of settings: + +| Scenario | `track_latest` + `replace` + `reset` | `track_latest` + `replace` + `preserve` | `track_latest` + `queue` | `track_latest` + `ignore` | `manual` | +|----------|--------------------------------------|----------------------------------------|--------------------------|--------------------------|----------| +| New revision created (nothing in candidates/staged) | Add to candidates as WIP | Add to candidates as WIP | Add to candidates as WIP | Add to candidates as WIP | No action | +| New revision created (older in candidates as WIP) | Replace in candidates as WIP | Replace in candidates as WIP | Add alongside as WIP | No action | No action | +| New revision created (older in candidates as awaiting-review) | Replace in candidates as WIP | Replace in candidates as awaiting-review | Add alongside as WIP | No action | No action | +| New revision created (older in staged as reviewed) | Remove from staged, add to candidates as WIP | Replace in staged as reviewed | Keep in staged, add to candidates as WIP | No action | No action | + +--- + +## Best Practices + +### Recommended Configuration for Production Release Tracks + +For release tracks that publish to production environments: + +```javascript +{ + member_sync: { + strategy: "track_latest", + supplant: { + behavior: "replace", + status_policy: "reset" + } + }, + candidacy_threshold: "reviewed", + promotion_conflicts: { + candidates_to_staged: "prefer_latest", + staged_to_members: "abort" + } +} +``` + +**Rationale:** +- `track_latest` ensures no revisions are missed +- `replace` + `reset` ensures all changes are reviewed +- `staged_to_members: "abort"` prevents accidental overwrites during release + +### Recommended Configuration for Development Release Tracks + +For release tracks used in development or testing: + +```javascript +{ + member_sync: { + strategy: "track_latest", + supplant: { + behavior: "replace", + status_policy: "preserve" + } + }, + candidacy_threshold: "work-in-progress", + auto_promote: true +} +``` + +**Rationale:** +- `track_latest` ensures continuous tracking +- `preserve` speeds up iteration by not requiring re-review +- Permissive threshold allows rapid release cycles + +### When to Use `queue` Behavior + +Use `queue` when: +- Your team works on multiple release cycles in parallel +- You need to ship hotfixes while continuing development on the next major version +- You want to preserve staged work while tracking new development + +### When to Use `ignore` Behavior + +Use `ignore` when: +- Specific version pinning is important for compliance or reproducibility +- You want manual control over which revisions enter the workflow +- Your team makes deliberate decisions about version selection + +### When to Use `manual` Strategy + +Use `manual` when: +- You only want hand-picked revisions in the release track +- The release track is for archival purposes (capturing specific historical state) +- You're migrating from an older workflow and want to preserve existing behavior + +--- + +## Limitations and Future Considerations + +### Release-Track-Level Configuration Only + +Member sync configuration applies uniformly to all member objects in a release track. There is no per-object override mechanism in the current design. + +**Rationale:** Per-object overrides would require tracking configuration on individual object references across three tiers (`candidates`, `staged`, `members`). This significantly increases schema complexity and API surface area. The current release track schema and API are not designed for per-object configuration, and introducing it would require substantial refactoring. + +**Future Consideration:** If use cases emerge where per-object sync behavior is essential (e.g., specific objects that should never be auto-tracked), this limitation can be revisited. A potential approach would be to introduce an "exclusion list" that specifies objects exempt from member sync, rather than full per-object configuration. + +### No Retroactive Processing + +Changing the member sync configuration does not retroactively process existing member objects. For example, if you switch from `manual` to `track_latest`, the system will not immediately scan all members and enroll their latest revisions. It will only respond to future object modifications. + +**Workaround:** If you need to bulk-enroll the latest revisions of all member objects after switching to `track_latest`, you can use the Add Candidates endpoint with the list of member object IDs (without specifying `modified`, which defaults to latest). + +### Event Ordering + +When multiple objects are modified in rapid succession (e.g., during a bulk import), the system processes events in order. This should not cause issues in normal operation, but be aware that: +- Each modification event may trigger a new snapshot +- The final state reflects all modifications, but intermediate snapshots may exist + +--- + +## Glossary + +| Term | Definition | +|------|------------| +| **Member Sync** | The system that automatically responds to new object revisions by enrolling them in release tracks | +| **Auto-enrollment** | The act of automatically adding a new object revision to `candidates` | +| **Supplant** | The act of replacing an older revision with a newer one | +| **Status Policy** | The rule determining what workflow status a new revision receives during supplanting | +| **Track Latest** | A sync strategy that automatically enrolls new revisions of member objects | +| **Manual** | A sync strategy that requires explicit user action to enroll new revisions | + +--- + +## Appendix: Schema Update + +The release track configuration schema is extended as follows: + +```javascript +// In release-track-snapshot-schema.js + +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" + } + } + } +} +``` diff --git a/docs/developer/service-exception-middleware.md b/docs/developer/service-exception-middleware.md new file mode 100644 index 00000000..b507ea51 --- /dev/null +++ b/docs/developer/service-exception-middleware.md @@ -0,0 +1,131 @@ +# Service Exception Middleware + +The global error handler now includes middleware for catching well-defined service-layer exceptions. This eliminates the need for duplicate error handling logic in controllers. + +## How It Works + +The `serviceExceptions` middleware in [app/lib/error-handler.js](app/lib/error-handler.js) automatically catches all custom exceptions from [app/exceptions/index.js](app/exceptions/index.js) and maps them to appropriate HTTP status codes: + +- **400 Bad Request**: User input errors (MissingParameterError, BadlyFormattedParameterError, InvalidQueryStringParameterError, ImmutablePropertyError, InvalidPostOperationError, InvalidTypeError, PropertyNotAllowedError, CannotUpdateStaticObjectError, BadRequestError) +- **404 Not Found**: Resource not found errors (NotFoundError, SystemConfigurationNotFound, OrganizationIdentityNotFoundError, AnonymousUserAccountNotFoundError, DefaultMarkingDefinitionsNotFoundError) +- **409 Conflict**: Duplicate resource errors (DuplicateIdError, DuplicateEmailError, DuplicateNameError) +- **500 Internal Server Error**: Service failures (IdentityServiceError, TechniquesServiceError, TacticsServiceError, GenericServiceError, DatabaseError) +- **501 Not Implemented**: Unimplemented functionality (NotImplementedError) +- **502 Bad Gateway**: External service connection errors (HostNotFoundError, ConnectionRefusedError) +- **503 Service Unavailable**: Configuration or HTTP errors (HTTPError, OrganizationIdentityNotSetError, AnonymousUserAccountNotSetError) + +## Migration Guide + +### Before (with manual exception handling) + +```javascript +const { + DuplicateIdError, + BadlyFormattedParameterError, + InvalidQueryStringParameterError, + ImmutablePropertyError, +} = require('../exceptions'); + +exports.create = async function (req, res) { + const analyticData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + }; + + try { + const analytic = await analyticsService.create(analyticData, options); + logger.debug('Success: Created analytic with id ' + analytic.stix.id); + return res.status(201).send(analytic); + } catch (err) { + if (err instanceof ImmutablePropertyError) { + return res.status(400).send(err.message); + } + if (err instanceof DuplicateIdError) { + logger.warn('Duplicate stix.id and stix.modified'); + return res.status(409).send('Unable to create analytic. Duplicate stix.id and stix.modified properties.'); + } else { + logger.error('Failed with error: ' + err); + return res.status(500).send('Unable to create analytic. Server error.'); + } + } +}; +``` + +### After (with automatic exception handling) + +```javascript +exports.create = async function (req, res, next) { + const analyticData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + }; + + try { + const analytic = await analyticsService.create(analyticData, options); + logger.debug('Success: Created analytic with id ' + analytic.stix.id); + return res.status(201).send(analytic); + } catch (err) { + // Pass the error to the next middleware - the serviceExceptions middleware will handle it + return next(err); + } +}; +``` + +**Key changes:** +1. Add `next` parameter to the controller function +2. Remove all custom exception imports (unless needed for other logic) +3. Remove all `instanceof` checks for service exceptions +4. Simply call `next(err)` to pass exceptions to the middleware + +### Even Simpler (no try-catch needed) + +For controller functions that don't need any special success handling, you can use an async handler wrapper or simply let the error propagate: + +```javascript +exports.create = async function (req, res, next) { + const analyticData = req.body; + const options = { + import: false, + userAccountId: req.user?.userAccountId, + }; + + const analytic = await analyticsService.create(analyticData, options).catch(next); + if (!analytic) return; // catch(next) already handled the error + + logger.debug('Success: Created analytic with id ' + analytic.stix.id); + return res.status(201).send(analytic); +}; +``` + +## Benefits + +1. **Consistency**: All exceptions are handled uniformly across the API +2. **Maintainability**: Exception handling logic is centralized in one place +3. **Reduced code**: Controllers are simpler and easier to read +4. **Fewer bugs**: Less chance of missing an exception case +5. **Better logging**: Consistent logging format for all exceptions + +## Custom Exception Handling + +If a controller needs custom handling for specific exceptions (e.g., different error messages or additional logic), it can still catch those exceptions before calling `next(err)`: + +```javascript +exports.specialCase = async function (req, res, next) { + try { + const result = await someService.doSomething(); + return res.status(200).send(result); + } catch (err) { + // Custom handling for a specific case + if (err instanceof DuplicateIdError && req.query.merge === 'true') { + // Do something special for merge scenarios + const merged = await someService.merge(); + return res.status(200).send(merged); + } + + // All other exceptions handled by middleware + return next(err); + } +}; +``` diff --git a/docs/developer/stix-bundle-import-pipeline.md b/docs/developer/stix-bundle-import-pipeline.md new file mode 100644 index 00000000..5dbf77fe --- /dev/null +++ b/docs/developer/stix-bundle-import-pipeline.md @@ -0,0 +1,234 @@ +# STIX Bundle Import Pipeline + +This document describes the internal pipeline that runs when a +client POSTs to `/api/collection-bundles`. For user-facing +documentation of the endpoint's behavior and response shape, see +[`docs/user/stix-bundle-import.md`](../user/stix-bundle-import.md). + +For the rules that govern hook and listener behavior during import +(why bundle `stix` content stays byte-faithful through the +pipeline), see [`import-fidelity-contract.md`](./import-fidelity-contract.md). + +## Entry points + +``` +HTTP request + → app/controllers/collection-bundles-controller.js:importBundle + → app/services/stix/collection-bundles-service/index.js + → app/services/stix/collection-bundles-service/import-bundle.js (this pipeline) +``` + +The controller reads query parameters (`previewOnly`, +`validateContents`, `forceImport`) into an `options` object and +hands the entire bundle and options to `importBundle`. + +## Pipeline stages + +``` +┌────────────────────────────────────────────────────────────────┐ +│ 1. Initialize per-import state │ +│ - importedCollection skeleton (workspace.import_categories) │ +│ - contentsMap from collection.x_mitre_contents │ +│ - referenceMap │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ 2. Check for duplicate collection │ +│ - Same stixId + same modified → existing collection │ +│ - forceImport=duplicate-collection: warn, attach a reimport │ +│ - Otherwise: throw, abort │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ 3. processObjects │ +│ - Sort objects by dependency order │ +│ - Group consecutive same-type objects into TIERS │ +│ - For each tier sequentially: processTier(type, objects) │ +│ - Then: report contents-map orphans as "Missing object" │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ 4. importReferences (sequential) │ +│ - Insert or update each unique external_reference │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ 5. saveCollection │ +│ - Persist x-mitre-collection itself (or append reimport) │ +└────────────────────────────────────────────────────────────────┘ +``` + +## Dependency-ordered tiers + +`sortObjectsByDependencies` returns the bundle's objects in this +order (lower numbers persist first): + +| Tier | STIX type | Rationale | +|---|---|---| +| 0 | `marking-definition` | No outbound refs to other types | +| 1 | `identity` | No outbound refs to other types | +| 2 | `x-mitre-data-source` | Data components reference these | +| 3 | `x-mitre-data-component` | Analytics reference these | +| 4 | `x-mitre-analytic` | Detection strategies reference these | +| 5 | `x-mitre-detection-strategy` | (depends on analytics) | +| 6 | `attack-pattern` (techniques) | SDOs in general | +| 7 | `x-mitre-tactic` | | +| 8 | `course-of-action` (mitigations) | | +| 9 | `intrusion-set` (groups) | | +| 10 | `campaign` | | +| 11 | `malware` | | +| 12 | `tool` | | +| 13 | `x-mitre-asset` | | +| 14 | `x-mitre-matrix` | | +| 15 | `relationship` | SROs last so their endpoints exist | +| 16 | `note` | | +| 17 | `x-mitre-collection` | The bundle's own collection (skipped here; persisted separately) | + +Sort is stable, so within a tier order matches the bundle's order. + +## `processTier` — what runs inside one tier + +For each tier (objects of a single STIX type, in dependency order): + +``` +┌────────────────────────────────────────────────────────────────┐ +│ A. Synchronous eligibility filter (single pass over tier) │ +│ - contents-map drain (warn on bundle-object-not-in-contents)│ +│ - ATT&CK spec-version gate (throws or skips per forceImport)│ +│ → eligible[] │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ B. Bulk pre-fetch existing versions │ +│ repository.retrieveAllByStixIds(eligible.map(o => o.id)) │ +│ → Map> │ +│ One DB query for the entire tier, replacing N retrieveById │ +│ calls from the legacy per-object loop. │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ C. Parallel compose & validate (bounded concurrency, cap 25) │ +│ For each eligible object: │ +│ - checkForDuplicate vs pre-fetched versions │ +│ - categorizeObject (additions/changes/etc.) │ +│ - processExternalReferences │ +│ - service.composeForImport (Zod + workspace.attack_id + │ +│ fail-open workspace.validation) │ +│ - deepFreezeStix(composed) — import-fidelity guard │ +│ - service.beforeCreate(composed, options) │ +│ - record any validation errors into │ +│ importedCollection.workspace.import_categories.errors │ +│ - push composed doc into composedToInsert[] │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ D. Bulk insert (one MongoDB insertMany per tier) │ +│ repository.saveMany(composedToInsert) │ +│ → { inserted: [...], errors: [...writeErrors] } │ +│ writeErrors (ordered:false) → import_categories.errors │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ E. Parallel post-insert lifecycle (bounded concurrency, cap 25)│ +│ For each inserted doc: │ +│ - deepFreezeStix(doc) — import-fidelity guard │ +│ - service.afterCreate(doc, options) │ +│ - service.emitCreatedEvent(doc, options) │ +│ afterCreate emits domain events (e.g. │ +│ 'x-mitre-detection-strategy::analytics-referenced') │ +│ that drive INBOUND workspace.embedded_relationships │ +│ population on referenced docs in earlier-finished tiers. │ +└────────────────────────────────────────────────────────────────┘ +``` + +The order of tiers matters because the post-insert listener +cascade in stage E may modify documents from earlier tiers +(e.g. analytics that were persisted in tier 4 receive inbound +embedded_relationships when detection strategies in tier 5 are +processed). + +## Concurrency primitives + +`runWithConcurrency(items, limit, task)` in `import-bundle.js` is a +small worker-pool helper used in stages C and E. It pulls from a +shared index so each worker fetches the next available item rather +than partitioning ahead of time, which keeps utilization high even +when per-item cost varies (a worker that finishes a cheap doc +immediately picks up the next one). + +We do not pull in `p-limit`: it is not a direct dependency of this +project, and recent versions are ESM-only, which doesn't fit a +CommonJS codebase. The helper is small enough to keep inline. + +## Bulk persistence primitives + +Both new repository methods live on `_base.repository.js` and are +inherited by every concrete repository: + +- **`retrieveAllByStixIds(stixIds)`** — single `find({ 'stix.id': + { $in: ids } })` followed by an in-memory bucket by stixId. + Returns `Map>` with versions sorted + newest-first (matching `retrieveAllById`'s order). + +- **`saveMany(dataArr, { ordered })`** — wraps + `Model.insertMany(dataArr, { ordered: false })`. Returns + `{ inserted, errors }` where `errors` is a normalized + `{ index, message, code }` per failed document. `ordered: false` + ensures one bad document does not abort the rest of the tier. + +Both are only invoked from the bundle-import path. The +single-object create/update paths continue to use +`repository.save(data)` and `repository.retrieveLatestByStixId`. + +## Error model + +The pipeline never throws for per-object failures (except the +ATT&CK spec-version violation without forceImport, which is +considered fatal). Every recoverable error is appended to +`importedCollection.workspace.import_categories.errors` with a +typed `error_type` and as much context as is available. See the +[user doc](../user/stix-bundle-import.md#error-types-in-import_categorieserrors) +for the full error-type taxonomy. + +ADM validation errors are recorded in **both** branches: + +- `validateContents=true` (strict): the doc is dropped from the + bulk insert; one entry with full `details` is written. +- `validateContents=false` (fail-open, default): the doc is still + persisted with `workspace.validation` attached; an entry with + full `details` is **also** written so the import response + surfaces the failure up front. + +Both branches use `error_type: 'Validation error'` and include +the complete Zod issue list in the entry's `details` field. + +## Performance characteristics + +The pipeline scales primarily with two factors: + +1. **Number of objects in the bundle.** The MongoDB round-trips + are dominated by per-tier reads and writes, both of which are + O(1) queries per tier regardless of the number of objects in + it. The total round-trip count is ~`2 * number_of_tiers`. + +2. **Cost of the listener cascade.** Each `afterCreate` that emits + a domain event triggers a listener that fetches and updates the + referenced documents. This is currently O(refs) per source + object — if 10 analytics reference one data component, that + data component is fetched and saved 10 times. Consolidating + listener writes per target is a future optimization. + +On developer hardware, an Enterprise bundle import that previously +took 5+ minutes completes in 20-60 seconds depending on local +Mongo and CPU. + +## Files + +| Path | Role | +|---|---| +| [`app/services/stix/collection-bundles-service/import-bundle.js`](../../app/services/stix/collection-bundles-service/import-bundle.js) | The pipeline itself. `processObjects`, `processTier`, `runWithConcurrency`. | +| [`app/services/stix/collection-bundles-service/bundle-helpers.js`](../../app/services/stix/collection-bundles-service/bundle-helpers.js) | Constants for `importErrors`, `forceImportParameters`, `errors`. | +| [`app/services/meta-classes/base.service.js`](../../app/services/meta-classes/base.service.js) | `composeForImport` (validation + workspace fields) and `_createFromImport` (single-object path). | +| [`app/repository/_base.repository.js`](../../app/repository/_base.repository.js) | `retrieveAllByStixIds` and `saveMany`. | +| [`app/lib/validation-schemas.js`](../../app/lib/validation-schemas.js) | ADM schema selection with cached `.partial()` for WIP objects. | +| [`app/lib/import-safety.js`](../../app/lib/import-safety.js) | `deepFreezeStix` enforcement helper. | diff --git a/docs/developer/stix-versioning-and-embedded-relationships.md b/docs/developer/stix-versioning-and-embedded-relationships.md new file mode 100644 index 00000000..15541ddf --- /dev/null +++ b/docs/developer/stix-versioning-and-embedded-relationships.md @@ -0,0 +1,613 @@ +# STIX Versioning and Embedded Relationships + +## Overview + +This document explains how STIX versioning works in the ATT&CK Workbench REST API, and how it interacts with the embedded relationships feature. Understanding these concepts is critical for developers working with the event-driven architecture. + +--- + +## STIX Versioning: POST vs PUT + +The ATT&CK Workbench implements STIX 2.1 versioning semantics with two distinct update mechanisms: + +### POST - Creating New Versions (Versioned History) + +**Endpoint:** `POST /api/{type}` + +**Behavior:** +- Creates a **new Mongoose document** in the database +- Used when `stix.id` already exists but `stix.modified` is different +- Builds a **temporal version chain** of immutable snapshots +- Each version is a complete, independent document +- Versions are linked by sharing the same `stix.id` + +**Example:** +```javascript +// First version +POST /api/data-components +{ + stix: { + id: "x-mitre-data-component--123", + modified: "2024-01-01T00:00:00.000Z", + name: "Process Creation" + } +} + +// Second version (creates NEW document) +POST /api/data-components +{ + stix: { + id: "x-mitre-data-component--123", // Same ID + modified: "2024-02-01T00:00:00.000Z", // Different modified + name: "Process Creation Events" // Updated content + } +} +``` + +**Result:** Two separate Mongoose documents in the database: +1. Document with `modified: "2024-01-01T00:00:00.000Z"` (unchanged) +2. Document with `modified: "2024-02-01T00:00:00.000Z"` (new) + +**Use Case:** +- **Primary update mechanism** in the Workbench frontend +- Preserves complete edit history +- Enables rollback to previous versions +- Supports audit trails and compliance requirements + +**Lifecycle Hooks Triggered:** +- `beforeCreate` +- `afterCreate` +- `emitCreatedEvent` + +--- + +### PUT - Editing Existing Snapshots (In-Place Modification) + +**Endpoint:** `PUT /api/{type}/{id}/modified/{modified}` + +**Behavior:** +- **Updates an existing Mongoose document** in-place +- Targets a specific version by `stix.id` AND `stix.modified` +- Uses `_.merge()` to apply changes to the document +- Increments Mongoose `__v` field (optimistic locking counter) +- No new document created - modifies the snapshot directly + +**Example:** +```javascript +// Update the 2024-01-01 version in-place +PUT /api/data-components/x-mitre-data-component--123/modified/2024-01-01T00:00:00.000Z +{ + stix: { + description: "Updated description" + } +} +``` + +**Result:** The existing document is modified: +- Same `_id` in MongoDB +- Same `stix.modified` timestamp +- `__v` incremented from 0 to 1 +- Content updated via `_.merge(document, data)` + +**Use Case:** +- **Rarely used** in practice +- Useful for fixing typos in historical snapshots +- Administrative corrections without creating new versions + +**Lifecycle Hooks Triggered:** +- `beforeUpdate` +- `afterUpdate` +- `emitUpdatedEvent` + +**Important Note on `_.merge()` Behavior:** +- Lodash `_.merge()` performs a **deep merge** +- Properties present in the target but **omitted** from the source are **NOT deleted** +- To remove a property, you must **explicitly set it to `null`** + +```javascript +// This does NOT remove x_mitre_data_source_ref: +PUT /api/data-components/{id}/modified/{modified} +{ + stix: { + name: "New Name" + // x_mitre_data_source_ref omitted + } +} + +// This DOES remove x_mitre_data_source_ref: +PUT /api/data-components/{id}/modified/{modified} +{ + stix: { + name: "New Name", + x_mitre_data_source_ref: null // Explicitly set to null + } +} +``` + +--- + +## Querying Versions + +### Get Latest Version +```javascript +GET /api/data-components/{id}?versions=latest +// Returns the single latest version +``` + +### Get All Versions +```javascript +GET /api/data-components/{id}?versions=all +// Returns array of all versions, ordered by modified date +``` + +### Get Specific Version +```javascript +GET /api/data-components/{id}/modified/{modified} +// Returns a specific snapshot +``` + +--- + +## Embedded Relationships and Versioning + +### The Design Choice + +Embedded relationships are stored **directly on the STIX documents** under `workspace.embedded_relationships`: + +```javascript +{ + stix: { /* STIX properties */ }, + workspace: { + attack_id: "DS0029", + embedded_relationships: [ + { + stix_id: "x-mitre-data-source--abc", + attack_id: "DS0001", // Immutable, safe to denormalize + direction: "outbound" + // Note: 'name' is NOT stored - fetched on read if needed + }, + { + stix_id: "x-mitre-analytic--def", + attack_id: "DA-0001", // Immutable, safe to denormalize + direction: "inbound" + // Note: 'name' is NOT stored - fetched on read if needed + } + ] + } +} +``` + +**What's Stored:** +- ✅ `stix_id` (required) - Reference to the related object +- ✅ `attack_id` (optional) - Immutable, server-generated identifier +- ✅ `direction` (required) - 'inbound' or 'outbound' +- ❌ `name` - NOT stored (mutable, must be fetched on read) + +**Why Not Store Names:** +- Names are **mutable** - users can change them via PUT/POST operations +- Storing them would create **data staleness** issues +- Would require **event propagation** to keep in sync across all references +- MongoDB warns against **unbounded arrays** with duplicated mutable data + +**Benefits:** +- ✅ **Minimal storage** - Only essential relationship data +- ✅ **Always current data** - Names fetched fresh when needed +- ✅ **No sync complexity** - No events needed for name changes +- ✅ **Smaller documents** - Reduces risk of hitting 16MB BSON limit +- ✅ **attack_id denormalization** - Safe because it's immutable + +**Trade-offs:** +- ⚠️ **Additional queries for names** - Must fetch referenced docs when names are needed +- ❌ **No relationship versioning** - Only latest version tracked +- ❌ **Temporal inconsistency** - Old snapshots may have "broken" references + +### Special Case: Fetching Names On-Demand + +When names are needed for embedded relationships (e.g., for display in the frontend), services implement on-demand name lookup: + +**Example: AnalyticsService with `includeEmbeddedRelationships=true`** + +```javascript +async populateEmbeddedRelationshipNames(analytics) { + const detectionStrategiesRepository = require('../../repository/detection-strategies-repository'); + + for (const analytic of analytics) { + if (!analytic.workspace?.embedded_relationships) continue; + + for (const rel of analytic.workspace.embedded_relationships) { + if (rel.direction === 'inbound' && rel.stix_id?.startsWith('x-mitre-detection-strategy--')) { + try { + const detectionStrategy = await detectionStrategiesRepository.retrieveLatestByStixId(rel.stix_id); + if (detectionStrategy) { + rel.name = detectionStrategy.stix.name; // Transient property, not persisted + } else { + rel.name = null; + } + } catch (error) { + logger.error(`AnalyticsService: Error fetching detection strategy ${rel.stix_id}:`, error); + rel.name = null; + } + } + } + } +} +``` + +**Key Points:** +- Names are added as **transient properties** to the embedded relationship objects +- Names are **never saved** to the database +- Names are **always fresh** - fetched from the latest version of the referenced object +- This pattern is used sparingly, only when the frontend explicitly needs names + +--- + +## The Temporal Consistency Problem + +### The Scenario + +Consider this sequence of operations: + +1. **POST** a new data source: `DS1` + - Creates: 1 document for DS1 + - `DS1.workspace.embedded_relationships = []` + +2. **POST** a new data component: `DC1` (version 1) + - Creates: 1 document for DC1 v1 + - `DC1.workspace.embedded_relationships = []` + +3. **POST** an update for `DC1` that references `DS1` (version 2) + - Creates: NEW document for DC1 v2 + - Modifies: Existing DS1 document (in-place) + - Result: + - DC1 v1: `embedded_relationships = []` (unchanged) + - DC1 v2: `embedded_relationships = [outbound → DS1]` (new) + - DS1: `embedded_relationships = [inbound ← DC1]` (modified, `__v` = 1) + +4. **POST** another update for `DC1` that removes the reference (version 3) + - Creates: NEW document for DC1 v3 + - Modifies: Existing DS1 document (in-place) + - Result: + - DC1 v1: `embedded_relationships = []` (unchanged) + - DC1 v2: `embedded_relationships = [outbound → DS1]` (unchanged) + - DC1 v3: `embedded_relationships = []` (new) + - DS1: `embedded_relationships = []` (modified, `__v` = 2) + +### The Temporal Mismatch + +After step 4: +- **DC1 v2 (historical snapshot)** says: "I reference DS1" +- **DS1 (current state)** says: "No data components reference me" + +This is a **temporal coupling problem**: +- Data components are **versioned** (immutable snapshots) +- Data sources are **non-versioned** for relationships (mutable state) + +--- + +## Is This a Problem? + +### Short Answer: **Acceptable for Most Use Cases** + +The design prioritizes **operational performance** over **historical queryability**, which is the right trade-off for a knowledge base editing tool where users primarily work with current state. + +### When It's NOT a Problem (99% of Use Cases) + +#### 1. Latest Version Queries +```javascript +// User queries: "Give me DC1" +GET /api/data-components/x-mitre-data-component--123 + +// Returns: Latest snapshot (v3) +// embedded_relationships = [] ✅ Correct + +// User queries: "Give me DS1" +GET /api/data-sources/x-mitre-data-source--456 + +// Returns: Current state +// embedded_relationships = [] ✅ Correct +``` +**Both are in harmony** ✅ + +#### 2. Historical Queries for Single Object +```javascript +// "What did DC1 look like on 2024-01-15?" +GET /api/data-components/x-mitre-data-component--123/modified/2024-01-15T... + +// Returns: Snapshot from that date +// Shows DC1 referenced DS1, which is factually correct ✅ +``` + +#### 3. Audit Trail / Change History +```javascript +// "When did DC1 start referencing DS1?" +GET /api/data-components/x-mitre-data-component--123?versions=all + +// Can determine exactly when x_mitre_data_source_ref was added ✅ +``` + +### When It IS a Problem (Rare Cases) + +#### 1. Historical "Point-in-Time" Queries Across Objects +```javascript +// "Show me DS1 as it existed on 2024-01-15, +// including which data components referenced it" +``` + +**Problem:** DS1 doesn't have relationship snapshots, so you can't reconstruct its `embedded_relationships` at that point in time ❌ + +**Workaround:** Query all DC1 snapshots from that date and reconstruct (expensive) + +#### 2. Bidirectional Navigation from Old Snapshots + +**Scenario:** +- User views DC1 v2 (historical snapshot from 2024-01-15) +- User clicks "Show parent data source" +- DS1 loads with current state + +**Problem:** +- DC1 v2 says: "I reference DS1" +- DS1 (current) says: "No data components reference me" +- Confusing user experience ❌ + +**Mitigation:** UI should clearly indicate "viewing historical snapshot" + +#### 3. Rollback Scenarios + +**Scenario:** User wants to "roll back" to DC1 v2 + +**Problem:** +- DC1 gets restored +- DS1's `embedded_relationships` are out of sync +- Would need to emit events to rebuild DS1's relationships ❌ + +**Mitigation:** Rollback operations would need to trigger relationship recalculation + +--- + +## Why This Design Was Chosen + +### Performance Requirements + +The ATT&CK knowledge base contains: +- ~700 techniques +- ~200 groups +- ~700 software +- ~30 data sources +- ~100+ data components +- Thousands of relationships + +**If relationships were in a separate collection:** +- Every "get data source with components" query requires a join +- Displaying the knowledge base matrix becomes expensive +- API response times degrade + +**With embedded relationships:** +- Single document lookup +- Immutable metadata (attack_id) is denormalized for fast access +- Mutable data (names) fetched on-demand when needed +- Fast reads for the common case (latest version) + +### Acceptable Trade-offs + +1. **Historical relationship queries are rare** + - Users primarily work with latest versions + - Historical analysis is edge case, can be expensive + +2. **Relationship history is preserved in DC snapshots** + - Can reconstruct if needed, just slower + - The data isn't lost, just requires more work to query + +3. **Temporal inconsistency is documented** + - UI can indicate historical snapshot viewing + - Users understand they're looking at a point-in-time view + +4. **No event sourcing requirements** + - System doesn't rely on event replay to rebuild state + - Events are for coordination, not source of truth + +--- + +## Implementation Details + +### How Events Handle Versioning + +When a new version of DC1 is created via POST: + +**In `DataComponentsService.beforeCreate()`:** +```javascript +// Check if this is a new version +let previousVersion = null; +if (data.stix?.id) { + try { + previousVersion = await dataComponentsRepository.retrieveLatestByStixId(data.stix.id); + } catch { + // First version, no previous + } +} + +// Compare old vs new +const oldDataSourceRef = previousVersion?.stix?.x_mitre_data_source_ref; +const newDataSourceRef = data.stix?.x_mitre_data_source_ref; + +// Detect changes +if (oldDataSourceRef && !newDataSourceRef) { + this._removedDataSourceRef = oldDataSourceRef; // Reference removed +} +``` + +**In `DataComponentsService.afterCreate()`:** +```javascript +// Emit removed event for old reference +if (this._removedDataSourceRef) { + await EventBus.emit('x-mitre-data-component::data-source-removed', { + dataComponentId: createdDocument.stix.id, + dataSourceId: this._removedDataSourceRef + }); +} +``` + +**In `DataSourcesService` event listener:** +```javascript +static async handleDataSourceRemoved(payload) { + const { dataComponentId, dataSourceId } = payload; + + // Update DS1 (in-place) + const dataSource = await dataSourcesRepository.retrieveLatestByStixId(dataSourceId); + + // Remove inbound relationship + dataSource.workspace.embedded_relationships = + dataSource.workspace.embedded_relationships.filter( + rel => !(rel.stix_id === dataComponentId && rel.direction === 'inbound') + ); + + await dataSourcesRepository.saveDocument(dataSource); +} +``` + +This ensures that: +- DC1 v3 has correct `embedded_relationships = []` +- DS1 has correct `embedded_relationships = []` +- Both latest versions are in harmony ✅ + +--- + +## Potential Improvements (If Needed) + +### Option 1: Accept the Limitation (Recommended) + +**Actions:** +1. Document this behavior clearly (this document!) +2. Add UI indicators when viewing historical snapshots +3. Provide a "reconstruct relationships at point-in-time" utility if needed + +**Pros:** +- No code changes needed +- Keeps performance benefits +- Works for 99% of use cases + +**Cons:** +- Historical bidirectional queries are expensive + +### Option 2: Snapshot Embedded Relationships Separately + +Create a separate collection to track relationship history: + +```javascript +// New collection: embedded_relationships_history +{ + source_id: "x-mitre-data-component--123", + source_modified: "2024-01-15T...", + target_id: "x-mitre-data-source--456", + direction: "outbound", + created_at: "2024-01-15T...", + deleted_at: null // or timestamp when removed +} +``` + +**Pros:** +- Full historical tracking +- Can reconstruct any point-in-time state +- Bidirectional queries at any date + +**Cons:** +- More storage +- More complex queries +- Adds another collection to maintain + +### Option 3: Version Data Sources Too + +Make data sources fully versioned like data components. + +**Pros:** +- Perfect temporal consistency +- Every snapshot has matching embedded relationships + +**Cons:** +- MUCH more storage (DS1 duplicated on every DC1 change) +- More complex queries (need to find "right" version) +- Breaking change to existing architecture + +### Option 4: Hybrid - Snapshot Only on Relationship Changes + +Only create DS1 snapshots when its `embedded_relationships` actually change. + +**Pros:** +- Less storage than full versioning +- Preserves relationship history + +**Cons:** +- Still adds complexity +- Harder to implement correctly + +--- + +## Best Practices + +### For API Consumers + +1. **Use POST for updates** (default in Workbench frontend) + - Creates proper version history + - Enables rollback + - Triggers correct lifecycle hooks + +2. **Use PUT sparingly** + - Only for administrative corrections + - Be aware of `_.merge()` behavior + - Explicitly set fields to `null` to remove them + +3. **Query latest versions by default** + - `GET /api/data-components/{id}?versions=latest` + - This is what users see in the UI + +4. **Indicate historical snapshots in UI** + - Show banner: "Viewing historical version from 2024-01-15" + - Warn that relationships reflect current state, not historical + +### For Service Developers + +1. **Implement both lifecycle hooks** + - `beforeCreate` / `afterCreate` for POST operations (versioning) + - `beforeUpdate` / `afterUpdate` for PUT operations (in-place edits) + +2. **Detect version changes in `beforeCreate`** + - Fetch previous latest version + - Compare old vs new values + - Store change tracking in instance variables + +3. **Emit events for both added and removed relationships** + - Don't assume POST only adds relationships + - New versions can remove relationships too + +4. **Handle "no previous version" case** + - First version creation is valid + - Emit "referenced" events for initial state + +5. **Never store mutable data in embedded_relationships** + - Only store: `stix_id` (required), `attack_id` (immutable), `direction` (required) + - Never store: `name` or other mutable properties + - Implement on-demand name lookup only if the frontend requires it + - Use transient properties that are never persisted to the database + +--- + +## Summary + +**The current design is acceptable** because: + +✅ ATT&CK Workbench primarily operates on "latest versions" +✅ Historical snapshots show accurate outbound relationships +✅ Historical state can be reconstructed if needed (just expensive) +✅ Performance benefits outweigh historical query complexity +✅ True point-in-time consistency is rarely needed + +The design prioritizes **operational performance** over **historical queryability**, which is the right trade-off for a knowledge base editing tool. + +If you find yourself frequently needing complete historical relationship graphs, consider implementing Option 2 (separate relationship history collection). But for now, this is a **reasonable and well-documented design choice**. + +--- + +## Related Documentation + +- [event-bus-architecture.md](event-bus-architecture.md) - Event-driven architecture patterns +- [lifecycle-hooks-guide.md](lifecycle-hooks-guide.md) - Service lifecycle hooks +- [STIX 2.1 Specification](https://docs.oasis-open.org/cti/stix/v2.1/stix-v2.1.html) - Official STIX standard diff --git a/docs/developer/task-scheduler.md b/docs/developer/task-scheduler.md new file mode 100644 index 00000000..7a86ecf0 --- /dev/null +++ b/docs/developer/task-scheduler.md @@ -0,0 +1,48 @@ +# Notes + +- Refactored the task scheduler +- Uses a new library for scheduling tasks: `node-schedule` +- Uses a new pattern for detecting tasks + - The scheduler will attempt to load any tasks defined in any JS module located in `app/scheduler/` with the suffix, `-task` (e.g., `app/scheduler/foobar-task.js` will work but `app/scheduler/foo.js` will not) + - All the scheduler does is load the task module. It is up to the module defining the task to (1) implement the task, (2) load the task with the `node-schedule` library, and (3) execute the loader in the global scope + +Example: +```javascript +/** + * Initialize and schedule this task + */ +function initializeTask() { + const cronPattern = config.scheduler.myTaskNameCron; + + logger.info(`[here-is-my-task-name] Scheduling task with cron pattern: ${cronPattern}`); + + schedule.scheduleJob(cronPattern, async () => { + try { + await MYTASKTHATSHOULDRUN(); + } catch (err) { + logger.error(`[here-is-my-task-name] Task execution failed: ${err.message}`); + } + }); + + logger.info(`[here-is-my-task-name] Task scheduled successfully`); +} + +if (config.scheduler.enableScheduler) { // <-- make sure to condition the task to only load if globally enabled! + initializeTask(); +} +``` +- The old task scheduler (formerly known as the "collection manager") is now defined in `app/scheduler/sync-collection-indexes-task.js` +- Adds a new global runtime configuration setting for toggling on/off all scheduled tasks. The environment variable is `ENABLE_SCHEDULER` and it maps to `config.scheduler.enableScheduler`. +- Adds a new CRON pattern for configuring when tasks are scheduled. + - The `SYNC_COLLECTION_INDEXES_CRON` environment variable is read at runtime to determine the periodicity that the scheduler should use for the former collection manager (now the `sync-collection-indexes-tasks`). It maps to `config.scheduler.syncCollectionIndexesCron`. + - Future tasks must follow a similar pattern: + - Add the task file + +## TODO + +- [ ] Add robust documentation to `USAGE.md` explaining how task scheduling works and how to create new tasks +- [ ] In the future we should add the ability to dynamically load tasks without having to clone the repository and modify the `app/` source code. This new design pattern makes it possible to define them elsewhere and mount them via Docker volume. +- [ ] There is another task called `check-wip-attack-ids-task.js` that should probably be deleted + - It was created with the goal of restricting ATT&CK IDs to only exist on non-WIP objects + - That conversation is sort of out of scope + - I think we're going to move away from this approach and that the task will probably be moot \ No newline at end of file diff --git a/docs/developer/workflow-response-pattern.md b/docs/developer/workflow-response-pattern.md new file mode 100644 index 00000000..86015653 --- /dev/null +++ b/docs/developer/workflow-response-pattern.md @@ -0,0 +1,265 @@ +# Workflow Response Pattern + +## Overview + +The ATT&CK Workbench REST API exposes several backend workflow endpoints that orchestrate complex, multi-step operations in a single atomic request. Examples include revoking an object, converting a technique to a subtechnique, and converting a subtechnique to a technique. + +These workflows create, modify, deprecate, or delete multiple documents as side effects. The user needs visibility into all changes that occurred as a consequence of their request. To support this, all workflow endpoints return a universal response structure called a **Workflow Result**. + +## Response Schema + +Every workflow endpoint returns the same top-level shape: + +```json +{ + "workflow": "convert-to-subtechnique", + "primary": { + "workspace": { ... }, + "stix": { ... } + }, + "sideEffects": { + "created": [ { "workspace": { ... }, "stix": { ... } } ], + "modified": [], + "deprecated": [], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [ + { + "message": "Duplicate relationship transfer skipped", + "skipped": { "id": "relationship--...", "source_ref": "...", "target_ref": "...", "relationship_type": "uses", "description": "..." }, + "existing": { "source_ref": "...", "target_ref": "...", "relationship_type": "uses" } + } + ] +} +``` + +### Field Reference + +| Field | Type | Description | +|-------|------|-------------| +| `workflow` | `string` | Discriminator identifying which workflow was executed. One of: `"revoke"`, `"convert-to-subtechnique"`, `"convert-to-technique"`. | +| `primary` | `object` | The main object the user acted on. Always exactly one full `workspace + stix` document. | +| `sideEffects.created` | `array` | Full documents created as consequences of the workflow (e.g., `revoked-by` relationship, `subtechnique-of` relationship). | +| `sideEffects.modified` | `array` | Full documents modified as consequences (e.g., transferred relationships). | +| `sideEffects.deprecated` | `array` | Full documents that had `x_mitre_deprecated` set to `true` as a consequence. | +| `sideEffects.deleted` | `object` | Hard-deleted documents. Only count + STIX IDs are returned (the documents no longer exist). | +| `sideEffects.deleted.count` | `integer` | Number of deleted documents. | +| `sideEffects.deleted.stixIds` | `array` | STIX IDs of deleted documents. | +| `warnings` | `array` | Non-fatal issues encountered during the workflow. Each warning is a structured object with a `message` field and additional context fields specific to the warning type (see [Warning Object Schema](#warning-object-schema) below). | + +**Design rationale:** Counts are derivable from array lengths, so no separate summary object is needed. The `deleted` category is the sole exception because deleted documents cannot be returned in full — only their IDs survive. + +### Warning Object Schema + +Every warning is an object with at least a `message` field. Additional fields vary by warning type: + +| Warning type | `message` | Additional fields | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Hierarchy relationship not transferred | `"Hierarchy relationship not transferred"` | `reason`, `relationship: { id, source_ref, target_ref, relationship_type }` | +| Duplicate relationship skipped | `"Duplicate relationship transfer skipped"` | `skipped: { id, source_ref, target_ref, relationship_type, description }`, `existing: { id, source_ref, target_ref, relationship_type, description }` | +| Relationship transfer failed | `"Relationship transfer failed"` | `relationship: { id, description, source_ref, target_ref, relationship_type }`, `error` | +| Failed to create relationship | `"Failed to create subtechnique-of relationship"` | `stixId`, `error` | +| Failed to deprecate relationship | `"Failed to deprecate relationship"` | `relationshipId`, `error` | +| Failed to deprecate relationships (batch) | `"Failed to deprecate relationships for revoked object"` or `"Failed to deprecate subtechnique-of relationships"` | `stixId`, `error` | + +## Which Endpoints Use This Pattern + +| Endpoint | `workflow` value | `primary` | Typical side effects | +|----------|-----------------|-----------|----------------------| +| `POST /api/:type/:stixId/revoke` | `"revoke"` | Revoked object | `created`: revoked-by relationship; `deprecated`: relationships referencing the revoked object | +| `POST /api/techniques/:stixId/convert-to-subtechnique` | `"convert-to-subtechnique"` | Converted subtechnique | `created`: subtechnique-of relationship | +| `POST /api/techniques/:stixId/convert-to-technique` | `"convert-to-technique"` | Converted technique | `deprecated`: subtechnique-of relationship(s) | + +## WorkflowResult Builder + +The `WorkflowResult` class (`app/lib/workflow-result.js`) is a DTO builder that assembles the response. Services construct a `WorkflowResult`, populate it, then return `result.toJSON()`. + +### API + +```javascript +const WorkflowResult = require('../../lib/workflow-result'); + +// Create +const result = new WorkflowResult('convert-to-subtechnique'); + +// Set the primary object +result.setPrimary(savedDocument); + +// Add side effects +result.addCreated(relationshipDoc); // single doc or array +result.addModified(transferredRelDoc); // single doc or array +result.addDeprecated(deprecatedRelDoc); // single doc or array +result.addDeleted(['attack-pattern--...']); // array of STIX IDs + +// Add warnings +result.addWarning('Could not deprecate relationship--...'); + +// Merge results returned by event handlers (see below) +const eventResults = await EventBus.emit(eventName, payload); +result.mergeEventResults(eventResults); + +// Serialize (calls .toObject() on Mongoose docs, strips _id/__v/__t) +return result.toJSON(); +``` + +### `mergeEventResults(eventResults)` + +Accepts the array returned by `EventBus.emit()` and merges each handler's result into the appropriate side-effect category: + +```javascript +mergeEventResults(eventResults) { + for (const handlerResult of eventResults) { + if (handlerResult.created) this.addCreated(handlerResult.created); + if (handlerResult.modified) this.addModified(handlerResult.modified); + if (handlerResult.deprecated) this.addDeprecated(handlerResult.deprecated); + if (handlerResult.warnings) this.addWarnings(handlerResult.warnings); + } +} +``` + +## Event Handler Return Contract + +To surface side effects in the workflow response, event handlers must return their results. + +### Before (results discarded) + +```javascript +static async handleSubtechniqueConvertedToTechnique(payload) { + // ... deprecate relationships ... + logger.info(`Deprecated ${count} relationships`); + // Returns undefined — caller has no visibility +} +``` + +### After (results returned) + +```javascript +static async handleSubtechniqueConvertedToTechnique(payload) { + // ... deprecate relationships ... + return { deprecated: deprecatedDocs }; +} +``` + +### Return Shape + +Event handlers return a plain object with any subset of these keys: + +```javascript +{ + created: [ /* full documents */ ], + modified: [ /* full documents */ ], + deprecated: [ /* full documents */ ], + warnings: [ /* structured warning objects — each must have a `message` field */ ] +} +``` + +Handlers that encounter errors in their catch blocks should return `{ warnings: [...] }` rather than swallowing the error silently: + +```javascript +} catch (error) { + logger.error(`Failed to deprecate relationship ${relId}: ${error.message}`); + return { warnings: [{ message: 'Failed to deprecate relationship', relationshipId: relId, error: error.message }] }; +} +``` + +## EventBus: Returning Handler Results + +`EventBus.emit()` uses `Promise.allSettled` to execute listeners. It collects the fulfilled return values and returns them as an array: + +```javascript +async emit(eventName, payload) { + // ... existing logging ... + const results = await Promise.allSettled( + listeners.map(async (listener) => { + return await listener(payload); // return value preserved + }), + ); + + // Return fulfilled values (undefined returns filtered out) + return results + .filter((r) => r.status === 'fulfilled' && r.value != null) + .map((r) => r.value); +} +``` + +This is backward-compatible: callers that do not inspect the return value are unaffected. Handlers that do not return anything produce `undefined`, which is filtered out. + +## Usage Example: Convert to Subtechnique + +Full flow from service → event handler → response: + +```javascript +// In TechniquesService.convertToSubtechnique(): +const result = new WorkflowResult('convert-to-subtechnique'); + +// 1. Save the converted technique +const savedDocument = await this.repository.save(newVersion); +result.setPrimary(savedDocument); + +// 2. Emit event — RelationshipsService creates the subtechnique-of SRO +const eventResults = await EventBus.emit( + EventConstants.TECHNIQUE_CONVERTED_TO_SUBTECHNIQUE, + { stixId, parentStixId, userAccountId }, +); +result.mergeEventResults(eventResults); + +// 3. Return the assembled response +return result.toJSON(); + + +// In RelationshipsService.handleTechniqueConvertedToSubtechnique(): +static async handleTechniqueConvertedToSubtechnique(payload) { + const { stixId, parentStixId, userAccountId } = payload; + try { + const rel = await relationshipsService.create({ ... }, { userAccountId }); + return { created: [rel] }; + } catch (error) { + logger.error(`Failed to create subtechnique-of: ${error.message}`); + return { warnings: [{ message: 'Failed to create subtechnique-of relationship', stixId, error: error.message }] }; + } +} +``` + +The HTTP response body will be: + +```json +{ + "workflow": "convert-to-subtechnique", + "primary": { + "workspace": { "attack_id": "T1234.001", ... }, + "stix": { "x_mitre_is_subtechnique": true, ... } + }, + "sideEffects": { + "created": [ + { + "stix": { + "type": "relationship", + "relationship_type": "subtechnique-of", + "source_ref": "attack-pattern--...", + "target_ref": "attack-pattern--..." + } + } + ], + "modified": [], + "deprecated": [], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [] +} +``` + +## Adding a New Workflow: Checklist + +1. Choose a `workflow` discriminator string (e.g., `"deprecate"`). +2. In your service method: + - Create `new WorkflowResult('your-workflow-name')` + - Call `result.setPrimary(...)` after persisting the primary object + - Call `result.addCreated/addDeprecated/etc.` for any side effects performed directly + - Capture `EventBus.emit()` return value and call `result.mergeEventResults(...)` + - Return `result.toJSON()` +3. In each event handler that produces side effects: + - Return `{ created, modified, deprecated, warnings }` (include only the keys that apply) + - On error, return `{ warnings: [...] }` instead of swallowing silently +4. Add the new workflow value to the `workflow` enum in the OpenAPI schema (`app/api/definitions/components/workflow-response.yml`). +5. Reference the `workflow-response` schema in the endpoint's OpenAPI path definition. +6. Update user-facing documentation in `docs/user/`. diff --git a/docs/developer/workspace-validation.md b/docs/developer/workspace-validation.md new file mode 100644 index 00000000..6abd378d --- /dev/null +++ b/docs/developer/workspace-validation.md @@ -0,0 +1,202 @@ +# Stateful Validation Tracking (`workspace.validation`) + +## Overview + +Every Mongoose document in the Workbench REST API has an optional +`workspace.validation` subdocument that records the result of validating +the document's `stix` payload against the [ATT&CK Data Model +(ADM)](https://github.com/mitre-attack/attack-data-model) schemas. + +The field is **server-controlled** and **diagnostic**: it does not +gate reads, but it tells operators and client UIs which documents are +known to fail current ADM validation and why. It exists so that a long- +lived database can carry forward documents that were valid under an +older ADM version (or that pre-date validation entirely) without losing +visibility into their non-compliance. + +This document defines the field, its invariants, and the pipelines that +write or clear it. + +## Why state-track validation at all? + +ADM validation is the gate at the write boundary: every POST and PUT +runs the composed STIX object through the ADM schemas before +persistence (see [`base.service.js`](../../app/services/meta-classes/base.service.js) +pipeline stage 5, "VALIDATE WITH ADM"). If validation fails on a write, +the request throws and nothing is persisted. + +Given that gate, **a freshly-seeded database should never contain +validation errors.** State-tracking is only meaningful for documents +that bypassed the gate or were validated under different rules: + +1. **Legacy content** — documents that existed before ADM-based + validation was introduced into the request pipeline. These + documents may have shapes that current schemas reject and were + never gated on entry. + +2. **Version-skewed content** — documents written under one ADM + version that subsequently became non-compliant when Workbench + upgraded to a later ADM version. For example, content authored + under ADM v1.0 may fail ADM v2.0 validation if v2.0 tightened a + constraint or renamed a required field. + + Schema-changing Workbench upgrades are expected to ship database + migration scripts that bring existing content into compliance, so + this case should be rare in practice. It remains technically + possible whenever an upgrade lands without a corresponding migration, + or when a migration cannot fully repair a document. + +3. **Imported content** — STIX bundle imports use a fail-open path + (see below). When an imported object fails validation but the + import is allowed to proceed, the errors are recorded on the + document so they are visible after the import completes. + +## Field shape + +```jsonc +{ + "workspace": { + "validation": { + "errors": [ + { "message": "stix.x_mitre_domains is Required", + "path": ["x_mitre_domains"], + "code": "invalid_type" } + ], + "attack_spec_version": "3.3.0", + "adm_version": "1.4.2", + "validated_at": "2026-05-06T12:00:00.000Z" + } + } +} +``` + +| Field | Meaning | +|---|---| +| `errors` | Array of `{ message, path, code }` derived from Zod issues, after bypass rules are applied. | +| `attack_spec_version` | ATT&CK spec version under which validation was performed. | +| `adm_version` | NPM version of `@mitre-attack/attack-data-model` at validation time. | +| `validated_at` | UTC timestamp of the validation run. | + +The presence of the `validation` subdocument means "this document had +unresolved errors as of `validated_at`." Its **absence** means "this +document was either never validated or last passed validation." + +## Invariants + +1. `workspace.validation` is **server-controlled.** Clients cannot + set, modify, or carry forward this field through any write path. +2. The field is **recomputed (or omitted) on every successful write.** + A POST or PUT that passes ADM validation produces a document with + no `workspace.validation`. A POST or PUT that fails ADM validation + throws — nothing is persisted, and the prior document (if any) is + untouched until a future write or scheduler tick revisits it. +3. The only paths that may legitimately *set* `workspace.validation` + are the scheduler and the import fail-open path. All other paths + either clear it or leave it absent. + +## Writers and behavior + +### 1. `BaseService.create()` — POST a new (version of an) object + +[`base.service.js`](../../app/services/meta-classes/base.service.js) + +- `stripServerControlledFields()` removes any client-supplied + `workspace.validation` at the top of the pipeline. +- ADM validation runs. +- If validation fails, the request throws — nothing persists. +- If validation passes, the new document is saved with no + `workspace.validation`. + +### 2. `BaseService.updateFull()` — PUT an existing version + +- `stripServerControlledFields()` removes any client-supplied + `workspace.validation`. +- ADM validation runs against the composed object. +- If validation fails, the request throws — the existing document is + untouched. +- If validation passes: + - The composed document is merged onto the existing one. + - If the existing document had `workspace.validation`, the merge + sets it to `undefined` and a follow-up `repository.unsetField` + call removes the field from the persisted document. + +### 3. `BaseService._createFromImport()` — STIX bundle import + +This path is intentionally **fail-open**: import is the primary way +that legacy and version-skewed content enters the system, so blocking +on every validation error would make migrations impossible. + +- Any client-supplied `workspace.validation` is stripped at entry. +- Revoked or deprecated objects skip validation entirely and are + persisted with no `workspace.validation`. +- Otherwise, ADM validation runs. +- If validation fails and `options.validateContents` is set, the + import throws. +- If validation fails and `validateContents` is not set, the errors + are recorded on `workspace.validation` (with current ADM and spec + versions) and the document persists. **This is the only legitimate + client-facing setter.** +- If validation passes, the document persists with no + `workspace.validation`. + +### 4. The `validate-objects` scheduler task + +[`app/scheduler/validate-objects-task.js`](../../app/scheduler/validate-objects-task.js) + +The scheduler exists to combat **concept drift**: a document that +passed validation last week may fail today if Workbench has since +upgraded ADM. On its configured cron schedule, the task iterates every +SDO and SRO in the database, re-runs validation, and brings each +document's `workspace.validation` field back in sync with the current +ADM rules. + +For each document: + +- Revoked/deprecated objects are skipped (validation is not + meaningful for retired content). +- Validation runs and bypass rules are applied. +- If the document passes, any existing `workspace.validation` is + unset (`totalCleared`). +- If the document fails, `workspace.validation` is set to the current + errors with current ADM/spec versions (`totalErrored`). + +Because the scheduler is the only writer that can transition a +document from "valid" to "has-validation-errors" without a user +write, it is also the only mechanism that surfaces version-skewed +documents after a Workbench upgrade. + +## Lifecycle summary + +| Path | Validation outcome | `workspace.validation` after the write | +|---|---|---| +| `create()` | passes | absent | +| `create()` | fails | request throws; nothing persisted | +| `updateFull()` | passes | absent (cleared if previously present) | +| `updateFull()` | fails | request throws; existing doc untouched | +| `_createFromImport()` | passes | absent | +| `_createFromImport()` | fails + `validateContents` | request throws | +| `_createFromImport()` | fails + fail-open | server-set with current ADM/spec | +| `_createFromImport()` | revoked/deprecated | absent | +| Scheduler | passes | absent (cleared if previously present) | +| Scheduler | fails | server-set with current ADM/spec | + +## Bypass rules + +Both the request pipeline and the scheduler consult +`validation-bypasses-repository` to filter Zod issues that match a +stored bypass rule (matching on `stixType`, `errorCode`, and +`fieldPath`). A bypassed error is removed from the `errors` array +before the field is written. This allows operators to suppress known- +benign validation noise without modifying ADM itself. + +The set of bypass rules is shared between the synchronous write path +and the scheduler so that a document's validation status is consistent +regardless of which writer last touched it. + +## Reading `workspace.validation` + +There is no public endpoint that filters on this field today. Clients +that need to surface "documents needing attention" should retrieve +the relevant collection and check for the presence of +`workspace.validation` on each document. The field is a diagnostic +hint, not a workflow gate — read paths do not consult it. diff --git a/docs/legacy/authentication.md b/docs/legacy/authentication.md index 95953d60..ee9214e2 100644 --- a/docs/legacy/authentication.md +++ b/docs/legacy/authentication.md @@ -89,6 +89,28 @@ Logs the user out of the REST API. OIDC authentication is intended for use in an organizational setting and can be tied into the organization's single-sign on configuration. +##### Configuring OIDC for Multiple Environments + +When deploying multiple instances of the REST API (e.g., local development, staging, and production), each instance requires its own `AUTHN_OIDC_REDIRECT_ORIGIN` configured to match the URL where that instance is accessible. + +For example: + +- **Local instance**: `AUTHN_OIDC_REDIRECT_ORIGIN=http://localhost:3000` +- **Production instance**: `AUTHN_OIDC_REDIRECT_ORIGIN=https://workbench.example.com` + +All instances can share the same OIDC Client ID and Client Secret, but you must configure **all redirect URIs** in your OIDC Identity Provider: + +- `http://localhost:3000/api/authn/oidc/callback` +- `https://workbench.example.com/api/authn/oidc/callback` + +**Example with Authentik:** + +1. In Authentik, navigate to **Applications** → **Providers** → your provider +2. Edit the **Redirect URIs/Origins** field +3. Add each environment's callback URI on a separate line + +This allows the same OIDC provider configuration to work with multiple deployed instances. + ##### Log In ``` diff --git a/docs/legacy/user-management.md b/docs/legacy/user-management.md index c3337f20..6d0df8a6 100644 --- a/docs/legacy/user-management.md +++ b/docs/legacy/user-management.md @@ -71,9 +71,9 @@ As shown in the table, the default role for users who aren't registered and acti These endpoints are disabled if the app is configured to use the anonymous authentication mechanism. The STIX ID of the corresponding identity object is used by the user management endpoints as the unique identifier for a user. -##### Get Users +### Get Users -``` +```http GET /api/user-accounts ``` @@ -81,25 +81,25 @@ Retrieves a list of user account documents (i.e., registered users). Query string parameters for searching are TBD. -###### Authorization +#### Authorization This endpoint will only be available to users with the `admin` role. -##### Get User +### Get User -``` +```http GET /api/user-accounts/:id ``` Retrieve a user account document by its id. -###### Authorization +#### Authorization This endpoint will only be available to users with the `admin` role or for a logged in user with the matching user account `id`. -##### Register User +### Register User -``` +```http POST /api/user-accounts/register ``` @@ -108,24 +108,83 @@ This results in a registered user. The user document will have the `email` and `username` properties set based on the log in data. `status` will be set to pending and `role` will be set to null. -###### Authorization +#### Authorization This endpoint will only be available for a logged in user who is in the process of registering. -##### Update User +### Update User -``` +```http PUT /api/user-accounts/:id ``` Update an existing user document in the database. -###### Authorization +#### Authorization This endpoint will only be available to users with the `admin` role. +## Creating User Accounts + +When using OIDC authentication, users who successfully authenticate through the identity provider will not have any permissions in the Workbench until a user account is created for them. + +### Process + +1. **User authenticates for the first time**: + - The user logs in through the OIDC provider + - They will see a "User not registered" message or have no permissions (effective role: `none`) + +2. **Administrator creates user account**: + - An existing admin creates the account via the REST API or frontend + - The email must match the email claim from the OIDC provider + +3. **User logs in again**: + - The user will now have the assigned role and permissions + +### Creating Accounts via REST API + +Use the `POST /api/user-accounts` endpoint: + +```bash +curl -X POST http://localhost:3000/api/user-accounts \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "username": "user@example.com", + "displayName": "User Display Name", + "status": "active", + "role": "editor" + }' +``` + +**Required fields:** + +- `email` (string): Must match the email claim from the OIDC provider +- `username` (string): Typically the same as email or the `preferred_username` claim +- `displayName` (string): User's full name for display +- `status` (string): Set to `active` for immediate access +- `role` (string): One of `admin`, `editor`, `visitor`, or `team_lead` + ## Initial User Configuration -Unless the app is configured to use the anonymous authentication mechanism, -it will be necessary to have a way to provision an initial admin user that can then be used to create the other users. -The design for provisioning the initial admin user is TBD. +For OIDC-based authentication, you need at least one admin user to manage other users. + +### Bootstrapping the First Admin + +#### Option 1: Direct Database Insert (for initial setup only) + +If you have direct access to MongoDB, you can create the first admin user manually: + +1. Have the user authenticate once through OIDC (they'll have no permissions) +2. Get their email from the OIDC provider +3. Insert a user account document directly into the database with admin role +4. User logs in again with admin permissions + +#### Option 2: Temporary Anonymous Access + +1. Temporarily switch to `AUTHN_MECHANISM=anonymous` in `.env` +2. Restart the REST API +3. Access the system with admin privileges +4. Create OIDC user accounts through the API or frontend +5. Switch back to `AUTHN_MECHANISM=oidc` +6. Restart the REST API diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md new file mode 100644 index 00000000..08601f2a --- /dev/null +++ b/docs/user/release-tracks/api-reference.md @@ -0,0 +1,970 @@ +# Release Tracks API V2 - API Reference + +## Overview + +This document provides the complete API reference for Release Tracks V2 (formerly "Collections V2"). + +**Related Documentation:** +- [summary.md](./summary.md) - High-level design summary and problem statement +- [terminology.md](./terminology.md) - Complete terminology guide +- [versioning.md](./versioning.md) - Versioning and release process +- [virtual-tracks.md](./virtual-tracks.md) - Virtual release tracks (aggregations) +- [release-workflow.md](./release-workflow.md) - Workflow integration and candidacy +- [entities.md](../../developer/release-tracks/entities.md) - Database schemas and data models +- [output-formats.md](./output-formats.md) - Output format specifications +- [member-sync-strategies.md](../../developer/release-tracks/member-sync-strategies.md) - Automatic tracking of member object revisions + +**Quick Navigation:** +- [Ephemeral Release Tracks](#ephemeral-release-tracks) +- [Release Track Management](#release-track-management) +- [Snapshot-Specific Operations](#snapshot-specific-operations) +- [Candidate Management](#candidate-management) +- [Staged Objects](#staged-objects) +- [Configuration](#configuration) +- [Preview & Dry Run](#preview--dry-run) +- [Version Pin Management](#version-pin-management) +- [Virtual Release Tracks](#virtual-release-tracks) +- [Query Variations](#query-variations) +- [Output Formats](#output-formats) +- [Error Responses](#error-responses) + + +## Complete Endpoint List + +### Ephemeral Release Tracks +``` +GET /api/release-tracks/ephemeral/:domain +``` + +### Release Track Management +``` +GET /api/release-tracks +POST /api/release-tracks/new +POST /api/release-tracks/new-from-bundle +POST /api/release-tracks/import +GET /api/release-tracks/:id +POST /api/release-tracks/:id/meta +POST /api/release-tracks/:id/contents +POST /api/release-tracks/:id/bump +POST /api/release-tracks/:id/clone +DELETE /api/release-tracks/:id +``` + +### Snapshot Operations +``` +GET /api/release-tracks/:id/snapshots/:modified +POST /api/release-tracks/:id/snapshots/:modified/meta +POST /api/release-tracks/:id/snapshots/:modified/bump +POST /api/release-tracks/:id/snapshots/:modified/clone +DELETE /api/release-tracks/:id/snapshots/:modified +``` + +### Candidate Management +``` +POST /api/release-tracks/:id/candidates +GET /api/release-tracks/:id/candidates +DELETE /api/release-tracks/:id/candidates/:objectRef +POST /api/release-tracks/:id/candidates/review +POST /api/release-tracks/:id/candidates/promote +POST /api/release-tracks/:id/candidates/:objectRef/update-version +``` + +### Staged Objects +``` +GET /api/release-tracks/:id/staged +POST /api/release-tracks/:id/staged/demote +``` + +### Configuration +``` +GET /api/release-tracks/:id/config +PUT /api/release-tracks/:id/config +``` + +### Preview & Dry Run +``` +GET /api/release-tracks/:id/bump/preview +``` + +### Version Management +``` +GET /api/release-tracks/:id/objects/:objectRef/versions +``` + +### Virtual Release Tracks (Additional) +``` +PUT /api/release-tracks/:id/composition +POST /api/release-tracks/:id/snapshots/create +GET /api/release-tracks/:id/snapshots/preview +``` + +--- + +## Ephemeral Release Tracks + +"Ephemeral" release tracks refer to unmanaged, stateless release track snapshots. Upon request, a STIX bundle will be generated containing the latest copy of all objects contained within the respective domain as defined by the `:domain` path parameter. + +Three options are supported in the `:domain` path parameter: +- `enterprise` +- `ics` +- `mobile` + +These refer to all objects delineated by ATT&CK domain membership as reflected by the objects' `x_mitre_domains` property. + +### Get Ephemeral Bundle + +``` +GET /api/release-tracks/ephemeral/:domain +``` + +**Path Parameters:** +- `:domain` - `enterprise` | `ics` | `mobile` + +**Query Parameters:** +- `format` - `bundle` | `filesystemstore` | `workbench` (default: `bundle`) + +--- + +## Release Track Management + +### List All Release Tracks + +Retrieves a list of all release tracks (both standard and virtual) with summary information. + +``` +GET /api/release-tracks +``` + +**Query Parameters:** +- `releases` - `only` (filter to show only release tracks that have at least one tagged release) +- `type` - `standard` | `virtual` (filter by track type) +- `limit` - Number of results (pagination) +- `offset` - Pagination offset + +**Response Example:** +```json +{ + "release_tracks": [ + { + "id": "release-track--123", + "type": "standard", + "name": "Enterprise ATT&CK", + "description": "Enterprise domain release track", + "latest_version": "14.1", + "latest_modified": "2024-01-15T16:20:00Z", + "snapshot_count": 47, + "tagged_release_count": 12 + }, + { + "id": "release-track--456", + "type": "virtual", + "name": "Aggregated Enterprise", + "description": "Virtual aggregation of multiple tracks", + "latest_version": null, + "latest_modified": "2024-01-10T10:00:00Z", + "snapshot_count": 3, + "tagged_release_count": 2 + } + ], + "total": 2, + "limit": 10, + "offset": 0 +} +``` + +### Create New Release Track + +``` +POST /api/release-tracks/new +``` + +**Request Body:** +```json +{ + "name": "Release Track Name", + "description": "Description", + "external_references": [], + "object_marking_refs": [] +} +``` + +### Bootstrap Release Track From Bundle + +Creates a new release track initialized with objects from a STIX bundle. This is useful for importing existing collections or bootstrapping from published ATT&CK releases. + +``` +POST /api/release-tracks/new-from-bundle +``` + +**Request Body:** +```json +{ + "type": "bundle", + "id": "bundle--9ed7099a-63b8-4e49-92c7-547d39aa29e0", + "objects": [ + { + "type": "attack-pattern", + "id": "attack-pattern--uuid1", + "name": "Technique A" + }, + { + "type": "malware", + "id": "malware--uuid2", + "name": "Malware B" + } + ] +} +``` + +**Response:** +```json +{ + "release_track_id": "release-track--new-uuid", + "snapshot_id": "2024-01-15T10:00:00.000Z", + "objects_imported": 2, + "initial_tier": "members" +} +``` + +**Note:** All objects are added directly to the `members` tier. To add objects as candidates instead, use the standard [Create New Release Track](#create-new-release-track) endpoint followed by [Add Candidates](#add-candidates). + +### Import Release Track (Not Implemented) + +Comprehensively importing a release track would necessitate including the full snapshot history of the source release track. We don't presently have a solution for serializing an entire release track, including its snapshot history, into an atomic structure that can be exchanged between different Workbench deployments. + +However, we can viably "bootstrap" a new release track from a given STIX bundle (see [Bootstrap Release Track From Bundle](#bootstrap-release-track-from-bundle)). + +This endpoint should return/throw a `NotImplementedError` exception with HTTP status 501 (Not Implemented) until such a solution has been designed. + +``` +POST /api/release-tracks/import +``` + +**Request Body:** TBD + +**Status:** Not Implemented (501) + +### Get Latest Snapshot + +Retrieves the most recent snapshot from the release track (by `modified` timestamp). + +``` +GET /api/release-tracks/:id +``` + +**Query Parameters:** + +| Parameter | Values | Description | +|-----------|--------|-------------| +| `format` | `bundle` \| `filesystemstore` \| `workbench` | Output format (default: `bundle`) | +| `include` | `staged` \| `candidates` \| `all` | Which tiers to include (default: members only) | +| `releases` | `only` | Return only the latest tagged release instead of latest snapshot | +| `version` | `X.Y` | Return specific version (e.g., `14.1`) | +| `versions` | `all` | List all snapshots with metadata | + +**Examples:** + +```bash +# Get latest snapshot as STIX bundle (members only) +GET /api/release-tracks/:id + +# Get latest snapshot with all tiers in workbench format +GET /api/release-tracks/:id?include=all&format=workbench + +# Get latest tagged release (not draft) +GET /api/release-tracks/:id?releases=only + +# Get specific version +GET /api/release-tracks/:id?version=14.1 + +# List all snapshots +GET /api/release-tracks/:id?versions=all +``` + +### Update Metadata + +A user or team may wish to: +- rename a release (e.g., fix a typo like `"Entrprise"` to `"Enterprise"`) or shift the scope/purpose of an existing release track without losing its history (though [cloning](#clone-latest-snapshot) is preferred in this scenario) +- update metadata (which at present consists of a `description` field, `object_marking_references` (typically only includes the global marking definition) and the author (`created_by_ref`). +``` +POST /api/release-tracks/:id/meta +``` + +Creates new snapshot with updated metadata. + +**Request Body:** +```json +{ + "name": "Updated Name", + "description": "Updated description", + "external_references": [], + "object_marking_refs": [] +} +``` + +### Update Contents + +``` +POST /api/release-tracks/:id/contents +``` + +Creates new snapshot with updated member objects. **This is intended for retroactive hotfixes only.** The main workflow for enrolling new member objects into `x_mitre_contents` is through the candidate-staging promotion cycle described in [versioning.md](./versioning.md). + +**Request Body:** +```json +{ + "x_mitre_contents": ["attack-pattern--uuid1", "malware--uuid2"] +} +``` + +### Bump/Tag Latest Snapshot + +Converts the latest draft snapshot to a tagged release. Tags the snapshot in-place (does not create new snapshot). Dynamically sets `x_mitre_version` based on the request body options. + +- If `version` is provided, uses that exact version (must be `X.Y` format) +- If `type` is provided, calculates next version based on bump type +- If omitted, defaults to minor bump +- If this is the first release, the version will be `1.0` + +``` +POST /api/release-tracks/:id/bump +``` + +**Request Body (optional):** +```json +{ + "type": "major" | "minor", // Defaults to "minor" if omitted + "version": "X.Y", // Alternative: explicit version + "dry_run": true // Optional: preview without persisting +} +``` + +### Clone Release Track From Latest + +Bootstraps a new `release-track` instance from an existing snapshot. +``` +POST /api/release-tracks/:id/clone +``` + +**Request Body:** +```json +{ + "name": "Cloned Release Track" // optional +} +``` + +### Delete Release Track + +``` +DELETE /api/release-tracks/:id +``` + +**Query Parameters:** +- `versions` - `latest` (delete only latest, default: all) + +--- + +## Snapshot-Specific Operations + +All operations in this section operate on a specific snapshot identified by its `modified` timestamp. + +### Get Specific Snapshot + +Retrieves a specific snapshot by its `modified` timestamp. + +``` +GET /api/release-tracks/:id/snapshots/:modified +``` + +**Path Parameters:** +- `:modified` - ISO 8601 timestamp (e.g., `2024-01-15T16:20:00.000Z`) + +**Query Parameters:** +- `format` - `bundle` | `filesystemstore` | `workbench` (default: `bundle`) +- `include` - `staged` | `candidates` | `all` (default: members only) + +**Example:** +```bash +# Get snapshot from January 15, 2024 as STIX bundle +GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z + +# Get with all tiers in workbench format +GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z?include=all&format=workbench +``` + +### Update Metadata (Specific Snapshot) + +``` +POST /api/release-tracks/:id/snapshots/:modified/meta +``` + +Creates new snapshot with updated metadata. + +**Request Body:** Same as [Update Metadata](#update-metadata) for latest snapshot. + +### Update Contents (Specific Snapshot) + +``` +POST /api/release-tracks/:id/snapshots/:modified/contents +``` + +Creates new snapshot with updated member objects. **This is intended for retroactive hotfixes only.** + +**Request Body:** Same as [Update Contents](#update-contents) for latest snapshot. + +### Bump/Tag Specific Snapshot + +Converts a specific draft snapshot to a tagged release. Tags snapshot in-place (does not create new snapshot). + +``` +POST /api/release-tracks/:id/snapshots/:modified/bump +``` + +**Request Body:** Same as [Bump/Tag Latest Snapshot](#bumptag-latest-snapshot). + +### Clone Specific Snapshot + +Bootstraps a new release track from the specified snapshot. +``` +POST /api/release-tracks/:id/snapshots/:modified/clone +``` + +### Delete Specific Snapshot + +**TODO**: further consideration needs to be given here. We need to be careful to avoid breaking contextual continuity between snapshots. +``` +DELETE /api/release-tracks/:id/snapshots/:modified +``` + +--- + +## Candidate Management + +### Add Candidates + +Adds STIX objects as candidates to the latest draft snapshot. Each object is identified by its `stix.id` field, as well as (optionally) its `stix.modified` field. If `stix.modified` is omitted, the latest permutation of the relevant STIX object will be added. The candidacy reference will follow the latest version of the object until the moment the draft is converted to a release, at which point the reference will become locked to the specific permutation of the object that was considered "latest" at the time the release bump occurred. + +``` +POST /api/release-tracks/:id/candidates +``` + +**Request Body:** +```json +{ + "object_refs": [ + {"id": "attack-pattern--uuid", "modified": "2024-01-15T10:00:00Z"}, // pinned to specific version + {"id": "malware--uuid"} // follows latest version while marked as candidate + ] +} +``` + +Simplified (uses latest versions): +```json +{ + "object_refs": ["attack-pattern--uuid", "malware--uuid"] +} +``` + +### List Candidates + +Retrieves the list of candidate objects from the latest snapshot. + +``` +GET /api/release-tracks/:id/candidates +``` + +**Query Parameters:** +- `status` - Filter by workflow status: `work-in-progress` | `awaiting-review` | `reviewed` + +**Response Example:** +```json +{ + "candidates": [ + { + "object_ref": "attack-pattern--eee", + "object_modified": "2024-01-12T09:00:00Z", + "object_name": "New Technique XYZ", + "object_type": "attack-pattern", + "status": "work-in-progress", + "added_at": "2024-01-10T10:00:00Z", + "added_by": "alice@example.com" + }, + { + "object_ref": "malware--fff", + "object_modified": "2024-01-13T14:00:00Z", + "object_name": "New Malware ABC", + "object_type": "malware", + "status": "awaiting-review", + "added_at": "2024-01-12T14:30:00Z", + "added_by": "bob@example.com" + } + ], + "total": 2 +} +``` + +### Remove Candidate + +Remove an object from the latest snapshot's candidates list (`workspace.candidates`). +``` +DELETE /api/release-tracks/:id/candidates/:objectRef +``` + +### Bulk Object Status Transition + +Bulk transition candidate objects currently in the latest snapshot from workflow status `from` to workflow status `to`. +- Optionally target specific candidates using the `object_refs` filter. +- `object_refs` is optional; if omitted, transitions all matching `from` status. + +Bidirectional status transition is supported here. For example, objects can be transition from "reviewed" → "awaiting-review" or from "awaiting-review" → "work-in-progress". + +Notably, changes to an object's status (e.g., "work-in-progress" → "awaiting-review") will automatically update its release track membership standing (e.g., candidate, staged, member). In the most restrictive (typical) scenario, a candidate object transitioning to the "reviewed" state will trigger a new draft snapshot creation wherein the object is now staged. +``` +POST /api/release-tracks/:id/candidates/review +``` + +**Request Body:** +```json +{ + "from": "work-in-progress", + "to": "awaiting-review", + "object_refs": [ + {"id": "attack-pattern--uuid", "modified": "2024-01-15T10:00:00Z"} + ] +} +``` + +--- + +## Staged Objects + +### List Staged Objects + +Retrieves the list of staged objects from the latest snapshot. Staged objects are ready for the next tagged release. + +``` +GET /api/release-tracks/:id/staged +``` + +**Response Example:** +```json +{ + "staged": [ + { + "object_ref": "attack-pattern--ddd", + "object_modified": "2024-01-14T10:00:00Z", + "object_name": "Reviewed Technique", + "object_type": "attack-pattern", + "status": "reviewed", + "staged_at": "2024-01-14T11:00:00Z", + "staged_by": "reviewer@example.com" + } + ], + "total": 1 +} +``` + +### Promote Candidate Objects To Staged + +``` +POST /api/release-tracks/:id/candidates/promote +``` + +**Request Body:** +```json +{ + "object_refs": ["attack-pattern--eee"] +} +``` + +**Response:** +```json +{ + "promoted": [ + { + "object_ref": "attack-pattern--eee", + "status": "work-in-progress", + "warning": "Object is not reviewed, manual override applied" + } + ] +} +``` + +### Demote Staged Objects To Candidates + +``` +POST /api/release-tracks/:id/staged/demote +``` + +**Request Body:** +```json +{ + "object_refs": [ + {"id": "attack-pattern--uuid", "modified": "2024-01-15T10:00:00Z"} + ] +} +``` + +--- + +## Configuration + +### Get Configuration + +``` +GET /api/release-tracks/:id/config +``` + +### Update Configuration + +``` +PUT /api/release-tracks/:id/config +``` + +**Request Body:** +```json +{ + "candidacy_threshold": "work-in-progress" | "awaiting-review" | "reviewed", + "auto_promote": true | false +} +``` + +--- + +## Preview & Dry Run + +> **Note on `include` Query Parameter:** The `include` query parameter (used on snapshot retrieval endpoints to filter which tiers are returned) is **NOT supported** on bump preview or dry-run operations. Bump previews and dry-runs are intended to show the user exactly what *will* happen when a bump occurs; ad-hoc filters would be misleading because they do not affect the actual release outcome. + +### Preview Next Release (Read-Only) + +Shows a verbose diff of what will change in the next tagged release without creating any data. + +``` +GET /api/release-tracks/:id/bump/preview +``` + +**Query Parameters:** +- `format` - `bundle` | `filesystemstore` | `workbench` (default: `workbench`) + +**Response Example:** +```json +{ + "current_version": "1.1", + "next_version": "1.2", + "release_preview": { + "will_include": [ + { + "ref": "attack-pattern--ddd", + "name": "New Technique XYZ", + "status": "reviewed", + "source": "staged" + } + ], + "will_exclude": [ + { + "ref": "attack-pattern--eee", + "name": "WIP Technique", + "status": "work-in-progress", + "reason": "Does not meet candidacy threshold" + } + ] + } +} +``` + +### Dry Run Bump (Returns Exact Output) + +Performs all bump logic and returns the exact release contents without persisting changes to the database. + +``` +POST /api/release-tracks/:id/bump +``` + +**Request Body:** +```json +{ + "type": "minor", + "dry_run": true +} +``` + +**Response:** Returns the exact snapshot that would be created, with all objects and metadata. + +--- + +## Version Pin Management + +### Update Candidate Version Pin + +Updates which version of an object a candidate reference is pinned to. This allows upgrading a candidate to track a newer version of an object, or downgrading to a previous version. + +``` +POST /api/release-tracks/:id/candidates/:objectRef/update-version +``` + +**Request Body:** +```json +{ + "old_modified": "2024-01-15T10:00:00Z", + "new_modified": "2024-01-20T14:00:00Z" +} +``` + +**Use Cases:** +- Upgrading a candidate to the latest version of an object +- Downgrading to a previous stable version +- Synchronizing with another release track's version + +**Note:** This operation creates a new draft snapshot with the updated version pin. + +### List Object Versions in Release Track + +Lists all versions of a specific object referenced across all tiers (candidates, staged, members) in the release track. + +``` +GET /api/release-tracks/:id/objects/:objectRef/versions +``` + +**Response Example:** +```json +{ + "object_ref": "attack-pattern--T1234", + "versions": [ + { + "modified": "2024-02-15T10:00:00Z", + "tier": "candidates", + "status": "work-in-progress" + }, + { + "modified": "2024-02-01T14:00:00Z", + "tier": "members", + "status": "reviewed" + } + ] +} +``` + +--- + +## Output Formats + +### `bundle` (Default) + +Standard STIX 2.1 bundle. + +### `filesystemstore` + +STIX FileSystemStore directory structure. + +### `workbench` + +Custom format with workflow metadata for UI. + +--- + +## Error Responses + +### AlreadyReleasedError + +**Status:** 409 Conflict + +Snapshot already has a version assigned. + +### InvalidVersionError + +**Status:** 400 Bad Request + +Invalid version format or not greater than previous versions. + +### NotFoundError + +**Status:** 404 Not Found + +Release track not found. + +--- + +## Virtual Release Tracks + +Virtual release tracks are computed aggregations of other release tracks. Unlike standard tracks, virtual tracks don't directly manage objects through the candidate → staged → released workflow. Instead, they compose content from multiple "component tracks" based on configurable rules. + +**Key Characteristics:** +- Compute contents from component standard or virtual tracks +- Only reference **tagged snapshots** from component tracks (never drafts) +- Create snapshots **manually or on schedule** (never event-driven) +- All snapshots start as **drafts** and must be explicitly tagged +- Support **resolution strategies** to control which component versions are included + +**Resolution Strategies:** +1. `latest_tagged` - Always use the most recent tagged snapshot from component +2. `specific_version` - Pin to a specific semantic version (e.g., "5.0") +3. `specific_snapshot` - Pin to a specific snapshot by timestamp + +See [virtual-tracks.md](./virtual-tracks.md) for complete documentation. + +### Create Virtual Track + +``` +POST /api/release-tracks/new +``` + +**Request Body:** +```json +{ + "type": "virtual", + "name": "Enterprise ATT&CK", + "description": "Virtual aggregation of Enterprise content", + "composition": { + "component_tracks": [ + { + "track_id": "GroupsMonthly--uuid", + "resolution_strategy": "latest_tagged", + "filters": { + "object_types": ["intrusion-set"] + } + } + ], + "deduplication": { + "strategy": "prefer_latest_modified", + "tier_resolution": "highest_tier", + "status_resolution": "highest_status" + } + }, + "snapshot_schedule": { + "mode": "cron", + "cron": "0 0 1 1,7 *" + } +} +``` + +### Update Virtual Track Composition + +``` +PUT /api/release-tracks/:id/composition +``` + +**Request Body:** +```json +{ + "component_tracks": [ + { + "track_id": "GroupsMonthly--uuid", + "resolution_strategy": "latest_tagged" + }, + { + "track_id": "TechniquesQuarterly--uuid", + "resolution_strategy": "specific_version", + "version": "2.0" + } + ] +} +``` + +**Note:** Updating composition creates a new draft snapshot with the new composition rules. + +### Create Virtual Snapshot + +``` +POST /api/release-tracks/:id/snapshots/create +``` + +**Request Body:** +```json +{ + "description": "Q1 2024 snapshot" +} +``` + +**Response:** +```json +{ + "stix": { + "id": "x-mitre-collection--virtual-uuid", + "modified": "2024-03-01T10:00:00Z", + "x_mitre_version": null, + "type": "virtual" + }, + "composition_resolution": { + "resolved_at": "2024-03-01T10:00:00Z", + "component_snapshots": [ + { + "track_id": "GroupsMonthly--uuid", + "track_name": "Groups Monthly", + "resolved_snapshot": "2024-02-15T10:00:00Z", + "resolved_version": "5.2", + "strategy_used": "latest_tagged", + "object_count": 47 + } + ], + "total_objects": 870, + "duplicates_resolved": 0 + } +} +``` + +### Preview Virtual Snapshot + +Preview what a snapshot would contain without creating it: + +``` +GET /api/release-tracks/:id/snapshots/preview +``` + +**Response:** +```json +{ + "preview": { + "would_resolve_to": { + "component_snapshots": [...], + "total_objects": 870 + }, + "comparison_to_latest_tagged": { + "current_version": "13.1", + "new_objects": 12, + "updated_objects": 45, + "removed_objects": 3 + } + } +} +``` + +--- + +## Query Variations + +### Snapshot Retrieval Endpoints + +The following retrieval endpoints support `include` and `format` query parameters: + +- `GET /api/release-tracks/:id` (get latest snapshot) +- `GET /api/release-tracks/:id/snapshots/:modified` (get specific snapshot) +- `GET /api/release-tracks/ephemeral/:domain` (get ephemeral bundle) + +**Include Parameter** (controls which tiers are returned): +``` +GET /api/release-tracks/:id # Default: members only +GET /api/release-tracks/:id?include=staged # Members and staged tiers +GET /api/release-tracks/:id?include=candidates # Members and candidates tiers +GET /api/release-tracks/:id?include=all # All tiers (members, staged, candidates) +``` + +**Format Parameter** (controls output format): +``` +GET /api/release-tracks/:id?format=bundle # Standard STIX 2.1 bundle (default) +GET /api/release-tracks/:id?format=filesystemstore # STIX FileSystemStore structure +GET /api/release-tracks/:id?format=workbench # Workbench format with metadata +``` + +**Combined Example:** +``` +GET /api/release-tracks/:id?include=all&format=workbench +``` + +### Bump Operations (Preview & Dry Run) + +The `include` query parameter is **NOT supported** on bump preview or dry-run endpoints: + +- `GET /api/release-tracks/:id/bump/preview` — only `format` is supported +- `POST /api/release-tracks/:id/bump` with `dry_run: true` — only `format` is supported (via request body) + +These endpoints are designed to show exactly what *will* happen during a release bump. Allowing ad-hoc tier filters would be misleading because they do not affect the actual release outcome. \ No newline at end of file diff --git a/docs/user/release-tracks/output-formats.md b/docs/user/release-tracks/output-formats.md new file mode 100644 index 00000000..71fb2a38 --- /dev/null +++ b/docs/user/release-tracks/output-formats.md @@ -0,0 +1,138 @@ +## Output Formats + +Release tracks (or rather, each snapshot) can serialize/export to multiple formats via query parameter: + +``` +GET /api/release-tracks/:id?format= +``` + +### Format: `bundle` (Default) + +Standard STIX 2.1 bundle format: + +```json +{ + "type": "bundle", + "id": "bundle--...", + "objects": [ + { + "type": "x-mitre-collection", + "id": "x-mitre-collection--123", + "x_mitre_version": "1.1", + "x_mitre_contents": ["attack-pattern--aaa", "malware--bbb"], + "name": "ATT&CK Enterprise" + }, + { + "type": "attack-pattern", + "id": "attack-pattern--aaa", + "name": "Technique A", + // ... STIX properties only, no workflow info + } + ] +} +``` + +**Characteristics:** +- STIX 2.1 compliant +- Only includes `stix.*` properties +- No workflow states, no workspace data +- Suitable for external publication + +### Format: `filesystemstore` + +STIX FileSystemStore structure (directory tree): + +``` +collection-123/ + x-mitre-collection/ + x-mitre-collection--123.json + attack-pattern/ + attack-pattern--aaa.json + attack-pattern--bbb.json + malware/ + malware--xxx.json +``` + +**Response:** +```json +{ + "format": "filesystemstore", + "structure": { + "x-mitre-collection": [ + { + "filename": "x-mitre-collection--123.json", + "content": { /* STIX object */ } + } + ], + "attack-pattern": [ + { + "filename": "attack-pattern--aaa.json", + "content": { /* STIX object */ } + } + ] + } +} +``` + +> **NOTE**: The `filesystemstore` is still a *concept* that will need additional refinement before it can be implemented. We will need to figure out an optimal way to return JSON files to the user. Optionally, we can attempt to generate an archive and serialize it over the wire, though this may be slow and error prone. Additionally, we can allow users to specify an output path via S3, FTP, etc. + +### Format: `workbench` (Custom) + +Workbench-optimized format with full metadata: + +```json +{ + "collection": { + "id": "x-mitre-collection--123", + "version": "1.1", + "name": "ATT&CK Enterprise", + "modified": "2024-01-15T16:20:00Z" + }, + "objects": [ + { + "stix": { /* Full STIX object */ }, + "workspace": { + "workflow": { + "status": "reviewed", + "reviewed_by": "admin@example.com", + "reviewed_at": "2024-01-14T10:00:00Z" + } + }, + "metadata": { + "collection_tier": "released", // "released" | "staged" | "candidate" + "object_type": "attack-pattern", + "object_name": "Technique A" + } + } + ], + "summary": { + "released_count": 2, + "staged_count": 1, + "candidate_count": 1 + } +} +``` + +**Characteristics:** +- Includes workflow states +- Includes workspace metadata +- Optimized for Workbench UI consumption +- Shows which tier each object belongs to + +> **NOTE**: The response `workbench` object above is just an example. This is not a prescriptive, final draft. The concept is desribed here to illustrate that we can serve information to the frontend in formats more suitable for UI rendering; we are not beholden to exclusively serving content in STIX-compatible formats. + +### Format Usage + +```bash +# Standard STIX bundle for publication +GET /api/release-tracks/:id?format=bundle + +# FileSystemStore export +GET /api/release-tracks/:id?format=filesystemstore + +# Workbench UI with workflow metadata +GET /api/release-tracks/:id?format=workbench + +# Dry run with detailed preview +GET /api/release-tracks/:id/bump/preview?format=workbench +``` \ No newline at end of file diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md new file mode 100644 index 00000000..c39faa04 --- /dev/null +++ b/docs/user/release-tracks/release-workflow.md @@ -0,0 +1,1110 @@ +# Release Workflow + +## Overview + +This document describes how object workflow states integrate with the release track versioning and release system. It addresses the critical challenge of managing thousands of objects being developed in parallel by multiple users while maintaining clean, production-ready tagged releases. + +**Key Design Decision:** This system uses **release track-centric status with version pinning** to solve the "STIX freeze" problem. Each release track tracks its own workflow status for objects and pins to specific object versions, allowing the same object to be in different states across different release tracks and enabling work on future releases while current releases are frozen. + +**Note on Terminology:** We use **release track** instead of "collection" to avoid confusion with TAXII collections, MongoDB collections, STIX bundles, and `x-mitre-collection` SDOs. See [terminology.md](./terminology.md) for the complete terminology guide. + +**Related Documentation:** +- [member-sync-strategies.md](../../developer/release-tracks/member-sync-strategies.md) - Automatic tracking of new member object revisions + +## Core Concepts + +### Object Workflow States (Release Track-Centric) + +Workflow status is **scoped to each release track**, not global to the object. This means: +- The same object can have different workflow states in different release tracks +- Release Track A can track an object as "reviewed" while Release Track B tracks it as "work-in-progress" +- Each release track independently manages which objects are ready for release + +The three workflow states tracked per release track: +1. **work-in-progress** - Object is being actively developed, not ready for review +2. **awaiting-review** - Object is complete and waiting for team review +3. **reviewed** - Object has been reviewed and approved, ready for release + +### Version Pinning + +Each tier entry includes **version pinning** via the `object_modified` timestamp: +- Release tracks track a reference to a **specific version** of an object (identified by its `stix.modified` timestamp) +- Different release tracks can pin to different versions of the same object +- This enables working on future object versions while a tagged release containing an earlier version is frozen + +### Release Track Membership Tiers + +Release tracks maintain objects in three distinct tiers, with each entry pinning to a specific object version: + +1. **Candidates** (`candidates`) - Objects being worked on with track-scoped status +2. **Staged** (`staged`) - Reviewed objects (in this release track) ready for the next tagged release +3. **Released** (`members`) - Object versions included in the current/latest tagged release + +### Automatic Promotion Flow + +``` +Object version added to release track + ↓ +Track-scoped status: work-in-progress → Added to candidates with version pin + ↓ +Track-scoped status: awaiting-review → Remains in candidates + ↓ +Track-scoped status: reviewed → Automatically promoted to staged + ↓ +Snapshot tagged → staged entries moved to members + ↓ +Snapshot exported → members reflected in stix.x_mitre_contents of the output bundle +``` + +### STIX Freeze Solution + +Version pinning solves the "STIX freeze" problem: + +**The Problem:** +- User completes changes to Object A for Release Track A's next tagged release +- Cannot start working on Object A for the next-next release until after current release ships +- Must wait for STIX freeze to end before continuing development + +**The Solution:** +- Release Track A pins to `attack-pattern--A, modified: 2024-01-15T10:00:00Z` for the v1.5 tagged release +- User creates new version of Object A: `attack-pattern--A, modified: 2024-01-20T14:00:00Z` +- New version can be added to Release Track A as a candidate for the NEXT release (v1.6) +- Release Track A now tracks TWO versions of the same object: + - Released tier: `modified: 2024-01-15T10:00:00Z` (frozen for v1.5) + - Candidates tier: `modified: 2024-01-20T14:00:00Z` (in development for v1.6) +- Work continues on v1.6 while v1.5 is frozen + +## Candidacy Threshold Configuration + +Release tracks can be configured with different thresholds for what workflow states are acceptable. This controls how the "auto-promotion" mechanism works; if an object status meets the candidacy threshold, then the object will be automatically staged for the next release. + +Typical release tracks will use the default candidacy threshold setting of `reviewed`, which requires that the object(s) status be `reviewed` in order for the object to become staged. + +However, smaller teams operating in purely developmenet or research capacities may prefer a more permissive model. Perhaps they simply want all objects to be included in the release irrespective of object status. In such situations, the candidacy threshold can be lowered to `awaiting-review` or `work-in-progress`. + +### Option 1: Include Only Reviewed (Default) +```javascript +workspace.config.candidacy_threshold = "reviewed" +``` +- Only objects with `status: "reviewed"` are auto-promoted to staged +- Strictest option for production collections + +### Option 2: Include Awaiting Review +```javascript +workspace.config.candidacy_threshold = "awaiting-review" +``` +- Objects with `status: "awaiting-review"` or `"reviewed"` are auto-promoted to staged +- Good for collections with trusted contributors + +### Option 3: Include Work in Progress +```javascript +workspace.config.candidacy_threshold = "work-in-progress" +``` +- All objects are immediately promoted to staged +- Useful for development/testing collections +- No filtering based on workflow status + +## Workflow Operations + +### 1. Adding Objects as Candidates (with Version Pinning) + +Candidates can be added or modified with the following endpoint: +``` +POST /api/release-tracks/:id/candidates +``` + +**Request Body:** +```json +{ + "object_refs": [ + { + "id": "attack-pattern--eee", + "modified": "2024-01-12T09:00:00Z" // Optional: pin to specific version, defaults to latest + }, + { + "id": "attack-pattern--fff" // No modified = use latest version + } + ] +} +``` + +**Simplified Request Body (defaults to latest version):** +```json +{ + "object_refs": ["attack-pattern--eee", "attack-pattern--fff"] +} +``` + +**Response:** +```json +{ + "added": [ + { + "object_ref": "attack-pattern--eee", + "object_modified": "2024-01-12T09:00:00Z", + "status": "work-in-progress", + "added_to": "candidates" + }, + { + "object_ref": "attack-pattern--fff", + "object_modified": "2024-01-13T14:00:00Z", + "status": "work-in-progress", + "added_to": "staged" // Auto-promoted if meets threshold + } + ], + "errors": [] +} +``` + +**Business Logic:** +1. Validate all object_refs exist +2. Resolve `object_modified` timestamp: + - If provided: validate that specific version exists + - If omitted: use latest version (highest `stix.modified`) +3. Set initial track-scoped status (defaults to "work-in-progress") +4. Add to `workspace.candidates` with version pin +5. If status meets `candidacy_threshold`, auto-promote to `workspace.staged` +6. Update object's `workspace.referenced_by` array + +Importantly, candidate removal/deletion must occur separately using the `DELETE` operation: +```bash +DELETE /api/release-tracks/:id/candidates +``` + +### 2. Bulk Workflow Status Change + +Update the workflow status of many candidates at once using one bulk operation. Target specific candidates using `object_refs` or all objects by omitting. +``` +POST /api/release-tracks/:id/candidates/Review +``` + +**Request Body:** +```json +{ + "from": "work-in-progress", + "to": "awaiting-review", + "object_refs": ["attack-pattern--eee"] // optional, transitions all if omitted +} +``` + +**Response:** +```json +{ + "transitioned": [ + { + "object_ref": "attack-pattern--eee", + "object_modified": "2024-01-12T09:00:00Z", + "old_status": "work-in-progress", + "new_status": "awaiting-review", + "promoted_to_staged": false // Doesn't meet threshold yet + } + ], + "errors": [] +} +``` + +**Business Logic:** +1. Find all entries in `workspace.candidates` matching `from` status +2. Filter by `object_refs` if provided +3. Update track-scoped status for each entry from `from` to `to` +4. If new status meets `candidacy_threshold`, promote entry to `workspace.staged` +5. Update object's `workspace.referenced_by` to reflect new status +6. Fire `collection:status-changed` event for each object +7. Return summary of transitions + +**Important:** This only affects status within THIS collection. The same object may have different statuses in other collections. + +### 3. Manual Promotion to Staged + +Editors may bypass auto-promotion (via candidacy threshold) by *manually* promoting candidates to the staged status. Importantly, the workflow status will remain unchanged which may be in conflict with the release track's candidacy threshold setting. Realistic use cases include situations where WIP objects need to be rushed out the door in some imminent release before a reviewer has time to officially review and update its workflow status accordingly. +``` +POST /api/release-tracks/:id/candidates/promote +``` + +**Request Body:** +```json +{ + "object_refs": ["attack-pattern--eee"] +} +``` + +**Response:** +```json +{ + "promoted": [ + { + "object_ref": "attack-pattern--eee", + "status": "work-in-progress", + "warning": "Object is not reviewed, manual override applied" + } + ] +} +``` + +**Business Logic:** +- Allows manual promotion even if status doesn't meet threshold +- Useful for exceptions or urgent fixes +- Logs warning for audit trail + +### 4. Promotion Conflict Resolution + +When promoting objects between tiers, conflicts can occur if multiple versions of the same object (same `stix.id`, different `stix.modified` timestamps) exist. Release tracks use **conflict resolution policies** to determine how to handle these situations. + +**When do conflicts occur?** +- Promoting from `candidates` to `staged` when a different version of the object already exists in `staged` +- Promoting from `staged` to `members` (during tagging/release) when a different version already exists in `members` + +**Promotions can happen via:** +- **Manual promotion** via REST API endpoint (e.g., `POST /api/release-tracks/:id/candidates/promote`) +- **Auto-promotion** based on candidacy threshold (e.g., object status changes to `awaiting-review`) +- **Tagging/release operations** (e.g., `POST /api/release-tracks/:id/bump`) + +#### Conflict Resolution Policies + +Release tracks can be configured with different policies for handling promotion conflicts: + +```javascript +config: { + promotion_conflicts: { + candidates_to_staged: "prefer_latest", // Candidates → Staged promotions + staged_to_members: "abort" // Staged → Members promotions (during release) + } +} +``` + +#### Policy Options + +##### 1. `always_overwrite` + +Always keep the incoming object and discard the incumbent object. + +**Example:** +```javascript +// Current state: +// - staged: attack-pattern--T1234, modified: 2024-01-15 + +// Promotion request: +// - Promote attack-pattern--T1234, modified: 2024-02-20 from candidates to staged + +// Result with always_overwrite: +// - staged: attack-pattern--T1234, modified: 2024-02-20 (new version) +// - candidates: attack-pattern--T1234, modified: 2024-02-20 (removed) +``` + +**Use case:** "Always use the latest work, overwrite previous versions" + +##### 2. `always_reject` + +Always keep the incumbent object and reject the incoming object. The rejected object remains in its current tier. + +**Example:** +```javascript +// Current state: +// - staged: attack-pattern--T1234, modified: 2024-01-15 + +// Promotion request: +// - Promote attack-pattern--T1234, modified: 2024-02-20 from candidates to staged + +// Result with always_reject: +// - staged: attack-pattern--T1234, modified: 2024-01-15 (unchanged) +// - candidates: attack-pattern--T1234, modified: 2024-02-20 (stays in candidates) + +// Response: +{ + "rejected": [ + { + "object_ref": "attack-pattern--T1234", + "object_modified": "2024-02-20T10:00:00Z", + "reason": "Conflict: Different version already in staged tier", + "incumbent_version": "2024-01-15T10:00:00Z", + "resolution": "Rejected per always_reject policy" + } + ] +} +``` + +**Use case:** "Protect already-staged content from being overwritten" + +##### 3. `prefer_latest` + +Keep whichever version has the newer `modified` timestamp. + +**Example:** +```javascript +// Current state: +// - staged: attack-pattern--T1234, modified: 2024-01-15 + +// Promotion request: +// - Promote attack-pattern--T1234, modified: 2024-02-20 from candidates to staged + +// Result with prefer_latest: +// - staged: attack-pattern--T1234, modified: 2024-02-20 (newer version wins) +``` + +**Use case:** "Trust the most recent edits, regardless of current tier" + +##### 4. `abort` (Tagging/Release Operations Only) + +[](./release-workflow.md#4-abort-taggingrelease-operations-only) +**Only available for `staged_to_members` during tagging/release operations.** + +If a conflict occurs during a tagging/release operation (`POST /api/release-tracks/:id/bump`), reject and abort the entire release. The snapshot will NOT be tagged, and no immutable snapshot will be created. + +**The error response will include ALL conflicting objects**, not just the first one encountered. This allows editors to see the full scope of conflicts that must be resolved before the release can proceed. + +**Example with single conflict:** +```javascript +// Current state: +// - members: attack-pattern--T1234, modified: 2024-01-15 +// - staged: attack-pattern--T1234, modified: 2024-02-20 + +// Tagging request: +POST /api/release-tracks/release-track--123/bump +{ "type": "minor" } + +// Result with abort: +// ERROR Response: +{ + "error": "ReleaseConflictError", + "message": "Cannot complete release: 1 conflict(s) detected", + "conflicts": [ + { + "object_ref": "attack-pattern--T1234", + "incumbent_version": "2024-01-15T10:00:00Z", + "incoming_version": "2024-02-20T10:00:00Z" + } + ] +} +``` + +**Example with multiple conflicts:** +```javascript +// Current state: +// - members: attack-pattern--T1234, modified: 2024-01-15 +// - members: attack-pattern--T5678, modified: 2024-01-16 +// - staged: attack-pattern--T1234, modified: 2024-02-20 +// - staged: attack-pattern--T5678, modified: 2024-02-21 +// - staged: attack-pattern--T9999, modified: 2024-02-22 (no conflict) + +// Tagging request: +POST /api/release-tracks/release-track--123/bump +{ "type": "minor" } + +// Result with abort - shows ALL conflicts: +// ERROR Response: +{ + "error": "ReleaseConflictError", + "message": "Cannot complete release: 2 conflict(s) detected", + "conflicts": [ + { + "object_ref": "attack-pattern--T1234", + "incumbent_version": "2024-01-15T10:00:00Z", + "incoming_version": "2024-02-20T10:00:00Z" + }, + { + "object_ref": "attack-pattern--T5678", + "incumbent_version": "2024-01-16T10:00:00Z", + "incoming_version": "2024-02-21T10:00:00Z" + } + ] +} + +// State unchanged: +// - Snapshot NOT tagged +// - No new version history entry +// - Objects remain in current tiers +// - Editor must resolve both conflicts before retrying +``` + +**Use case:** "Never accidentally overwrite released content during a release; require explicit conflict resolution" + +**Why abort is important:** Once a snapshot is tagged and released, it becomes immutable. The `abort` policy ensures that releases don't inadvertently overwrite existing released content, providing an additional safety guardrail for critical release operations. + +**Why report all conflicts:** When multiple conflicts exist, reporting all of them in a single error response allows editors to address all issues at once, rather than discovering them one at a time through repeated release attempts. This significantly improves the workflow efficiency when dealing with complex release scenarios. + +#### Configuring Conflict Resolution Policies + +**Update release track configuration:** +```bash +PUT /api/release-tracks/:id/config +``` + +**Request:** +```json +{ + "promotion_conflicts": { + "candidates_to_staged": "prefer_latest", + "staged_to_members": "abort" + } +} +``` + +**Default values:** +- `candidates_to_staged`: `"prefer_latest"` +- `staged_to_members`: `"abort"` + +#### Best Practices + +1. **Production tracks**: Use `abort` for `staged_to_members` to prevent accidental overwrites during releases +2. **Development tracks**: Use `always_overwrite` or `prefer_latest` for faster iteration +3. **Review conflicts before releasing**: Always run `GET /api/release-tracks/:id/bump/preview` to identify potential conflicts +4. **Manual resolution**: When `abort` triggers, manually resolve conflicts before retrying the release + +### 5. Viewing Latest Snapshot with All Tiers + +Set the `include` query parameter to `members`, `staged`, `candidates` or `all` to view different subsets of a given snapshot. +``` +GET /api/release-tracks/:id?include=all +``` + +**Response:** +```json +{ + "id": "release-track--123", + "version": "1.1", + "members": [ + { + "ref": "attack-pattern--aaa", + "modified": "2024-01-10T10:00:00Z" + }, + { + "ref": "malware--bbb", + "modified": "2024-01-11T14:30:00Z" + } + ], + "staged": [ + { + "ref": "attack-pattern--ddd", + "modified": "2024-01-14T10:00:00Z", + "status": "reviewed", + "object_name": "New Technique XYZ" + } + ], + "candidates": [ + { + "ref": "attack-pattern--eee", + "modified": "2024-01-12T09:00:00Z", + "status": "work-in-progress", + "object_name": "WIP Technique" + } + ], + "summary": { + "members_count": 2, + "staged_count": 1, + "candidate_count": 1, + "total_count": 4 + } +} +``` + +### 6. Preview Release + +Compute a release preview, which outputs a verbose diff of what will change in the next release. **This endpoint will detect and report all conflicts** that would prevent the release from proceeding, allowing editors to resolve issues before attempting to tag. + +``` +GET /api/release-tracks/:id/bump/preview +``` + +**Response (success - no conflicts):** +```json +{ + "current_version": "1.1", + "next_version": "1.2", + "release_preview": { + "will_include": [ + { + "ref": "attack-pattern--aaa", + "modified": "2024-01-10T10:00:00Z", + "object_type": "attack-pattern", + "name": "Technique A", + "status": "reviewed", + "source": "members" + }, + { + "ref": "attack-pattern--ddd", + "modified": "2024-01-14T10:00:00Z", + "object_type": "attack-pattern", + "name": "New Technique XYZ", + "status": "reviewed", + "source": "staged" + } + ], + "will_exclude": [ + { + "ref": "attack-pattern--eee", + "modified": "2024-01-12T09:00:00Z", + "object_type": "attack-pattern", + "name": "WIP Technique", + "status": "work-in-progress", + "reason": "Object is work-in-progress, not meeting candidacy threshold" + } + ] + }, + "statistics": { + "total_objects": 3, + "included_objects": 2, + "excluded_objects": 1 + } +} +``` + +**Response (with conflicts detected):** +```json +{ + "track_id": "release-track--123", + "snapshot_modified": "2024-01-15T16:20:00.000Z", + "is_already_tagged": false, + "current_version": null, + "next_version_minor": "1.2", + "next_version_major": "2.0", + "staged_count": 3, + "members_count": 2, + "candidates_count": 1, + "conflicts": [ + { + "object_ref": "attack-pattern--T1234", + "incumbent_version": "2024-01-15T10:00:00Z", + "incoming_version": "2024-02-20T10:00:00Z" + }, + { + "object_ref": "attack-pattern--T5678", + "incumbent_version": "2024-01-16T10:00:00Z", + "incoming_version": "2024-02-21T10:00:00Z" + } + ] +} +``` + +**Note:** When the `staged_to_members` conflict policy is set to `abort` and conflicts are detected, the preview will include a `conflicts` array listing **all** conflicting objects, not just the first one encountered. + +### 7. Bump with Staging + +``` +POST /api/collections/:id/bump +``` + +**Request:** +```json +{ + "type": "minor", + "dry_run": false // <-- optionally perform a dry run to preview the next release4 +} +``` + +**Response:** +```json +{ + "id": "release-track--123", + "snapshot_id": "2024-01-15T16:20:00.000Z", + "modified": "2024-01-15T16:20:00Z", + "version": "1.2", + "members": [ + { + "ref": "attack-pattern--aaa", + "modified": "2024-01-10T10:00:00Z" + }, + { + "ref": "malware--bbb", + "modified": "2024-01-11T14:30:00Z" + }, + { + "ref": "attack-pattern--ddd", + "modified": "2024-01-14T10:00:00Z" // Promoted from staged + } + ], + "release_summary": { + "promoted_from_staged": [ + { + "ref": "attack-pattern--ddd", + "modified": "2024-01-14T10:00:00Z", + "name": "New Technique XYZ" + } + ], + "remaining_staged": [], + "remaining_candidates": [ + { + "ref": "attack-pattern--eee", + "modified": "2024-01-12T09:00:00Z", + "name": "WIP Technique", + "status": "work-in-progress" + } + ] + } +} +``` + +**Business Logic:** +1. Validate no `AlreadyReleasedError` +2. Calculate next version +3. Move all entries from `staged` to `members` (preserving version pins) +4. Update object documents: change tier in `workspace.referenced_by` from "staged" → "members" +5. Set `version` on release track +6. Add entry to `version_history` +7. Return summary showing what was promoted + +**Note on Version Pins:** The `modified` timestamps are preserved during promotion. Released objects remain pinned to the specific version that was reviewed and staged. + +## Solving the STIX Freeze Problem + +### The Problem in Detail + +Workbench was originally designed such that objects have a single global status. When preparing a release: +1. Object A is marked "reviewed" and frozen for the v1.5 release +2. Release process begins (can take days or weeks) +3. During this freeze period, developers **cannot** work on Object A for the v1.6 release +4. Must wait for v1.5 release to complete before resuming work +5. This creates significant workflow bottlenecks + +### The Solution: Version Pinning + Collection-Scoped Status + +With version pinning, collections track specific object versions: + +**Step-by-Step Example:** + +```bash +# 1. Initial state: Collection v1.4 released +GET /api/release-tracks/release-track--enterprise +# Response shows: +# - version: "1.4" +# - members includes: attack-pattern--T1234, modified: 2024-01-01T10:00:00Z + +# 2. Developer updates T1234 for v1.5 release +POST /api/objects/attack-pattern--T1234 +{ "description": "Updated for v1.5..." } +# Creates NEW version: attack-pattern--T1234, modified: 2024-02-01T14:00:00Z + +# 3. Add new version to collection as candidate +POST /api/collections/collection--enterprise/candidates +{ + "object_refs": [{ + "id": "attack-pattern--T1234", + "modified": "2024-02-01T14:00:00Z" // Pin to new version + }] +} + +# 4. Review and promote to staged +POST /api/collections/collection--enterprise/candidates/review +{ + "object_refs": [{ + "id": "attack-pattern--T1234", + "modified": "2024-02-01T14:00:00Z" + }], + "from": "work-in-progress", + "to": "reviewed" +} +# → Promoted to staged tier + +# 5. Bump collection to v1.5 +POST /api/collections/collection--enterprise/bump +{ "type": "minor" } +# → Release track now at v1.5 +# → Released tier: attack-pattern--T1234, modified: 2024-02-01T14:00:00Z + +# 6. *** KEY MOMENT: v1.5 is now frozen, but we can keep working! *** + +# 7. Developer immediately starts work on v1.6 changes +POST /api/objects/attack-pattern--T1234 +{ "description": "Updated for v1.6..." } +# Creates ANOTHER new version: attack-pattern--T1234, modified: 2024-02-15T09:00:00Z + +# 8. Add v1.6 version to collection as candidate while v1.5 is still published +POST /api/collections/collection--enterprise/candidates +{ + "object_refs": [{ + "id": "attack-pattern--T1234", + "modified": "2024-02-15T09:00:00Z" // Pin to newest version + }] +} + +# 9. Current collection state: +GET /api/release-tracks/release-track--enterprise?include=all +# Response shows: +# { +# "version": "1.5", +# "members": [{ +# "ref": "attack-pattern--T1234", +# "modified": "2024-02-01T14:00:00Z" // v1.5 version (FROZEN) +# }], +# "candidates": [{ +# "ref": "attack-pattern--T1234", +# "modified": "2024-02-15T09:00:00Z", // v1.6 version (IN DEVELOPMENT) +# "status": "work-in-progress" +# }] +# } + +# Same object, TWO versions tracked simultaneously: +# - members: 2024-02-01 version (frozen for v1.5) +# - candidates: 2024-02-15 version (in development for v1.6) +``` + +### Multi-Collection Independence + +Version pinning also solves cross-collection conflicts: + +```bash +# Collection A: Enterprise ATT&CK +POST /api/collections/collection--enterprise/candidates +{ + "object_refs": [{ + "id": "attack-pattern--T1234", + "modified": "2024-01-15T10:00:00Z" // Pin to specific version + }] +} +# Status in Enterprise collection: "work-in-progress" + +# Collection B: Mobile ATT&CK +POST /api/collections/collection--mobile/candidates +{ + "object_refs": [{ + "id": "attack-pattern--T1234", + "modified": "2024-01-20T14:00:00Z" // Pin to DIFFERENT version + }] +} +# Status in Mobile collection: "reviewed" + +# Collections are completely independent: +# - Enterprise tracks v1 (2024-01-15) as WIP +# - Mobile tracks v2 (2024-01-20) as reviewed +# - No conflict of interest +# - No cross-collection coupling +``` + +### Updating Version Pins + +Collections can update which version they're tracking: + +``` +POST /api/release-tracks/:id/candidates/:objectRef/update-version +``` + +**Request:** +```json +{ + "old_modified": "2024-01-15T10:00:00Z", + "new_modified": "2024-01-20T14:00:00Z" +} +``` + +**Use Cases:** +- Upgrading a candidate to latest version +- Downgrading to previous stable version +- Synchronizing with another collection's version + +## Workflow Scenarios + +### Scenario 1: Standard Release Cycle + +```bash +# 1. Add new techniques as candidates +POST /api/collections/collection--123/candidates +{ "object_refs": ["attack-pattern--new1", "attack-pattern--new2"] } + +# 2. Work on objects (they start as work-in-progress) +# ... development happens ... + +# 3. Transition to awaiting review +POST /api/collections/collection--123/candidates/review +{ + "from": "work-in-progress", + "to": "awaiting-review", + "object_refs": ["attack-pattern--new1"] +} + +# 4. Review and approve +POST /api/collections/collection--123/candidates/review +{ + "from": "awaiting-review", + "to": "reviewed", + "object_refs": ["attack-pattern--new1"] +} +# → auto-promoted to workspace.staged + +# 5. Preview the release +GET /api/release-tracks/collection--123/bump/preview +# → Shows attack-pattern--new1 will be included + +# 6. Bump the collection +POST /api/collections/collection--123/bump +{ "type": "minor" } +# → attack-pattern--new1 moved to x_mitre_contents +# → attack-pattern--new2 remains in candidates (still WIP) +``` + +### Scenario 2: Bulk Review Before Release + +```bash +# Team has been working on 50 techniques +# All are awaiting-review + +# Preview what's ready +GET /api/release-tracks/collection--123/candidates?status=awaiting-review +# → Returns 50 candidates + +# Bulk approve all awaiting review +POST /api/collections/collection--123/candidates/review +{ + "from": "awaiting-review", + "to": "reviewed" +} +# → All 50 auto-promoted to staged + +# Preview release +GET /api/release-tracks/collection--123/bump/preview +# → Shows all 50 will be included + +# Release +POST /api/collections/collection--123/bump +{ "type": "major" } +# → All 50 moved to x_mitre_contents +``` + +### Scenario 3: STIX Freeze Workflow (No Bottleneck) + +```bash +# Realistic timeline demonstrating no freeze bottleneck + +# January 15: Preparing v1.5 release +POST /api/collections/collection--123/candidates +{ + "object_refs": [ + { "id": "attack-pattern--A", "modified": "2024-01-15T10:00:00Z" }, + { "id": "attack-pattern--B", "modified": "2024-01-15T11:00:00Z" } + ] +} + +# January 20: Review complete, promote to staged +POST /api/collections/collection--123/candidates/review +{ + "from": "awaiting-review", + "to": "reviewed" +} + +# January 25: Bump to v1.5 (freeze begins for v1.5 release) +POST /api/collections/collection--123/bump +{ "type": "minor" } +# v1.5 now released with: +# - attack-pattern--A, modified: 2024-01-15T10:00:00Z +# - attack-pattern--B, modified: 2024-01-15T11:00:00Z + +# January 26: *** v1.5 is frozen, but work continues on v1.6 *** + +# Developer starts new changes to Object A +POST /api/objects/attack-pattern--A +{ "description": "Changes for v1.6..." } +# Creates: attack-pattern--A, modified: 2024-01-26T09:00:00Z + +# Add new version as candidate for v1.6 +POST /api/collections/collection--123/candidates +{ + "object_refs": [{ + "id": "attack-pattern--A", + "modified": "2024-01-26T09:00:00Z" // New version + }] +} + +# February 10: More v1.6 work continues +POST /api/objects/attack-pattern--A +{ "description": "More v1.6 updates..." } +# Creates: attack-pattern--A, modified: 2024-02-10T14:00:00Z + +# Update candidate pin to latest version +POST /api/collections/collection--123/candidates/attack-pattern--A/update-version +{ + "old_modified": "2024-01-26T09:00:00Z", + "new_modified": "2024-02-10T14:00:00Z" +} + +# March 1: v1.5 freeze finally ends, v1.6 work is already mostly complete! +# Current state: +# - members (v1.5): attack-pattern--A, modified: 2024-01-15 (still frozen) +# - candidates: attack-pattern--A, modified: 2024-02-10 (already in review) + +# March 5: Bump to v1.6 +POST /api/collections/collection--123/bump +{ "type": "minor" } +# No bottleneck - work continued throughout v1.5 freeze +``` + +### Scenario 4: Development Collection (Permissive Threshold) + +```bash +# Configure collection to include WIP objects +PUT /api/release-tracks/collection--dev/config +{ + "candidacy_threshold": "work-in-progress", + "auto_promote": true +} + +# Add candidates +POST /api/collections/collection--dev/candidates +{ "object_refs": ["attack-pattern--exp1"] } +# → Immediately promoted to staged (meets threshold) + +# Bump immediately +POST /api/collections/collection--dev/bump +{ "type": "minor" } +# → WIP objects included in release +``` + +## Best Practices + +### 1. Use Appropriate Thresholds + +- **Production collections**: `candidacy_threshold: "reviewed"` +- **Team preview collections**: `candidacy_threshold: "awaiting-review"` +- **Development collections**: `candidacy_threshold: "work-in-progress"` + +### 2. Leverage Dry Run + +Always preview releases before bumping: +```bash +GET /api/release-tracks/:id/bump/preview?format=workbench +``` + +### 3. Bulk Operations for Efficiency + +Use bulk transitions for large-scale reviews: +```bash +POST /api/release-tracks/:id/candidates/review +{ + "from": "awaiting-review", + "to": "reviewed" +} +``` + +### 4. Monitor Candidates + +Regularly check candidate status: +```bash +GET /api/release-tracks/:id?include=all +``` + +### 5. Use Events for Automation + +Set up event handlers for: +- Notifications when objects are reviewed +- Auto-staging based on custom rules +- Audit logging for compliance + +--- + +## Virtual Release Track Workflows + +Virtual release tracks follow a different workflow since they don't manage objects directly. Instead, they aggregate content from component tracks. + +See [virtual-tracks.md](virtual-tracks.md) for complete virtual track documentation. + +### Basic Virtual Track Workflow + +``` +1. Create virtual track (one-time setup) + - Define which component tracks to aggregate + - Configure resolution strategies (latest_tagged, specific_version, etc.) + - Set up snapshot schedule (optional) + +2. Component tracks release independently + - GroupsMonthly releases v1.0, v1.1, v1.2, etc. + - TechniquesQuarterly releases v2.0, v2.1, etc. + - Each on their own cadence + +3. Virtual track snapshot creation (manual or scheduled) + - Resolves latest (or pinned) version from each component + - Creates draft snapshot with resolved composition + - Team receives notification to review + +4. Review and tag + - Team reviews which component versions were included + - Verifies object counts and composition + - Tags snapshot when satisfied +``` + +### Example: Enterprise Virtual Track + +**Setup (one-time):** + +```bash +POST /api/release-tracks/new +{ + "type": "virtual", + "name": "Enterprise ATT&CK", + "composition": { + "component_tracks": [ + { + "track_id": "GroupsMonthly--uuid", + "resolution_strategy": "latest_tagged" + }, + { + "track_id": "TechniquesQuarterly--uuid", + "resolution_strategy": "latest_tagged" + } + ] + }, + "snapshot_schedule": { + "mode": "dates", + "dates": ["2024-01-15T00:00:00Z", "2024-07-15T00:00:00Z"] + } +} +``` + +**Ongoing workflow:** + +``` +Timeline: + +January - June: + - GroupsMonthly releases v1.0, v1.1, v1.2, v1.3, v1.4, v1.5 + - TechniquesQuarterly releases v2.0, v2.1 + +July 15 (scheduled): + - Virtual track snapshot auto-created + - Resolves to: + * GroupsMonthly v1.5 (latest tagged as of July 15) + * TechniquesQuarterly v2.1 (latest tagged as of July 15) + - Draft snapshot created + +July 16 (manual): + - Team reviews draft + - Verifies composition + - Tags as Enterprise v14.0 +``` + +### Key Differences from Standard Tracks + +| Aspect | Standard Track | Virtual Track | +|--------|---------------|---------------| +| Object management | Direct (add candidates, transition status) | Indirect (composed from components) | +| Snapshot creation | When objects/config change | Manual or scheduled only | +| Workflow states | Candidates → Staged → Members | N/A (uses component track states) | +| Version control | Own object versions | Aggregates component versions | +| Primary use case | Source of truth for objects | Publication/release packaging | + +### Virtual Track Best Practices + +1. **Organize standard tracks by object type or domain** + - Example: GroupsMonthly, TechniquesQuarterly, SoftwareBiannual + +2. **Use virtual tracks for publication** + - Standard tracks = internal working tracks + - Virtual tracks = external publication releases + +3. **Schedule snapshots in advance** + - Define release dates up front for predictability + - Use `mode: "dates"` with explicit schedule + +4. **Always review before tagging** + - Scheduled snapshots create drafts + - Manually review composition resolution + - Tag only when satisfied + +5. **Pin component versions conservatively** + - Default to `latest_tagged` to stay current + - Pin only when stability required + +### Virtual Track Constraints + +- Only references **tagged snapshots** from components (never drafts) +- Snapshots created **manually or on schedule** (never event-driven) +- All snapshots start as **drafts** (must explicitly tag) +- Component tracks must have at least one tagged release +- Circular dependencies not allowed + diff --git a/docs/user/release-tracks/summary.md b/docs/user/release-tracks/summary.md new file mode 100644 index 00000000..dd2c4717 --- /dev/null +++ b/docs/user/release-tracks/summary.md @@ -0,0 +1,233 @@ +# Release Tracks API V2 - Design Summary + +## Overview + +This document provides a high-level summary of the Release Tracks API refactor (a.k.a. "Collections V2"), tying together the versioning system and workflow integration. + +**Note on Terminology:** We're replacing the overloaded term "collection" with **release track** to avoid confusion with TAXII collections, MongoDB collections, STIX bundles, and `x-mitre-collection` SDOs. See [terminology.md](./terminology.md) for complete terminology guide. + +## Problem Statement + +The existing Collections API has five major issues: + +1. **Confusing API design** - Three separate routers (stix-bundles, collection-bundles, collections) for related functionality +2. **No version control** - Release tracks can't be tagged as releases or track version history +3. **No workflow integration** - Thousands of objects being developed in parallel with no way to filter by readiness state +4. **STIX freeze bottleneck** - Objects frozen for one release can't be modified for the next release, blocking parallel development +5. **Cross-track duplication** - Teams want to track the same objects across release tracks with different cadences (e.g., monthly Groups releases + twice-yearly Enterprise releases), leading to duplicate tracking overhead and concept fatigue + +## Solution Architecture + +### 0. Standard and Virtual Release Tracks + +The Release Tracks API supports two types of release tracks: + +**Standard Release Tracks** - Direct object lifecycle management (the traditional model) +- Manage objects through the candidate → staged → released workflow +- Source of truth for specific object types or content domains +- Create snapshots when objects are added/removed or configuration changes +- Examples: "GroupsMonthly", "TechniquesQuarterly", "SoftwareBiannual" + +**Virtual Release Tracks** - Computed aggregations of other release tracks (NEW) +- Compose content from multiple standard (or other virtual) tracks +- No duplicate object tracking - objects managed in source tracks only +- Create snapshots manually or on schedule (never event-driven) +- Always compose from tagged snapshots only (never drafts) +- Examples: "EnterpriseTwiceAnnual" (aggregates Groups + Techniques + Software) + +**Use Case for Virtual Tracks:** + +Teams can organize objects into modular standard tracks by type (e.g., one track for Groups, one for Techniques), each with its own release cadence. Then create virtual tracks that aggregate these into domain-specific releases (e.g., Enterprise ATT&CK = Groups + Techniques + Software) without duplicating object tracking. + +See [virtual-tracks.md](./virtual-tracks.md) for complete virtual track documentation. + +### 1. Unified API Structure + +**Old API:** +``` +GET /api/stix-bundles (ephemeral bundles) +GET /api/collection-bundles (export) +POST /api/collection-bundles (import) +GET /api/collections (list) +POST /api/collections (create) +GET /api/collections/:id (retrieve) +``` + +**New API V2 (partial preview):** + +The new API is still a work in progress. The source of truth is located in [api-reference.md](./api-reference.md). The following is a preview. If there are any discrepencies between what is shown here and what is shown in [api-reference.md](./api-reference.md), defer to the latter. +``` +# Ephemeral bundles (stateless) +GET /api/release-tracks/ephemeral/:domain + +# Release track management +POST /api/release-tracks/new +GET /api/release-tracks/:id +POST /api/release-tracks/:id/config +POST /api/release-tracks/:id/meta +POST /api/release-tracks/:id/clone +PUT /api/release-tracks/:id/bump +POST /api/release-tracks/:id/archive +DELETE /api/release-tracks/:id + +# Candidate/workflow management +POST /api/release-tracks/:id/candidates +POST /api/release-tracks/:id/candidates/review + +# Snapshot-specific operations +GET /api/release-tracks/:id/snapshots/:modified +POST /api/release-tracks/:id/snapshots/:modified/config +POST /api/release-tracks/:id/snapshots/:modified/meta +POST /api/release-tracks/:id/snapshots/:modified/clone +DELETE /api/release-tracks/:id/snapshots/:modified +PUT /api/release-tracks/:id/snapshots/:modified/bump +``` + +### 2. Git-Inspired Versioning + +We borrow heavily concepts from git. Snapshots are sort of like commits and tagged releases are like git tags. A release track contains snapshots: delta permutations that can be linearly tracked to deduce how the release track has evolved over time. A snapshot is generated every time a change is made, whether that be adding/removing objects, updating the release track configuration, or renaming the release track altogether. + +**Snapshots** (like Git commits) +- Every modification creates a new snapshot +- Identified by `stix.modified` timestamp +- Immutable once created +- Complete audit trail +- May be a **draft release** (untagged) or **tagged release** (has version number) + +**Tagged Releases** (like Git tags) +- Snapshots are tagged with `version`, which when exported/retrieved as a STIX bundle, will be expressed as `x_mitre_version`. Draft snapshots are denoted by the fact that their `version` key is set to `null`. +- Uses MAJOR.MINOR versioning (not MAJOR.MINOR.PATCH), as specified by the [`x_mitre_version` ADM schema](https://github.com/mitre-attack/attack-data-model/blob/f249442b3588de9cca84b819d480306b106d2c1f/src/schemas/common/property-schemas/attack-versioning.ts#L21:L26) +- Snapshots are tagged in-place (no duplicate data) +- When a snapshot is tagged/released, an event is captured in its `version_history` array +- Once a snapshot is tagged, it cannot be re-tagged. Tagged snapshots are **immutable**. + +### 3. Three-Tier Workflow Integration with Version Pinning + +We use the preexisting object workflow statuses, `work-in-progress`, `awaiting-review`, and `reviewed`, to control each object's "standing" in a release track. + +There are three types of membership "standings": + 1. **Candidate**: When an object is first added to a release track, is it considered a candidate. It does not have full membership yet; if the snapshot were to be tagged and released right now, candidates would not be included. + 2. **Staged**: Once a candidate's workflow status meets the release track's ["candidacy threshold"](./release-workflow.md#candidacy-threshold-configuration) criteria, it will automatically become staged. Once the snapshot is tagged/released, staged objects will be included in the resultant bundle's `x_mitre_contents`. + 3. **Member**: Objects are considered "members" if they are "cooked" into the `x_mitre_contents` array of the current snapshot. These are considered already released. + +This presents a tenable solution to the classic "STIX freeze" dilemma wherein editors cannot begin working on the next-*next* (e.g., v20) release until all objects in the next (e.g., v19) release have been released. Staged objects are locked in for the imminent release, but editors are free to continue iterating on future object changes and can queue them up as candidates without affecting the permutation that has already been staged for the imminent release. + +Candidates and staged objects alike can be be statically pinned to specific versions via `stix.id` and `stix.modified` couplings, or maintain dynamic/moving references to object versions by omitting `stix.modified`. In the latter, scenario, the release track will effectively "follow" the latest permutation of the relevant object until the moment a release snapshot is generated, at which point the latest permutation will become "locked in" to `x_mitre_contents` via the `stix.id` and `stix.modified` keys of the latest permutation of the object that existed at the time of the release. + +## Key Features + +### Automatic Promotion + +Object versions automatically move between tiers based on release track-scoped workflow status: + +``` +Object version added to release track + → track-scoped status = "work-in-progress" + → Added to workspace.candidates with version pin + +Object status changed in release track + → track-scoped status = "reviewed" + → Auto-promoted to workspace.staged (version pin preserved) + +Snapshot tagged + → workspace.staged entries → stix.x_mitre_contents (version pins preserved) +``` + +### Configurable Thresholds + +Each release track can set its own candidacy threshold: + +```javascript +workspace.config.candidacy_threshold = "reviewed" // Default +workspace.config.candidacy_threshold = "awaiting-review" // Permissive +workspace.config.candidacy_threshold = "work-in-progress" // Very permissive +``` + +### Multiple Output Formats + +- **bundle** - Standard STIX 2.1 bundle (for publication) +- **filesystemstore** - STIX FileSystemStore directory structure +- **workbench** - Custom format with workflow metadata (for UI) + +### Dry Run + Preview + +"Preview" will provide a verbose/detailed diff of what will change in the next release +``` +GET /api/release-tracks/:id/bump/preview + ?format = bundle | filesystemstore | workbench +``` + +"Dry-run" will output the literal/exact contents of the would-be tagged release +``` +POST /api/release-tracks/:id/bump +{ + "type": "major", + "dry_run": true <-- IMPORTANT!! +} +``` + +Shows exactly what will be in the next release before bumping. + +### Bulk Operations + +``` +POST /api/release-tracks/:id/candidates/review +{ + "from": "awaiting-review", + "to": "reviewed" +} +``` + +Transition all candidates matching status in one operation. + +## State Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ RELEASE TRACK LIFECYCLE │ +└─────────────────────────────────────────────────────────┘ + +Release Track Created +(x_mitre_version: null) + │ + ↓ +Add/Update Objects ────────────────┐ + │ │ + ↓ │ +New Snapshot Created │ +(stix.modified updated) │ +(x_mitre_version: null) │ +(DRAFT RELEASE) │ + │ │ + ↓ │ +Ready for Tagging? │ + │ │ + ├─ NO ──────────────────────────┘ + │ (continue development) + │ + ├─ YES + ↓ +Tag Snapshot +(x_mitre_version: "1.0") +(snapshot tagged IN-PLACE) +(TAGGED RELEASE) + │ + ↓ +Tagged Release +(x_mitre_version: "1.0") + │ + ↓ +Continue Development ──────────────┐ + │ │ + ↓ │ +New Snapshot │ +(x_mitre_version: null) │ +(DRAFT RELEASE) │ + │ │ + ↓ │ +Tag Again │ +(x_mitre_version: "1.1") │ +(TAGGED RELEASE) │ + │ │ + └──────────────────────────────┘ +``` \ No newline at end of file diff --git a/docs/user/release-tracks/terminology.md b/docs/user/release-tracks/terminology.md new file mode 100644 index 00000000..14234894 --- /dev/null +++ b/docs/user/release-tracks/terminology.md @@ -0,0 +1,462 @@ +# Collections V2 - Terminology Guide + +## Overview + +This document defines the new terminology for Collections V2, replacing the overloaded term "collection" with a clearer vocabulary that distinguishes Workbench-specific concepts from STIX bundles, TAXII collections, MongoDB collections, and `x-mitre-collection` SDOs. + +## Problem Statement + +The term "collection" is heavily overloaded in the ATT&CK Workbench context: + +- **MongoDB Collection** - Database table/collection in MongoDB +- **TAXII Collection** - TAXII 2.1 specification concept for grouping STIX content +- **STIX Bundle** - A STIX 2.1 bundle (JSON object with type "bundle") +- **Collection Bundle** - A STIX bundle that contains an `x-mitre-collection` object +- **`x-mitre-collection` SDO** - A custom STIX Domain Object that acts as a table of contents +- **Collection Index** - Workbench-specific concept for subscribing to remote STIX bundles +- **Workbench Collection** (V1) - The existing Workbench concept being replaced + +This overloading creates confusion in documentation, code, and conversation. Collections V2 introduces new terminology to eliminate this ambiguity. + +--- + +## Core Terminology + +### Release Track + +A **release track** (RT) is a series/chain (linked list) of **snapshots**, where each snapshot is either a draft release or a tagged release. + +**Technical Definition:** +- A release track is represented by all documents in a RT-designated Mongo collection sharing the same `id` + - There exists exactly one Mongo collection for each release track. +- Each document in the series represents a snapshot at a specific point in time +- The release track provides version control and release management for curated sets of STIX objects + +**Characteristics:** +- Has a unique identifier (e.g., `release-track--uuid`) (see [naming conventions](../../developer/release-tracks/entities.md#naming-conventions) for details) +- Contains a chronological history of all changes +- Supports Git-inspired versioning workflow + +#### Types of Release Tracks + +There are two types of release tracks: + +1. **Standard Release Track** - Directly manages objects through workflow states (candidates → staged → released) + - Source of truth for specific objects + - Creates snapshots when objects are added/removed or metadata changes + - Examples: "GroupsMonthly", "TechniquesQuarterly" + +2. **Virtual Release Track** - Computes content by aggregating other release tracks + - No direct object management (objects managed in source tracks) + - Creates snapshots **manually** or on **schedule** + - **Event-driven snapshots are not supported** to avoid from RT snapshot explosion. Consider for example a virtual RT that composes from 10 standard RTs, each of which releases on a daily basis: an enormous amount of virtual snapshots would quickly accrue, making it difficult to ascertain the causal relationships to the originating snapshots and which should actually be tagged/released. + - Instead, users are encouraged to use virtual release tracks carefully and intentionally, creating aggregate releases on a more controlled, infrequent cadence than their non-virtual counterparts. + - Examples: "EnterpriseTwiceAnnual" (aggregates Groups + Techniques + Software) + +**Examples:** +- "Enterprise release track" (could be standard or virtual) +- "Mobile release track" (could be standard or virtual) +- "ICS release track" (could be standard or virtual) +- "Groups Monthly" (typically standard) +- "Techniques Quarterly" (typically standard) + +--- + +### Snapshot + +A **snapshot** is a *node* in the release track's version history, identified by its `modified` timestamp. + +**Technical Definition:** +- Each snapshot is a MongoDB document with a specific `id` and `modified` timestamp +- **Snapshots are immutable** once created (**except** for tagging operations) +- The snapshot identifier is the combination of `id` + `modified` + +**Characteristics:** +- Unique `modified` timestamp (ISO 8601 format) +- May be a **draft** release or a **tagged** release +- Contains the full state of the release track at that point in time +- Analogous to a Git commit + +**Types of Snapshots:** +1. **Draft Release** - Untagged snapshot, still in development +2. **Tagged Release** - Snapshot marked with a version number, considered published + +**Examples:** +- "Snapshot from 2024-01-15T16:20:00Z" +- "The latest snapshot in the Enterprise release track" +- "Show me all snapshots created in January" + +--- + +### Draft Release + +A **draft release** (or **draft snapshot**) is an untagged snapshot - still in development, not yet published. + +**Technical Definition:** +- A snapshot where `version === null` +- Represents work-in-progress that has not been marked as production-ready +- Can be freely modified (creates new snapshots) without affecting published releases + +**Characteristics:** +- No version number assigned +- Not considered production-ready +- Can transition from draft to tagged state via tagging (in-place) operation +- May contain candidate, staged, and member objects in various states + +**Examples:** +- "The current draft release has 150 candidate objects" +- "Let's review the draft before tagging it" +- "This draft release includes updates to 50 techniques" + +--- + +### Tagged Release + +A **tagged release** (or **tagged snapshot**) is a snapshot that has been marked with a version number (`version`) and is considered published/released. + +**Technical Definition:** +- A snapshot where `version !== null` +- The version follows MAJOR.MINOR format (e.g., "1.0", "2.3", "15.1") +- Created by performing a tagging operation on a draft release +- The `stix.modified` timestamp does not change during tagging (in-place operation) + +**Characteristics:** +- Has an explicit version number +- Considered production-ready and published +- **Immutable** - cannot be re-tagged or untagged +- Recorded in `version_history` for audit trail +- Analogous to a Git tag + +**Examples:** +- "Enterprise release track v14.1 is a tagged release" +- "Tag this snapshot as release v2.0" +- "Show me all tagged releases from 2024" +- "The latest tagged release contains 3,000 techniques" + +--- + +### Tagging Operation + +The **tagging operation** marks an existing snapshot as a tagged release by assigning it a version number. + +**Technical Definition:** +- Sets `version` on an existing snapshot (in-place update) +- Does NOT create a new snapshot (does NOT change `modified`) +- Adds an entry to `version_history` for audit trail +- Can be performed on the latest snapshot or a specific historical snapshot + +**Characteristics:** +- Version must be greater than all previous tagged releases (monotonically increasing) +- Cannot tag a snapshot that is already tagged (throws `AlreadyReleasedError`) +- Supports automatic version calculation (MAJOR/MINOR bump) or explicit version + +**Examples:** +- "Tag the latest snapshot as v1.5" +- "Tag snapshot from 2024-01-15 as v2.0" +- "Tagging operation failed - snapshot already tagged as v1.3" + +--- + +### Object Membership Tiers + +The tier system differs between **standard release tracks** and **virtual release tracks**. + +#### Standard Release Tracks: Three-Tier System + +Standard release tracks use three tiers to manage the object lifecycle from development to release: + +##### 1. Candidate Objects + +**Location:** `candidates` + +**Definition:** Objects being worked on; not yet ready for release. + +**Characteristics:** +- When an object is first added to a release track, is it considered a candidate. It does not have full membership yet; if the snapshot were to be tagged and released right now, candidates would not be included. +- Each entry can either be statically pinned to a specific version (via its `object_modified` timestamp), or dynamically pinned to the latest version. +- Each entry has a collection-scoped status: `work-in-progress`, `awaiting-review`, or `reviewed` +- Objects in this tier are NOT included in published STIX bundles by default +- Automatically promoted to staged tier when status reaches the candidacy threshold + +**Duplicate Rules:** +- Cannot contain exact duplicates (same `object_ref` + `object_modified` pair) +- **CAN** contain multiple versions of the same object (same `object_ref`, different `object_modified` timestamps) + - Example: Can have `attack-pattern--T1234, modified: 2024-01-15` AND `attack-pattern--T1234, modified: 2024-02-20` simultaneously + - However, only one version of a given object can be promoted to the `staged` tier and `members` tier + +**Examples:** +- "Add these 10 techniques as candidate objects" +- "There are 47 candidate objects in work-in-progress status" +- "Transition candidate objects from awaiting-review to reviewed" + +##### 2. Staged Objects + +**Location:** `staged` + +**Definition:** Objects that have been reviewed (in this release track) and are ready for the next tagged release. + +**Characteristics:** +- Once a candidate's workflow status meets the release track's ["candidacy threshold"](./release-workflow.md#candidacy-threshold-configuration) criteria, it will automatically become staged. Once the snapshot is tagged/released, staged objects will be included in `members`. + +When the release is exported as a `bundle`, all `members` will be included in the resultant bundle's `x_mitre_contents` array. + +- Each `staged` entry includes a version pin (`object_modified` timestamp), which can either equal an ISO 8601 timestamp (designating a specific object version) or `"latest"` (designating a dynamic reference to the latest permutation of the relevant object) +- Auto-promoted from candidates when objects meet the [candidacy threshold](./release-workflow.md#candidacy-threshold-configuration) +- Moved to member objects tier (`members`) when the snapshot is tagged +- NOT included in published STIX bundles until the snapshot is tagged + +**Duplicate Rules:** +- Cannot contain exact duplicates (same `object_ref` + `object_modified` pair) +- **CANNOT** contain multiple versions of the same object +- If a promotion would create a duplicate (different version of same object already in staged), conflict resolution policy applies + +**Examples:** +- "There are 12 staged objects ready for the next release" +- "Promote all reviewed candidates to staged" +- "Preview which staged objects will be included in the next tagged release" + +##### 3. Member Objects + +**Location:** `members` + +**Definition:** Objects included in the current/latest released version of this release track. + +**Characteristics:** +- Objects are considered "members" if they are contained in the `x_mitre_contents` array of the current snapshot. These are considered *already* released. +- Each entry is a version-pinned reference (`object_ref` + `object_modified`). Dynamic references (`object_modified: "latest"`) are not supported on member objects. +- These objects are included in published STIX bundles +- Represents the production-ready, published content +- Only updated when a snapshot is tagged (staged objects are promoted to members) + +**Duplicate Rules:** +- Cannot contain exact duplicates (same `object_ref` + `object_modified` pair) +- **CANNOT** contain multiple versions of the same object +- If a promotion would create a duplicate (different version of same object already in members), conflict resolution policy applies + +**Examples:** +- "The Enterprise release track has 3,247 member objects" +- "Export all member objects as a STIX bundle" +- "Which version of Technique T1234 is in the member objects?" + +#### Virtual Release Tracks: Two-Tier System + +Virtual release tracks use a simplified two-tier system since they aggregate already-released content: + +##### 1. Member Objects + +**Location:** `members` + +**Definition:** Successfully synced objects from component tracks. + +**Characteristics:** +- Contains objects synced from component tracks' `members` tiers +- Objects that were automatically resolved using the deduplication strategy +- OR objects manually promoted from quarantine +- These objects are included in published STIX bundles +- No workflow states (no work-in-progress, awaiting-review, reviewed) + +**Duplicate Rules:** +- Cannot contain exact duplicates (same `object_ref` + `object_modified` pair) +- **CANNOT** contain multiple versions of the same object +- Deduplication strategy determines which version to keep when conflicts occur + +##### 2. Quarantine + +**Location:** `quarantine` + +**Definition:** Conflicting objects that require manual resolution. + +**Characteristics:** +- Only populated when using `quarantine` deduplication strategy +- Contains objects that couldn't be automatically resolved due to conflicts +- NOT included in published STIX bundles +- Requires manual intervention to promote one version to members + +**Duplicate Rules:** +- Cannot contain exact duplicates (same `object_ref` + `object_modified` pair) +- **CAN** contain multiple versions of the same object (different versions from different component tracks) +- Example: Can have `attack-pattern--T1234, modified: 2024-01-15` (from Track A) AND `attack-pattern--T1234, modified: 2024-02-20` (from Track B) simultaneously + +--- + +### Virtual Release Track + +A **virtual release track** is a special type of release track that computes its contents by aggregating objects from other release tracks (called **component tracks**). + +**Technical Definition:** +- A virtual track is identified by `stix.type = "virtual"` in its schema +- Instead of managing objects directly, it defines **composition rules** that specify which tracks to aggregate and how +- Snapshots are created manually or on schedule by **resolving** the composition rules + +**Characteristics:** +- Does NOT manage objects through candidate/staged/released workflow +- Aggregates content from **component tracks** (standard or other virtual tracks) +- Only references **tagged snapshots** from component tracks (never drafts) +- Creates snapshots **manually** or **on schedule** (*never* event-driven; see [Types of Release Tracks](#types-of-release-tracks) for explanation) +- All snapshots start as drafts and must be explicitly tagged +- Can optionally have **native objects** in addition to composed content (hybrid model) + +**Examples:** +- "EnterpriseTwiceAnnual" virtual track aggregates: + - GroupsMonthly (latest tagged release) + - TechniquesQuarterly (latest tagged release) + - SoftwareBiannual (latest tagged release) +- "MobileQuarterly" virtual track aggregates: + - GroupsMobile (filtered to mobile domain) + - TechniquesMobile (filtered to mobile domain) + +--- + +### Component Track + +A **component track** is a release track (standard or virtual) that is referenced by a virtual release track. + +**Technical Definition:** +- A component track is specified in a virtual track's `composition.component_tracks` array +- Each component defines a `resolution_strategy` (how to select which snapshot to use) +- Each component can optionally specify `filters` (which objects to include) + +**Characteristics:** +- Component tracks are independent - they don't know they're being referenced +- Virtual tracks "pull" content from components via composition rules +- Components can be standard tracks (manage objects) or virtual tracks (aggregate) +- Components must have at least one tagged snapshot for virtual track to resolve + +**Examples:** +- "GroupsMonthly" is a component track of "EnterpriseTwiceAnnual" +- "TechniquesQuarterly" is a component track of "EnterpriseTwiceAnnual" + +--- + +### Composition + +**Composition** refers to the rules and configuration that define how a virtual release track aggregates content from component tracks. + +**Technical Definition:** +- Defined in `composition` object of virtual track +- Specifies which component tracks to include +- Defines resolution strategies, filters, and deduplication rules + +**Characteristics:** +- Composition is configuration, not data +- Resolved at snapshot creation time into concrete object references +- Can be updated, which creates a new draft snapshot with new composition + +**Example:** +```javascript +{ + component_tracks: [ + { + track_id: "GroupsMonthly--uuid", + resolution_strategy: "latest_tagged", + filters: { object_types: ["intrusion-set"] } + } + ], + deduplication: { + strategy: "prefer_latest_modified" + } +} +``` + +--- + +### Resolution + +**Resolution** is the process of converting a virtual track's composition rules into concrete object references. + +**Technical Definition:** +- Occurs when a virtual track snapshot is created +- For each component track, resolves to a specific snapshot based on strategy +- Collects all objects from resolved snapshots +- Applies filters and deduplication +- Produces immutable `composition_resolution` metadata + +**Characteristics:** +- Resolution happens at snapshot creation time (not query time) +- Resolution metadata is stored in snapshot (`composition_resolution`) +- Once resolved, a snapshot's composition is immutable +- Different snapshots of same virtual track may resolve to different component versions + +**Example:** + +``` +Virtual track "EnterpriseTwiceAnnual" has composition: + - GroupsMonthly: strategy = "latest_tagged" + +When snapshot created on March 1: + → Resolves to GroupsMonthly v5.2 (latest tagged on March 1) + +When snapshot created on July 1: + → Resolves to GroupsMonthly v5.8 (latest tagged on July 1) +``` + +--- + +### Resolution Strategy + +A **resolution strategy** determines which snapshot from a component track to use. + +**Options:** +1. **latest_tagged** - Use the most recent tagged snapshot from the component track +2. **specific_version** - Use a specific semantic version (e.g., "5.0") +3. **specific_snapshot** - Use a specific snapshot by timestamp + +**Examples:** +- `{ resolution_strategy: "latest_tagged" }` → Always gets latest +- `{ resolution_strategy: "specific_version", version: "5.0" }` → Always uses v5.0 +- `{ resolution_strategy: "specific_snapshot", snapshot: "2024-02-01T10:00:00Z" }` → Always uses that exact snapshot + +--- + +## Terminology Mapping + +### Old Term (V1) → New Term (V2) + +| Old Term (V1) | New Term (V2) | Notes | +|---------------|---------------|-------| +| Collection | Release Track | Top-level container concept | +| Collection version | Snapshot | Individual node in version history | +| Collection (unpublished) | Draft Release | Snapshot without version number | +| Collection (published) | Tagged Release | Snapshot with version number | +| Collection contents | Member objects | Objects in `members` | +| Collection Bundle | -- | STIX bundle exported from a released/tagged snapshot | + +### Technical Mappings + +| Concept | MongoDB Representation | +|---------|------------------------| +| Release Track | Mongo collection following name format `$name--$uuid` | +| Snapshot | Doc with specific `id` + `modified` | +| Draft Release | Snapshot where `version === null` | +| Tagged Release | Snapshot where `version !== null` | +| Tagging Operation | Set `version` on existing doc | +| Candidate Objects | Array at `candidates` | +| Staged Objects | Array at `staged` | +| Member Objects | Array at `members` | + +--- + +## Conversational Usage + +**Development Workflow:** +- "Did you review Technique-A in the latest draft release?" +- "Add these techniques as candidates to the Enterprise release track" +- "Transition all candidate objects from work-in-progress to awaiting-review" +- "Which objects are staged for the next release?" + +**Release Management:** +- "Are we ready to tag this snapshot as a release?" +- "Tag the current draft as v14.1" +- "The Enterprise release track has 47 snapshots, 12 of which are tagged releases" +- "Show me all tagged releases from Q1 2024" + +**Version Control:** +- "Let's create a new release track for the space domain" +- "Compare snapshots from the Mobile and Enterprise release tracks" +- "Which snapshot introduced Technique T1234?" +- "Roll back to the previous tagged release" + +**Object Management:** +- "The current snapshot contains 3,000 member objects and 150 staged objects" +- "Move these candidate objects to staged" +- "Export the member objects as a STIX bundle" \ No newline at end of file diff --git a/docs/user/release-tracks/versioning.md b/docs/user/release-tracks/versioning.md new file mode 100644 index 00000000..2c13c10b --- /dev/null +++ b/docs/user/release-tracks/versioning.md @@ -0,0 +1,180 @@ +# Release Track Versioning and Release Process + +## Overview + +The Release Tracks API uses a Git-inspired versioning strategy that separates two distinct concerns: + +1. **Snapshot History** - Every modification creates a new timestamped snapshot for complete audit trail +2. **Release Versioning** - Specific snapshots can be "tagged" as releases using semantic versioning + +This approach allows continuous development while providing stable, versioned releases for publication. + +**Note on Terminology:** We use **release track** instead of "collection" to avoid confusion with TAXII collections, MongoDB collections, STIX bundles, and `x-mitre-collection` SDOs. See [terminology.md](terminology.md) for the complete terminology guide. + +## Core Concepts + +### Snapshots + +A **snapshot** is an immutable state of a release track at a specific point in time, identified by: +- `id` - The release track's STIX identifier (constant across all snapshots) +- `modified` - ISO 8601 timestamp when the snapshot was created (unique per snapshot) + +Every modification operation creates a new snapshot with a new `modified` timestamp. + +A snapshot may be either a **draft release** (untagged) or a **tagged release** (has version number). + +### Draft Releases vs Tagged Releases + +A **draft release** is a snapshot without a version number (`version === null`). It represents work-in-progress. + +A **tagged release** is a snapshot that has been marked as production-ready for publication, identified by: +- `version` - Version string in MAJOR.MINOR format (e.g., "1.0") + +**Note:** ATT&CK release tracks use a two-part versioning scheme (MAJOR.MINOR), not the three-part semver format (MAJOR.MINOR.PATCH). The patch component is not tracked in `version`. + +Not all snapshots are tagged releases. Only snapshots explicitly tagged via the **bump** operation become tagged releases. + +**Example Timeline with Tagged Releases:** +``` +id: "release-track--123", modified: "2024-01-01T10:00:00.000Z" + version: null ← DRAFT RELEASE (work in progress) + +id: "release-track--123", modified: "2024-01-02T14:30:00.000Z" + version: null ← DRAFT RELEASE (work in progress) + +id: "release-track--123", modified: "2024-01-05T09:15:00.000Z" + version: "1.0" ← TAGGED RELEASE (via tagging operation) + version_history: [{ + version: "1.0", + tagged_at: "2024-01-05T10:00:00Z", + tagged_by: "user@example.com", + modified: "2024-01-05T09:15:00.000Z" + }] + +id: "release-track--123", modified: "2024-01-10T11:00:00.000Z" + version: null ← DRAFT RELEASE (more development) + +id: "release-track--123", modified: "2024-01-15T16:20:00.000Z" + version: "1.1" ← TAGGED RELEASE (via tagging operation) + version_history: [ + { version: "1.1", tagged_at: "2024-01-15T17:00:00Z", tagged_by: "user@example.com", modified: "2024-01-15T16:20:00.000Z" }, + { version: "1.0", tagged_at: "2024-01-05T10:00:00Z", tagged_by: "user@example.com", modified: "2024-01-05T09:15:00.000Z" } + ] +``` + +## The Tagging Operation + +### What is "Tagging"? + +The `tag` operation **tags an existing snapshot as a release** by assigning it a semantic version number (without the patch number). It does **NOT** create a new snapshot. + +This is analogous to Git's tagging system: +- Git commits = release track snapshots (identified by `modified` key) +- Git tags = tagged releases (identified by `version` key) + +### In-Place Tagging Strategy + +When you tag a snapshot: + +1. The **existing** snapshot is updated in-place +2. `version` is set to the new version +3. An entry is added to `version_history` for audit trail +4. The `modified` timestamp **does not change** + +**Why in-place?** +- Avoids duplicate data (no need to copy the entire release track) +- Clear semantics: tagging is metadata, not a content change +- Snapshots remain immutable except for the version tag +- Matches Git's model where tags point to existing commits + +### Tagging Endpoints + +#### Tag Latest Snapshot +``` +POST /api/release-tracks/:id/bump +``` + +Tags the most recent snapshot (highest `modified`) as a tagged release. + +**Request Body (optional):** +```json +{ + "type": "major" | "minor", // Default: "minor" + "version": "2.0" // Alternative: explicit version (MAJOR.MINOR format) +} +``` + +**Examples:** + +1. **Automatic version calculation:** +```bash +# Current latest tagged release: 1.2 +# Tag as: 1.3 (minor increment) +POST /api/release-tracks/release--123/bump +{ + "type": "minor" +} +``` + +1. **Major version increment:** +```bash +# Current latest tagged release: 1.2 +# Tag as: 2.0 (major increment) +POST /api/release-tracks/release--123/bump +{ + "type": "major" +} +``` + +1. **Explicit version:** +```bash +# Set specific version (must be greater than previous) +POST /api/release-tracks/release--123/bump +{ + "version": "2.0" +} +``` + +1. **Default behavior (no body):** +```bash +# Defaults to minor increment +POST /api/release-tracks/release--123/bump +``` + +#### Tag Specific Snapshot +``` +POST /api/release-tracks/:id/snapshots/:modified/bump +``` + +Tags a specific snapshot as a tagged release. Can tag retroactively, (i.e., a non-latest snapshot can be tagged), granted no [versioning rules](#versioning-rules) are violated. + +**Use Cases:** +- You want to tag snapshot 3, then later also tag snapshot 5 +- You forgot to tag a snapshot and want to mark it retroactively +- You want to create multiple tagged releases from different development branches + +**Constraint:** The version must be greater than any previously tagged version (no semver regression). + +## Versioning Rules + +### Version Format + +Collections use a **two-part versioning scheme** (MAJOR.MINOR), inspired by semantic versioning but simplified for ATT&CK's release model: + +- **MAJOR** (`X.0`) - Significant releases with substantial changes, may include breaking changes +- **MINOR** (`X.Y`) - Incremental releases with additions, updates, or fixes + +**Note:** Unlike full semantic versioning (MAJOR.MINOR.PATCH), ATT&CK collections do not track patch versions. All changes, including bug fixes, increment the minor version or major version depending on significance. + +### Version Constraints + +1. **Monotonically increasing** - New versions must always be greater than previous versions +2. **Immutable once set** - Once a snapshot has `version` assigned, it cannot be changed +3. **Cannot re-tag** - A snapshot can only be tagged once (throws `AlreadyReleasedError` if attempted) +4. **Valid version format** - Must match `/^\d+\.\d+$/` (MAJOR.MINOR only, no patch component) + +### First Tagged Release + +For release tracks with no prior tagged releases: +- The first tag sets `version: "1.0"` (regardless of increment type) +- Or you can specify an explicit version like `"0.1"` \ No newline at end of file diff --git a/docs/user/release-tracks/virtual-tracks.md b/docs/user/release-tracks/virtual-tracks.md new file mode 100644 index 00000000..e00bb67f --- /dev/null +++ b/docs/user/release-tracks/virtual-tracks.md @@ -0,0 +1,1233 @@ +# Virtual Release Tracks + +## Overview + +Virtual release tracks are computed aggregations of standard release tracks. They provide a way to compose releases from multiple source tracks without duplicating object tracking, reducing mental overhead and storage requirements. + +**Key Characteristics:** +- Virtual tracks **compute** their contents from component standard tracks +- Only reference **tagged snapshots** from standard tracks (never drafts) +- Maintain their own **independent snapshot history and versioning** +- Create snapshots **manually or on schedule** (never event-driven) +- All snapshots start as **drafts** and must be explicitly tagged + +## Use Cases + +### Scenario 1: Different Cadences for Different Object Types + +``` +Standard Tracks (source of truth): + - GroupsMonthly: intrusion-set objects, releases monthly + - TechniquesQuarterly: attack-pattern objects, releases quarterly + - SoftwareBiannual: malware + tool objects, releases twice yearly + +Virtual Track (aggregation): + - EnterpriseTwiceAnnual: Aggregates all three, releases twice yearly +``` + +**Workflow:** +1. Each standard track releases independently on its own schedule +2. Enterprise virtual track snapshots twice yearly (Jan 1, July 1) +3. Each snapshot captures the **latest tagged release** from each component track +4. Enterprise team reviews snapshot, then tags it as a release + +**Benefit:** Groups can release 12 times/year while Enterprise releases 2 times/year, without tracking Groups in both places. + +### Scenario 2: Modular Content Organization + +``` +Standard Tracks: + - CoreTactics: All tactics + - CoreTechniques: All attack-patterns + - CoreGroups: All intrusion-sets + - CoreSoftware: All malware + tools + - CoreMitigations: All course-of-action objects + +Virtual Tracks: + - EnterpriseATT&CK: Aggregates all five + - MobileATT&CK: Aggregates relevant subsets with mobile domain filter + - ICSATT&CK: Aggregates relevant subsets with ICS domain filter +``` + +**Benefit:** Maintain objects by type in standard tracks, compose domain-specific releases as virtual tracks. + +## Virtual Track Types + +### Type: `virtual` + +Virtual tracks are identified by `stix.type = "virtual"` in their schema. + +```javascript +{ + // Identity + id: "release-track--uuid-virtual", + type: "virtual", // Distinguishes from standard tracks + + // Snapshot metadata + snapshot_id: "2024-03-01T10:00:00.000Z", + modified: "2024-03-01T10:00:00Z", + version: null, // Draft snapshot (or "14.0" if tagged) + + // Release track metadata + name: "Enterprise ATT&CK", + description: "Virtual aggregation of Enterprise content", + created: "2024-01-01T10:00:00.000Z", + created_by_ref: "identity--uuid", + object_marking_refs: ["marking-definition--uuid"], + + // Objects in this snapshot (Virtual tracks use a 2-tier system) + members: [], // Successfully synced objects from component tracks + quarantine: [], // Conflicting objects that require manual resolution + + // Composition rules (how to build this virtual track) + composition: { + component_tracks: [ + { + track_id: "release-track--uuid-1", + resolution_strategy: "latest_tagged", + priority: 1, // Used with prioritize_higher_priority strategy (lower number = higher priority) + filters: { + object_types: ["intrusion-set"], + // Additional filters... + } + }, + { + track_id: "release-track--uuid-2", + resolution_strategy: "latest_tagged", + priority: 2, + filters: { + object_types: ["attack-pattern"] + } + } + ], + + deduplication: { + strategy: "prioritize_latest_object" // See Deduplication Strategies below + } + }, + + // Snapshot schedule configuration + snapshot_schedule: { + mode: "manual", // "manual" | "cron" | "dates" + cron: "0 0 1 1,7 *", // Jan 1 and July 1 at midnight + dates: ["2024-01-01T00:00:00Z", "2024-07-01T00:00:00Z"] + }, + + // Configuration + config: { + candidacy_threshold: "reviewed", + auto_promote: true + }, + + version_history: [] +} +``` + +## Composition Resolution + +### Resolution Strategies + +#### 1. `latest_tagged` + +Always resolves to the most recent **tagged snapshot** from the component track. + +```javascript +{ + track_id: "release-track--uuid-1", + resolution_strategy: "latest_tagged" +} + +// At virtual snapshot time (e.g., March 1, 2024): +// 1. Query GroupsMonthly for all snapshots where version !== null +// 2. Sort by modified DESC +// 3. Take first result +// → Resolves to GroupsMonthly v5.2 (released Feb 15, 2024) +``` + +**Use case:** "Always include the latest Groups release in Enterprise" + +#### 2. `specific_version` + +Resolves to a specific semantic version from the component track. + +```javascript +{ + track_id: "release-track--uuid-1", + resolution_strategy: "specific_version", + version: "5.0" +} + +// At virtual snapshot time: +// 1. Query GroupsMonthly for snapshot where version === "5.0" +// → Resolves to GroupsMonthly v5.0 (regardless of when snapshot occurs) +``` + +**Use case:** "Pin Enterprise to Groups v5.0 until we're ready to upgrade" + +#### 3. `specific_snapshot` + +Resolves to a specific snapshot by its `modified` timestamp. + +```javascript +{ + track_id: "release-track--uuid-1", + resolution_strategy: "specific_snapshot", + snapshot: "2024-02-01T10:00:00Z" +} + +// At virtual snapshot time: +// 1. Query GroupsMonthly for snapshot where modified === "2024-02-01T10:00:00Z" +// → Resolves to that specific snapshot +``` + +**Use case:** "Lock to exact snapshot for reproducibility" + +### Component Track Sync Rules + +Virtual tracks **only sync from component tracks' `members` tier** (`x_mitre_contents`). This ensures that virtual tracks only aggregate objects that have been officially released in their source tracks. + +**Important:** +- Virtual tracks reference **tagged snapshots only** (never drafts) +- Virtual tracks pull objects from **`members` tier only** (never staged or candidates) +- This guarantees that virtual track releases are composed of stable, released content + +**Rationale:** Since virtual tracks can only reference tagged snapshots from component tracks, it makes sense to only pull from the `members` tier, which contains the released objects from those snapshots. + +### Filters + +Each component track can specify filters to limit which objects are included: + +```javascript +filters: { + // Only include specific object types + object_types: ["intrusion-set", "malware"], + + // Only include objects with specific domains (if applicable) + domains: ["enterprise", "mobile"], + + // Only include objects matching STIX filter pattern (advanced) + stix_pattern: { + "x_mitre_platforms": { "$in": ["Windows", "macOS"] } + } +} +``` + +### Deduplication Strategies + +When multiple component tracks contain the same object (same `stix.id`), a conflict occurs during the sync operation. The virtual track's deduplication strategy determines how to resolve the conflict. Four strategies are available: + +#### 1. `prioritize_latest_object` + +Keep the version with the newest `modified` timestamp, regardless of which component track it came from. + +```javascript +deduplication: { + strategy: "prioritize_latest_object" +} +``` + +**Example:** +```javascript +// GroupsMonthly v5.2 has: +// intrusion-set--APT1, modified: 2024-02-01T10:00:00Z + +// MobileGroups v3.1 has: +// intrusion-set--APT1, modified: 2024-01-15T14:00:00Z + +// Virtual track sync result: +// → Uses 2024-02-01 version from GroupsMonthly (newer object) +// → Added to virtual track's members +``` + +**Use case:** "Always use the most recently updated object, regardless of source" + +#### 2. `prioritize_latest_snapshot` + +Keep the version from the component track whose resolved snapshot has the newest `modified` timestamp. This can result in syncing **older** versions of objects if they came from a more recently released snapshot. + +```javascript +deduplication: { + strategy: "prioritize_latest_snapshot" +} +``` + +**Example:** +```javascript +// GroupsMonthly v5.2 +// - Snapshot created: 2024-02-15T10:00:00Z +// - intrusion-set--APT1, modified: 2024-02-01T10:00:00Z + +// MobileGroups v3.1 +// - Snapshot created: 2024-01-10T10:00:00Z +// - intrusion-set--APT1, modified: 2024-02-05T10:00:00Z + +// Virtual track sync result: +// → Uses 2024-02-01 version from GroupsMonthly +// → GroupsMonthly snapshot is newer (2024-02-15), even though APT1 object is older +// → Added to virtual track's members +``` + +**Use case:** "Trust the more recently released track, even if individual objects are older" + +#### 3. `prioritize_higher_priority` + +Keep the version from the component track with the higher priority (lower priority number). Each component track must have a unique priority value. + +```javascript +composition: { + component_tracks: [ + { + track_id: "release-track--authoritative", + resolution_strategy: "latest_tagged", + priority: 1, // Higher priority (lower number = higher priority) + filters: { object_types: ["intrusion-set"] } + }, + { + track_id: "release-track--supplemental", + resolution_strategy: "latest_tagged", + priority: 2, // Lower priority + filters: { object_types: ["intrusion-set"] } + } + ], + deduplication: { + strategy: "prioritize_higher_priority" + } +} +``` + +**Example:** +```javascript +// Authoritative track (priority: 1) has: +// intrusion-set--APT1, modified: 2024-01-01T10:00:00Z + +// Supplemental track (priority: 2) has: +// intrusion-set--APT1, modified: 2024-02-15T10:00:00Z + +// Virtual track sync result: +// → Uses 2024-01-01 version from Authoritative track +// → Priority 1 wins, even though object is older +// → Added to virtual track's members +``` + +**Use case:** "One track is authoritative; always prefer its version over others" + +**Important:** Component tracks cannot have duplicate priority values. The API will reject composition configurations with conflicting priorities. + +#### 4. `quarantine` + +Don't automatically choose a version. Instead, store **both** versions in the virtual track's `quarantine` tier for manual review and resolution. + +```javascript +deduplication: { + strategy: "quarantine" +} +``` + +**Example:** +```javascript +// GroupsMonthly has: intrusion-set--APT1, modified: 2024-02-01 +// MobileGroups has: intrusion-set--APT1, modified: 2024-01-15 + +// Virtual track sync result: +{ + members: [ + // APT1 is NOT included here + // ... other non-conflicting objects + ], + quarantine: [ + { + object_ref: "intrusion-set--APT1", + object_modified: "2024-02-01T10:00:00Z", + source_track_id: "release-track--groups-monthly", + source_track_name: "Groups Monthly", + source_snapshot_version: "5.2", + conflict_reason: "duplicate_object" + }, + { + object_ref: "intrusion-set--APT1", + object_modified: "2024-01-15T14:00:00Z", + source_track_id: "release-track--mobile-groups", + source_track_name: "Mobile Groups", + source_snapshot_version: "3.1", + conflict_reason: "duplicate_object" + } + ] +} +``` + +**Use case:** "Conflicts require human review; don't automatically choose a version" + +**Follow-up workflow:** Users review the quarantined objects and manually promote one version to `members` during a future snapshot update. The quarantined objects remain in the virtual track until manual intervention occurs. + +### Virtual Track Two-Tier System + +Unlike standard release tracks (which use a three-tier system: candidates → staged → members), virtual tracks use a simplified **two-tier system**: + +1. **`members`** - Successfully synced objects from component tracks + - Contains objects that were either: + - Synced from component tracks without conflicts, OR + - Manually promoted from quarantine after conflict resolution + - These objects are included in published STIX bundles + - No duplicate objects allowed (unique by `stix.id`) + +2. **`quarantine`** - Conflicting objects requiring manual resolution + - Contains objects that couldn't be automatically resolved due to conflicts + - Only populated when using `quarantine` deduplication strategy + - Can contain multiple versions of the same object (different `modified` timestamps) + - NOT included in published STIX bundles + - Requires manual intervention to resolve + +**Comparison to Standard Tracks:** + +| Feature | Standard Track | Virtual Track | +|---------|---------------|---------------| +| Tiers | candidates, staged, members | quarantine, members | +| Object management | Direct (add/remove objects) | Indirect (synced from components) | +| Workflow states | work-in-progress, awaiting-review, reviewed | N/A | +| Auto-promotion | Based on candidacy threshold | N/A | +| Manual promotion | candidates → staged → members | quarantine → members | + +**Why only two tiers?** + +Virtual tracks aggregate content from component tracks that have already gone through the full workflow (candidates → staged → members). Virtual tracks don't need the intermediate `staged` tier because they're composing already-released content. The only workflow step is resolving conflicts via the `quarantine` tier. + +## Virtual Track Snapshot Lifecycle + +### 1. Snapshot Creation + +Virtual track snapshots are created either **manually** or **on schedule**. + +#### Manual Snapshot + +```bash +POST /api/release-tracks/:id/snapshots/create +``` + +**Request:** +```json +{ + "description": "Q1 2024 Enterprise snapshot" +} +``` + +**Response:** +```json +{ + "id": "release-track--uuid-virtual", + "type": "virtual", + "snapshot_id": "2024-03-01T10:00:00.000Z", + "modified": "2024-03-01T10:00:00Z", + "version": null, + "name": "Enterprise ATT&CK", + "description": "Virtual aggregation of Enterprise content", + + "composition_resolution": { + "resolved_at": "2024-03-01T10:00:00Z", + "component_snapshots": [ + { + "track_id": "release-track--uuid-1", + "track_name": "Groups Monthly", + "track_type": "standard", + "resolved_snapshot_id": "2024-02-15T10:00:00.000Z", + "resolved_version": "5.2", + "strategy_used": "latest_tagged", + "total_objects_in_source": 47, + "objects_after_filter": 47, + "objects_contributed": 47 + }, + { + "track_id": "release-track--uuid-2", + "track_name": "Techniques Quarterly", + "track_type": "standard", + "resolved_snapshot_id": "2024-01-15T10:00:00.000Z", + "resolved_version": "2.1", + "strategy_used": "latest_tagged", + "total_objects_in_source": 823, + "objects_after_filter": 823, + "objects_contributed": 823 + } + ], + "deduplication": { + "total_objects_before": 870, + "total_objects_after": 870, + "duplicates_found": 0, + "conflicts_resolved": [] + }, + "summary": { + "total_objects": 870 + } + } +} +``` + +**Business Logic:** +1. For each component track in `composition.component_tracks`: + - Resolve snapshot based on `resolution_strategy` + - **Validate that resolved snapshot is tagged** (version !== null) + - Pull objects from component track's **`members` tier only** (`x_mitre_contents`) + - Apply `filters` to get subset of objects + - Collect all object references with source metadata +2. Apply deduplication rules across all components: + - If no conflicts: objects go to virtual track's `members` + - If conflicts + `quarantine` strategy: both versions go to `quarantine` + - If conflicts + other strategies: winning version goes to `members` +3. Create new virtual track snapshot with: + - New `snapshot_id` and `modified` timestamp + - `version = null` (always starts as draft) + - `members` array (successfully synced objects) + - `quarantine` array (conflicting objects, if any) + - `composition_resolution` metadata (what was included) +4. Return snapshot with resolution details + +#### Scheduled Snapshot + +Virtual tracks can be configured to auto-generate snapshots on a schedule: + +```javascript +snapshot_schedule: { + mode: "cron", + cron: "0 0 1 1,7 *" // Jan 1 and July 1 at midnight +} +``` + +**Scheduler integration:** +```javascript +scheduler.register({ + type: "virtual-track-snapshot", + trackId: "release-track--uuid-virtual", + schedule: "0 0 1 1,7 *", + handler: async (trackId) => { + await virtualTrackService.createSnapshot(trackId, { + description: `Scheduled snapshot ${new Date().toISOString()}` + }); + + // Optionally notify team + await notificationService.send({ + to: "enterprise-team@example.com", + subject: "Enterprise ATT&CK snapshot created", + body: "A new draft snapshot is ready for review and tagging" + }); + } +}); +``` + +### 2. Snapshot Review + +Before tagging, team reviews the draft snapshot: + +```bash +GET /api/release-tracks/:id/snapshots/:modified?format=workbench&include=all +``` + +**Response includes:** +- All objects that will be in the release +- Composition resolution details (which component versions were used) +- Statistics and diff from previous tagged release + +### 3. Snapshot Tagging + +Once reviewed, explicitly tag the draft snapshot: + +```bash +POST /api/release-tracks/:id/snapshots/:modified/bump +``` + +**Request:** +```json +{ + "type": "major", // or "minor", or explicit "version": "14.0" +} +``` + +**Response:** +```json +{ + "id": "release-track--uuid-virtual", + "type": "virtual", + "snapshot_id": "2024-03-01T10:00:00.000Z", + "modified": "2024-03-01T10:00:00Z", + "version": "14.0", + "name": "Enterprise ATT&CK", + + "composition_resolution": { + "resolved_at": "2024-03-01T10:00:00Z", + "component_snapshots": [...] + }, + + "version_history": [ + { + "version": "14.0", + "tagged_at": "2024-03-05T14:00:00Z", + "tagged_by": "admin@example.com", + "snapshot_id": "2024-03-01T10:00:00.000Z", + "component_versions": { + "Groups Monthly": "5.2", + "Techniques Quarterly": "2.1" + } + } + ] +} +``` + +**Business Logic:** +1. Validate snapshot exists and is a draft (version === null) +2. Calculate/validate version number +3. Set version on snapshot (in-place update) +4. Add entry to version_history +5. Snapshot is now immutable + +### 4. Snapshot Export + +Export virtual track snapshot as STIX bundle: + +```bash +GET /api/release-tracks/:id/snapshots/:modified?format=bundle +``` + +**Response:** +```json +{ + "type": "bundle", + "id": "bundle--uuid", + "objects": [ + { + "type": "x-mitre-collection", + "id": "x-mitre-collection--virtual-uuid", + "modified": "2024-03-01T10:00:00Z", + "x_mitre_version": "14.0", + "name": "Enterprise ATT&CK", + "x_mitre_contents": [ + { "object_ref": "intrusion-set--APT1", "object_modified": "2024-02-01T10:00:00Z" }, + { "object_ref": "intrusion-set--APT2", "object_modified": "2024-01-15T10:00:00Z" }, + { "object_ref": "attack-pattern--T1234", "object_modified": "2024-01-10T10:00:00Z" } + // ... all 870 objects + ] + }, + // ... all 870 actual STIX objects + ] +} +``` + +**Note:** The exported bundle is **materialized** - it contains concrete object references, not composition metadata. Consumers see a standard STIX bundle, unaware it came from a virtual track. + +## Composition Resolution Details + +### Resolution Metadata + +Each virtual track snapshot stores metadata about how it was composed: + +```javascript +{ + // Identity and snapshot metadata + id: "release-track--uuid-virtual", + type: "virtual", + snapshot_id: "2024-03-01T10:00:00.000Z", + modified: "2024-03-01T10:00:00Z", + version: "14.0", + + // Composition resolution metadata + composition_resolution: { + resolved_at: "2024-03-01T10:00:00Z", + + component_snapshots: [ + { + track_id: "release-track--uuid-1", + track_name: "Groups Monthly", + track_type: "standard", + + // Which snapshot was used + resolved_snapshot_id: "2024-02-15T10:00:00.000Z", + resolved_version: "5.2", + + // How it was resolved + strategy_used: "latest_tagged", + filters_applied: { + object_types: ["intrusion-set"] + }, + + // Statistics + total_objects_in_source: 47, + objects_after_filter: 47, + objects_contributed: 47 // After deduplication + } + ], + + // Deduplication report + deduplication: { + total_objects_before: 870, + total_objects_after: 870, + duplicates_found: 0, + conflicts_resolved: [] + }, + + // Native objects (if any) + native_objects: { + candidates_count: 0, + staged_count: 0, + members_count: 0 + }, + + // Final statistics + summary: { + total_objects: 870, + by_type: { + "intrusion-set": 47, + "attack-pattern": 823 + }, + by_tier: { + "members": 870, + "staged": 0, + "candidates": 0 + } + } + } +} +``` + +### Validation Rules + +#### 1. Component tracks must have tagged snapshots + +```javascript +// When creating virtual snapshot +for (const component of composition.component_tracks) { + const snapshot = await resolveSnapshot(component); + + if (snapshot.version === null) { + throw new ValidationError( + `Component track ${component.track_id} resolved to draft snapshot. ` + + `Virtual tracks can only reference tagged snapshots.` + ); + } +} +``` + +**User experience:** +```bash +POST /api/release-tracks/release-track--uuid-virtual/snapshots/create + +# Error response: +{ + "error": "ValidationError", + "message": "Cannot create virtual snapshot: component track 'GroupsMonthly' has no tagged releases", + "details": { + "component": "release-track--uuid-1", + "issue": "No tagged snapshots found (all snapshots are drafts)" + } +} +``` + +#### 2. Component tracks must be standard tracks + +```javascript +// When creating/updating virtual track composition +async function validateComponentsAreStandard(virtualTrack) { + for (const component of virtualTrack.composition.component_tracks) { + const track = await getReleaseTrack(component.track_id); + + if (track.type === "virtual") { + throw new ValidationError( + `Virtual tracks can only compose from standard tracks. ` + + `Component track ${component.track_id} is a virtual track.` + ); + } + } +} +``` + +## API Reference + +### Create Virtual Track + +```bash +POST /api/release-tracks/new +``` + +**Request:** +```json +{ + "type": "virtual", + "name": "Enterprise ATT&CK", + "description": "Virtual aggregation of Enterprise content", + + "composition": { + "component_tracks": [ + { + "track_id": "release-track--uuid-1", + "resolution_strategy": "latest_tagged", + "filters": { + "object_types": ["intrusion-set"] + } + } + ], + "deduplication": { + "strategy": "prefer_latest_modified", + "tier_resolution": "highest_tier", + "status_resolution": "highest_status" + } + }, + + "snapshot_schedule": { + "mode": "cron", + "cron": "0 0 1 1,7 *" + } +} +``` + +### Update Composition + +```bash +PUT /api/release-tracks/:id/composition +``` + +**Request:** +```json +{ + "component_tracks": [ + { + "track_id": "release-track--uuid-1", + "resolution_strategy": "latest_tagged" + }, + { + "track_id": "release-track--uuid-2", + "resolution_strategy": "specific_version", + "version": "2.0" + } + ] +} +``` + +**Note:** Updating composition creates a new draft snapshot with the new composition rules. + +### Create Virtual Snapshot + +```bash +POST /api/release-tracks/:id/snapshots/create +``` + +**Request:** +```json +{ + "description": "Q1 2024 snapshot" +} +``` + +### Preview Virtual Snapshot + +Preview what a snapshot would contain without creating it: + +```bash +GET /api/release-tracks/:id/snapshots/preview +``` + +**Response:** +```json +{ + "preview": { + "would_resolve_to": { + "component_snapshots": [...], + "total_objects": 870 + }, + "comparison_to_latest_tagged": { + "current_version": "13.1", + "new_objects": 12, + "updated_objects": 45, + "removed_objects": 3 + } + } +} +``` + +### Tag Virtual Snapshot + +```bash +POST /api/release-tracks/:id/snapshots/:modified/bump +``` + +**Request:** +```json +{ + "type": "major" +} +``` + +### Get Virtual Track with Resolved Content + +```bash +GET /api/release-tracks/:id?format=workbench&include=all +``` + +**Query params:** +- `format`: `bundle` | `workbench` | `filesystemstore` +- `include`: `members` | `quarantine` | `all` +- `resolve`: `true` (default) | `false` - Whether to resolve composition + +**Response when `resolve=true`:** +```json +{ + "id": "release-track--uuid-virtual", + "type": "virtual", + "snapshot_id": "2024-03-05T10:00:00.000Z", + "modified": "2024-03-05T10:00:00Z", + "version": null, + "name": "Enterprise ATT&CK", + + "resolved_content": { + "members": [ + { + "object_ref": "intrusion-set--APT1", + "object_modified": "2024-02-01T10:00:00Z", + "source_track": "release-track--uuid-1", + "source_version": "5.2" + } + // ... all resolved objects + ], + "quarantine": [] + }, + + "composition_resolution": { + "resolved_at": "2024-03-05T10:00:00Z", + "component_snapshots": [...] + } +} +``` + +## Quarantine Management + +When using the `quarantine` deduplication strategy, conflicting objects are stored in the virtual track's `quarantine` tier. Users must manually resolve these conflicts: + +**View quarantined objects:** +```bash +GET /api/release-tracks/:id?include=quarantine +``` + +**Manually promote a quarantined object to members:** +```bash +POST /api/release-tracks/:id/quarantine/promote +``` + +**Request:** +```json +{ + "object_ref": "intrusion-set--APT1", + "object_modified": "2024-02-01T10:00:00Z" +} +``` + +**Effect:** +- Moves the specified version from `quarantine` to `members` +- Removes other versions of the same object from `quarantine` +- Next snapshot tagging will include this object in the release + +## Hybrid Model: Virtual Track + Native Objects + +Virtual tracks can optionally have **native objects** in addition to composed content. This is an advanced use case where a virtual track needs to include objects that don't exist in any component track: + +```javascript +{ + id: "release-track--uuid-virtual", + type: "virtual", + + // Composed from standard tracks + composition: { + component_tracks: [ + { track_id: "release-track--uuid-1", priority: 1 }, + { track_id: "release-track--uuid-2", priority: 2 } + ], + deduplication: { + strategy: "prioritize_latest_object" + } + }, + + // PLUS virtual track's own native members + native_members: [ + { + object_ref: "marking-definition--enterprise-only", + object_modified: "2024-01-01T10:00:00Z" + } + ], + + // Final result after sync + members: [ + // ... objects from component tracks + // ... plus native_members + ], + quarantine: [] +} +``` + +**Use case:** Enterprise track includes Groups and Techniques from standard tracks, PLUS Enterprise-specific marking definitions or custom objects that don't belong in any component track. + +**When virtual snapshot is created:** +1. Resolve composed content from component tracks (goes to `members` or `quarantine`) +2. Merge with virtual track's `native_members` (goes to `members`) +3. If any `native_members` conflict with composed objects, apply deduplication strategy + +**Note:** This is an advanced feature. Most virtual tracks should only use composition without native members. + +## Migration Strategy + +### Phase 1: Create Standard Tracks + +```bash +# Create standard tracks for each object type +POST /api/release-tracks/new +{ + "name": "Groups Monthly", + "description": "All intrusion-set objects" +} + +# Add existing Groups as candidates +POST /api/release-tracks/release-track--uuid-1/candidates +{ + "object_refs": ["intrusion-set--APT1", "intrusion-set--APT2", ...] +} + +# Tag initial release +POST /api/release-tracks/release-track--uuid-1/bump +{ "version": "1.0" } +``` + +### Phase 2: Create Virtual Track + +```bash +POST /api/release-tracks/new +{ + "type": "virtual", + "name": "Enterprise ATT&CK", + "composition": { + "component_tracks": [ + { + "track_id": "release-track--uuid-1", + "resolution_strategy": "latest_tagged" + }, + { + "track_id": "release-track--uuid-2", + "resolution_strategy": "latest_tagged" + } + ] + }, + "snapshot_schedule": { + "mode": "dates", + "dates": ["2024-07-01T00:00:00Z", "2025-01-01T00:00:00Z"] + } +} +``` + +### Phase 3: Create First Virtual Snapshot + +```bash +# Manually trigger first snapshot +POST /api/release-tracks/release-track--uuid-virtual/snapshots/create + +# Review draft snapshot +GET /api/release-tracks/release-track--uuid-virtual/snapshots/:modified + +# Tag as Enterprise v14.0 +POST /api/release-tracks/release-track--uuid-virtual/snapshots/:modified/bump +{ "version": "14.0" } +``` + +### Phase 4: Ongoing Workflow + +``` +Timeline: + +Jan 15: GroupsMonthly releases v1.1 (updated Groups) +Feb 15: GroupsMonthly releases v1.2 (more updates) +Mar 15: TechniquesQuarterly releases v2.1 (updated Techniques) +Apr 15: GroupsMonthly releases v1.3 + +July 1: Enterprise scheduled snapshot triggers + → Resolves GroupsMonthly v1.3 (latest tagged) + → Resolves TechniquesQuarterly v2.1 (latest tagged) + → Creates draft snapshot + +July 5: Team reviews draft, tags as Enterprise v14.1 +``` + +## Performance Optimizations + +### 1. Snapshot Caching + +Since virtual snapshots are immutable once created, cache resolved content: + +```javascript +const cacheKey = `virtual-snapshot:${trackId}:${modified}:resolved`; + +const cached = await cache.get(cacheKey); +if (cached) return cached; + +const resolved = await resolveVirtualSnapshot(trackId, modified); +await cache.set(cacheKey, resolved, { ttl: 3600 }); // 1 hour cache +``` + +### 2. Lazy Resolution + +For `GET /api/release-tracks/:id` (latest snapshot), only resolve if: +- Query param `resolve=true` is specified +- Format requires resolution (e.g., `format=bundle`) + +Otherwise, return composition metadata without resolving: + +```javascript +if (!query.resolve && query.format === 'workbench') { + // Return composition config without resolving + return { + id: snapshot.id, + type: snapshot.type, + snapshot_id: snapshot.snapshot_id, + modified: snapshot.modified, + version: snapshot.version, + name: snapshot.name, + composition: snapshot.composition, + composition_resolution: snapshot.composition_resolution // Pre-computed + }; +} +``` + +### 3. Parallel Component Resolution + +Resolve component tracks in parallel: + +```javascript +const resolutions = await Promise.all( + composition.component_tracks.map(async (component) => { + return await resolveComponentSnapshot(component); + }) +); +``` + +### 4. Deduplication Optimization + +Use Set for O(1) duplicate detection: + +```javascript +const seen = new Set(); +const deduplicated = []; + +for (const obj of allObjects) { + const key = `${obj.object_ref}:${obj.object_modified}`; + if (!seen.has(key)) { + seen.add(key); + deduplicated.push(obj); + } +} +``` + +## Best Practices + +### 1. Snapshot Before Tagging + +Always create snapshot, review, then tag: + +```bash +# Create draft +POST /api/release-tracks/:id/snapshots/create + +# Review +GET /api/release-tracks/:id/snapshots/:modified?format=workbench + +# Preview export +GET /api/release-tracks/:id/snapshots/:modified?format=bundle + +# Tag only when satisfied +POST /api/release-tracks/:id/snapshots/:modified/bump +``` + +### 2. Use Scheduled Snapshots for Consistency + +Define snapshot schedule up front: + +```javascript +snapshot_schedule: { + mode: "dates", + dates: [ + "2024-01-15T00:00:00Z", + "2024-07-15T00:00:00Z", + "2025-01-15T00:00:00Z" + ] +} +``` + +### 3. Document Component Versions + +Add metadata to virtual track for documentation: + +```javascript +{ + description: "Enterprise ATT&CK v14.0 includes:\n" + + "- Groups Monthly v1.3 (47 Groups)\n" + + "- Techniques Quarterly v2.1 (823 Techniques)\n" + + "- Software Biannual v1.0 (450 Software)" +} +``` + +### 4. Monitor Component Track Releases + +Set up alerts when component tracks release: + +```javascript +eventBus.on('release-track:released', async (event) => { + // Find virtual tracks that reference this standard track + const virtualTracks = await findVirtualTracksByComponent(event.collectionId); + + // Notify virtual track owners + for (const vt of virtualTracks) { + await notificationService.send({ + to: vt.owner_email, + subject: `Component track ${event.collectionName} released v${event.version}`, + body: `Your virtual track "${vt.name}" references this component. ` + + `Consider creating a new snapshot to include the latest release.` + }); + } +}); +``` + +## Limitations + +### 1. No Event-Driven Snapshots + +Virtual tracks do NOT automatically snapshot when component tracks release. + +**Rationale:** Prevents snapshot explosion when many component tracks release frequently. + +**Alternative:** Use notifications + manual snapshots, or scheduled snapshots. + +### 2. No Workflow on Composed Objects + +Virtual tracks cannot transition workflow status of composed objects. + +**Rationale:** Composed objects are owned by standard tracks; virtual tracks are read-only views. + +**Alternative:** If you need to change object status, do it in the source standard track. + +### 3. Only Reference Tagged Snapshots + +Virtual tracks cannot compose from draft snapshots. + +**Rationale:** Ensures stability and prevents virtual snapshots from inadvertently including WIP content. + +**Alternative:** Tag the standard track snapshot first, then create virtual snapshot. + +## Error Handling + +### Error: Component Has No Tagged Snapshots + +```json +{ + "error": "NoTaggedSnapshotsError", + "message": "Component track 'GroupsMonthly' has no tagged releases", + "resolution": "Tag at least one snapshot in the component track before creating virtual snapshot" +} +``` + +### Error: Component Is Virtual Track + +```json +{ + "error": "InvalidComponentTypeError", + "message": "Virtual tracks can only compose from standard tracks. Component 'release-track--uuid-x' is a virtual track.", + "resolution": "Remove the virtual track from component_tracks. Virtual tracks cannot compose from other virtual tracks." +} +``` diff --git a/docs/user/release-tracks/workflow-examples.md b/docs/user/release-tracks/workflow-examples.md new file mode 100644 index 00000000..8dec6910 --- /dev/null +++ b/docs/user/release-tracks/workflow-examples.md @@ -0,0 +1,107 @@ +## Workflow Examples + +### Example 1: Standard Release Cycle + +```bash +# 1. Create initial collection +POST /api/release-tracks/new +{ "name": "My Release", ... } +# Creates: snapshot 1, x_mitre_version: null + +# 2. Update contents +POST /api/release-tracks/release--123/contents +{ "x_mitre_contents": [...] } +# Creates: snapshot 2, x_mitre_version: null + +# 3. Update metadata +POST /api/release-tracks/release--123/meta +{ "description": "Updated description" } +# Creates: snapshot 3, x_mitre_version: null + +# 4. Ready for first release - tag as v1.0 +POST /api/release-tracks/release--123/bump +{ "type": "major" } +# Updates: snapshot 3, x_mitre_version: "1.0" (IN-PLACE) + +# 5. Continue development +POST /api/release-tracks/release--123/contents +{ "x_mitre_contents": [...] } +# Creates: snapshot 4, x_mitre_version: null + +# 6. Minor release +POST /api/release-tracks/release--123/bump +{ "type": "minor" } +# Updates: snapshot 4, x_mitre_version: "1.1" (IN-PLACE) + +# 7. More changes +POST /api/release-tracks/release--123/contents +{ "x_mitre_contents": [...] } +# Creates: snapshot 5, x_mitre_version: null + +# 8. Another minor release +POST /api/release-tracks/release--123/bump +{ "type": "minor" } +# Updates: snapshot 5, x_mitre_version: "1.2" (IN-PLACE) +``` + +**Resulting Timeline:** +``` +snapshot 1: modified: T1, x_mitre_version: null +snapshot 2: modified: T2, x_mitre_version: null +snapshot 3: modified: T3, x_mitre_version: "1.0" ← RELEASE +snapshot 4: modified: T4, x_mitre_version: "1.1" ← RELEASE +snapshot 5: modified: T5, x_mitre_version: "1.2" ← RELEASE +``` + +### Example 2: Selective Release Tagging + +```bash +# Create several snapshots +POST /api/collections/collection--456/contents # snapshot 1 +POST /api/collections/collection--456/contents # snapshot 2 +POST /api/collections/collection--456/contents # snapshot 3 +POST /api/collections/collection--456/contents # snapshot 4 +POST /api/collections/collection--456/contents # snapshot 5 + +# Only tag snapshots 2 and 5 as releases +POST /api/collections/collection--456/modified//bump +{ "version": "1.0" } + +POST /api/collections/collection--456/bump # Latest = snapshot 5 +{ "version": "1.1" } +``` + +**Resulting Timeline:** +``` +snapshot 1: x_mitre_version: null (skipped) +snapshot 2: x_mitre_version: "1.0" ← RELEASE +snapshot 3: x_mitre_version: null (skipped) +snapshot 4: x_mitre_version: null (skipped) +snapshot 5: x_mitre_version: "1.1" ← RELEASE +``` + +This mirrors Git's ability to tag any commit, not just the latest. + +### Example 3: Handling Already-Released Snapshots + +```bash +# Tag latest snapshot +POST /api/collections/collection--789/bump +{ "version": "1.0" } +# Success: snapshot tagged as v1.0 + +# Attempt to bump the same snapshot again +POST /api/collections/collection--789/bump +{ "version": "1.1" } +# Error: AlreadyReleasedError - "This snapshot has already been tagged as version 1.0" + +# Solution: Make a change first (creates new snapshot) +POST /api/collections/collection--789/contents +{ "x_mitre_contents": [...] } +# Creates new snapshot + +# Now bump the new snapshot +POST /api/collections/collection--789/bump +{ "version": "1.1" } +# Success: new snapshot tagged as v1.1 +``` diff --git a/docs/user/revoke-workflow.md b/docs/user/revoke-workflow.md new file mode 100644 index 00000000..2a506eff --- /dev/null +++ b/docs/user/revoke-workflow.md @@ -0,0 +1,144 @@ +# The 'Revoke Object' workflow + +The Revoke Object workflow allows you to revoke an existing object in the system, which creates a new revoked version of the object with a new STIX ID and updated metadata. The original object remains in the system but is marked as revoked and is not returned in default queries. + +Formerly, this workflow was orchestrated by the frontend, which made multiple API calls to achieve the desired result. Now, the backend has a dedicated endpoint that handles the entire revoke workflow in a single request, simplifying the process and reducing the potential for errors. + +## Usage + +To revoke an object, send a POST request to the following endpoint: + +``` +POST /api/:type/:stixId/revoke +``` +Where `:stixId` is the STIX ID of the object you want to revoke and `:type` is the type of the object. + +e.g., `POST /api/attack-patterns/attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6c/revoke` + +**Request Body**: + +Specify the `id` and `modified` timestamp for the revoked object in the request body. The `id` should be a new STIX ID that follows the standard format (e.g., `attack-pattern--xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`), and the `modified` timestamp should be in RFC3339 format. + +```json +{ + "revoking": { + "stixId": "attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6f", + "modified": "2022-10-24T15:09:07.609Z" + } +} +``` + +### Query Parameters + +Optionally, you can set the following query parameter to preserve relationships: + +- `preserveRelationships` (boolean): If set to `true`, the workflow clones each relationship that references the revoked object so that it points to the revoking object instead, then deprecates the original. If not set or set to `false`, relationships referencing the revoked object are deprecated without being transferred. During transfer, each relationship on the revoked object (Object A) is rewritten so that Object A's STIX ID is replaced with Object B's STIX ID. If the revoking object (Object B) already participates in an equivalent relationship (i.e., one with the same source, target, and relationship type after substitution), the transfer is skipped and a warning is included in the response. Additionally, `subtechnique-of` relationships are never transferred — they are deprecated along with the other relationships but are excluded from the preservation process because transferring hierarchy relationships could create invalid parent/child states. A warning is emitted for each skipped `subtechnique-of` relationship. + +### Techniques and Subtechniques + +Both techniques (parents) and subtechniques are of STIX type `attack-pattern`, so the type check alone does not prevent cross-hierarchy revocations. The following rules apply: + +| Scenario | Allowed? | Notes | +|---|---|---| +| Parent revokes parent | Yes | Standard flow, no hierarchy concerns | +| Sub revokes sub (same parent) | Yes | `subtechnique-of` relationships are skipped during preservation (shared parent) | +| Sub revokes sub (different parent) | Yes | `subtechnique-of` relationships are skipped during preservation; the revoking sub retains its existing parent | +| Parent revokes sub | Yes | `subtechnique-of` relationships are skipped during preservation | +| Sub revokes parent (parent has no children) | Yes | `subtechnique-of` relationships are skipped during preservation | +| Sub revokes parent (parent has children) | **No** | Would orphan the parent's subtechniques; convert the subtechnique to a parent first via the conversion endpoint | + +When a revocation is blocked due to these rules, the API returns a **400 Bad Request** with a message explaining the constraint violation. + +## Response + +### Success Response + +On success, the API returns a **200 OK** with a [workflow response envelope](../../docs/developer/workflow-response-pattern.md). The response includes the revoked object, the `revoked-by` relationship, any transferred relationships, and any deprecated relationships: + +```json +{ + "workflow": "revoke", + "primary": { + "workspace": { + "workflow": { "state": "work-in-progress" }, + "attack_id": "T0006" + }, + "stix": { + "type": "attack-pattern", + "spec_version": "2.1", + "id": "attack-pattern--83efdc56-d35f-4508-9f10-152bbfffde79", + "revoked": true, + "modified": "2026-03-27T14:31:52.744Z", + "name": "technique-E" + } + }, + "sideEffects": { + "created": [ + { + "workspace": { "workflow": {} }, + "stix": { + "type": "relationship", + "relationship_type": "revoked-by", + "source_ref": "attack-pattern--83efdc56-d35f-4508-9f10-152bbfffde79", + "target_ref": "attack-pattern--ab992c5a-4a03-4374-ad15-440fac072760" + } + } + ], + "modified": [], + "deprecated": [ + { + "workspace": { "workflow": { "state": "reviewed" } }, + "stix": { + "type": "relationship", + "relationship_type": "uses", + "x_mitre_deprecated": true + } + } + ], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [] +} +``` + +| Field | Description | +|-------|-------------| +| `workflow` | Always `"revoke"` | +| `primary` | The technique in its post-revocation state (`revoked: true`) | +| `sideEffects.created` | The `revoked-by` relationship, plus any transferred relationships (when `preserveRelationships=true`) | +| `sideEffects.deprecated` | Relationships that referenced the revoked object, deprecated with `x_mitre_deprecated: true` | +| `warnings` | Non-fatal issues (e.g., duplicate relationships skipped during transfer) | + +> **Note:** When `preserveRelationships=true`, relationships are cloned to point to the revoking object (appearing in `sideEffects.created`) and the originals are deprecated (appearing in `sideEffects.deprecated`). If a duplicate relationship already exists on the revoking object, the transfer is skipped and a warning is emitted instead. + +### Error Responses + +#### 409 Conflict + +If you attempt to revoke an object that has already been revoked, you will receive a 409 Conflict response with the following message: + +```json +{ + "message": "Object has already been revoked", + "details": "Object attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6c is already revoked" +} +``` + +#### 404 Not Found + +If you attempt to revoke an object that does not exist, you will receive a 404 Not Found response with the following message: + +```json +{ + "message": "Document not found", + "details": "Object B with stixId attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6f and modified 2022-10-24T15:09:07.609Z not found" +} +``` + +#### 400 Self Revocation Error + +If you attempt to revoke an object by revoking with the same STIX ID and modified timestamp as the original object (i.e., self-revocation), you will receive a 400 Bad Request response with the following message: + +```html +"An object cannot revoke itself" +``` \ No newline at end of file diff --git a/docs/user/stix-bundle-import.md b/docs/user/stix-bundle-import.md new file mode 100644 index 00000000..1d6e6f1c --- /dev/null +++ b/docs/user/stix-bundle-import.md @@ -0,0 +1,171 @@ +# Importing a STIX Bundle + +The ATT&CK Workbench REST API can ingest a STIX 2.1 bundle that wraps an +ATT&CK collection (`x-mitre-collection`). The endpoint persists every +object in the bundle, populates ATT&CK Workbench metadata on each one, +and returns a single response document summarizing what happened. + +Bundles are imported **as-is**: the `stix` content of every persisted +object matches what was in the bundle, byte-for-byte. Workbench adds +metadata in a separate `workspace` namespace but does not alter the +bundle's STIX fields. This guarantee holds even when Workbench's +hooks would otherwise rewrite fields like `stix.name`, +`stix.aliases`, or `stix.external_references` on a user-driven POST. + +## Usage + +``` +POST /api/collection-bundles +Content-Type: application/json +``` + +**Request body**: a STIX 2.1 bundle whose `objects` array contains +exactly one `x-mitre-collection` object and any number of +collection-member objects. + +### Query parameters + +| Parameter | Type | Default | Effect | +|---|---|---|---| +| `previewOnly` | boolean | `false` | Process the bundle and return the would-be import response without persisting anything. | +| `checkOnly` | boolean | `false` | Synonymous with `previewOnly` for backwards compatibility. | +| `validateContents` | boolean | `false` | When `true`, ADM validation is strict — see [Validation modes](#validation-modes). When `false` (default), validation runs but failures are recorded rather than rejected. | +| `forceImport` | repeated string | (none) | Allow import to proceed past specific blocking conditions. Supported values: `attack-spec-version-violations`, `duplicate-collection`. | + +## Validation modes + +Every object in the bundle is validated against the ATT&CK Data Model +(ADM) schemas during import — provided that ADM validation is enabled +in the deployment (`VALIDATE_WITH_ADM_SCHEMAS`, default `true`). +Objects marked `revoked: true` or `x_mitre_deprecated: true` skip +validation; everything else is checked. + +The behavior on validation failure depends on `validateContents`: + +### Default mode — `validateContents=false` (or unset) + +**Fail-open.** A failing object is still persisted, but two pieces of +state are written so the failure is visible: + +1. **On the document itself**, in `workspace.validation`: + + ```jsonc + "workspace": { + "validation": { + "errors": [ + { "message": "type is Invalid literal value", "path": ["type"], "code": "invalid_literal" } + ], + "attack_spec_version": "3.3.0", + "adm_version": "4.11.7", + "validated_at": "2026-05-14T12:00:00.000Z" + } + } + ``` + +2. **On the collection's import response**, in + `workspace.import_categories.errors`, one entry per failing + object: + + ```jsonc + { + "object_ref": "attack-pattern--1234...", + "object_modified": "2024-10-15T14:00:21.000Z", + "error_type": "Validation error", + "error_message": "3 ADM validation error(s): path.x is invalid_type; ...", + "details": [ + { "message": "x_mitre_platforms is Required", "path": ["x_mitre_platforms"], "code": "invalid_type" }, + { "message": "...", "path": ["..."], "code": "..." } + ] + } + ``` + + The `details` array preserves the full Zod issue list so callers + can act on the failure without fetching every object individually. + +Fail-open mode is the default because bundle import is the primary +way that legacy and version-skewed content enters the system; aborting +on every ADM mismatch would make migrations between ATT&CK versions +impossible. + +### Strict mode — `validateContents=true` + +A failing object is **not** persisted. The entry in +`import_categories.errors` is written exactly as above (with full +`details`), but the document is dropped from the bulk insert. Other +objects in the same bundle continue to be processed. The import as a +whole succeeds; only the failing objects are missing from the database. + +Use strict mode when you want the import to be a clean filter: only +objects that pass current ADM validation will be persisted, and the +import response tells you exactly which ones were rejected and why. + +## Reading the import response + +The response is the persisted `x-mitre-collection` document. Look at +`workspace.import_categories`: + +```jsonc +"workspace": { + "imported": "2026-05-14T12:00:00.000Z", + "import_categories": { + "additions": [ /* stixIds of new objects */ ], + "changes": [ /* stixIds where x_mitre_version increased */ ], + "minor_changes": [ /* stixIds where only modified changed */ ], + "revocations": [ /* stixIds newly revoked in this version */ ], + "deprecations": [ /* stixIds newly deprecated in this version */ ], + "supersedes_user_edits": [ ], + "supersedes_collection_changes": [ ], + "duplicates": [ /* stixIds whose modified matches an existing version */ ], + "out_of_date": [ /* stixIds where existing modified is newer */ ], + "errors": [ /* see below */ ] + }, + "import_references": { + "additions": [ /* source_names of newly inserted references */ ], + "changes": [ /* source_names of updated references */ ], + "duplicates": [ ] + } +} +``` + +### Error types in `import_categories.errors` + +| `error_type` | Meaning | +|---|---| +| `Validation error` | The object failed ADM schema validation. The `details` array contains every `{message, path, code}`. In fail-open mode the object is still persisted; in strict mode it is dropped. | +| `Save error` | A persistence failure (e.g. MongoDB duplicate-key race). | +| `Retrieval error` | The bulk pre-fetch for the tier failed; no object in that tier was processed. | +| `Not in contents` | The object exists in the bundle's `objects` array but is missing from the collection's `x_mitre_contents`. It is still persisted; this is a warning. | +| `Missing object` | The object is listed in `x_mitre_contents` but is missing from the bundle. | +| `Unknown object type` | The object's `type` is not one the server knows how to persist. | +| `ATT&CK Spec version violation` | The object's `x_mitre_attack_spec_version` is later than the server supports. Without `forceImport=attack-spec-version-violations`, the entire import aborts when this occurs. | +| `Duplicate collection object` | A second `x-mitre-collection` matching an already-persisted collection was found. Without `forceImport=duplicate-collection`, the import aborts. | + +## Re-importing the same bundle + +Re-importing a bundle whose collection (`x-mitre-collection`) already +exists at the same `modified` timestamp augments the existing +collection rather than creating a duplicate: the new import's +`import_categories` is appended to `workspace.reimports` and member +objects are upserted version-by-version. Members that match an +existing version exactly (same `modified`) appear in `duplicates`; +members whose `modified` is newer than what's stored appear in +`additions` / `changes` / `minor_changes` as appropriate. + +## Performance + +For very large bundles (the Enterprise ATT&CK bundle ships ~5,000 +objects), the import runs in tier-batched parallel passes — see +[`docs/developer/stix-bundle-import-pipeline.md`](../developer/stix-bundle-import-pipeline.md) +for the implementation detail. Typical wall-clock times on developer +hardware: + +| Bundle | Approximate import time | +|---|---| +| Mobile ATT&CK | < 5 seconds | +| ICS ATT&CK | < 5 seconds | +| Enterprise ATT&CK | 20-60 seconds (depending on hardware and Mongo configuration) | + +If the request seems hung past a minute, check the server logs for +`Import Bundle Error` entries — most often the cause is a deeper +issue (e.g. a Mongo connection problem) rather than continued +processing. diff --git a/docs/user/technique-conversion-workflow.md b/docs/user/technique-conversion-workflow.md new file mode 100644 index 00000000..b213ca5a --- /dev/null +++ b/docs/user/technique-conversion-workflow.md @@ -0,0 +1,217 @@ +# Technique Conversion Workflows + +There are two conversion workflows for techniques: + +- **Convert to subtechnique** — promotes a standalone technique to a subtechnique of an existing parent technique. +- **Convert to technique** — promotes a subtechnique to a standalone technique, removing its parent association. + +Both workflows create a new version of the object (same `stix.id`, new `modified` timestamp) with updated properties. The `x_mitre_is_subtechnique` field cannot be changed through a normal PUT update — these endpoints are the only way to change a technique's subtechnique status. + +## Convert Technique to Subtechnique + +### Usage + +``` +POST /api/techniques/:stixId/convert-to-subtechnique +``` + +Where `:stixId` is the STIX ID of the technique to convert (e.g., `attack-pattern--15dbf668-795c-41e6-8219-f0447c0e64ce`). + +Requires **editor** role or higher. + +**Request Body:** + +```json +{ + "parentTechniqueAttackId": "T1234" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `parentTechniqueAttackId` | string | yes | ATT&CK ID of the parent technique. Must match the pattern `T####` and refer to an existing, non-revoked technique. | + +### What Happens + +1. **Validation** — The endpoint verifies that: + - The target technique exists and is not already a subtechnique + - The target technique is not revoked + - The target technique has no child subtechniques (rehome them first) + - The parent technique (`parentTechniqueAttackId`) exists in the system + +2. **New version created** — A new version of the technique is saved with: + - `x_mitre_is_subtechnique` set to `true` + - A new ATT&CK ID in subtechnique format (e.g., `T1234.001`), auto-generated as the next available number under the parent + - Updated `external_references` with the new ATT&CK ID and URL + - A new `modified` timestamp + +3. **Relationship created** — A `subtechnique-of` relationship is created linking the converted subtechnique (`source_ref`) to the parent technique (`target_ref`). + +### Response + +On success, returns **200 OK** with a [workflow response envelope](../../docs/developer/workflow-response-pattern.md): + +```json +{ + "workflow": "convert-to-subtechnique", + "primary": { + "workspace": { + "workflow": { "state": "work-in-progress" }, + "attack_id": "T1234.001" + }, + "stix": { + "type": "attack-pattern", + "id": "attack-pattern--15dbf668-795c-41e6-8219-f0447c0e64ce", + "name": "Example Technique", + "x_mitre_is_subtechnique": true, + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "T1234.001", + "url": "https://attack.mitre.org/techniques/T1234/001" + } + ] + } + }, + "sideEffects": { + "created": [ + { + "workspace": { "workflow": { "state": "reviewed" } }, + "stix": { + "type": "relationship", + "relationship_type": "subtechnique-of", + "source_ref": "attack-pattern--15dbf668-795c-41e6-8219-f0447c0e64ce", + "target_ref": "attack-pattern--a1234567-abcd-1234-abcd-1234567890ab" + } + } + ], + "modified": [], + "deprecated": [], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [] +} +``` + +| Field | Description | +|-------|-------------| +| `workflow` | Always `"convert-to-subtechnique"` | +| `primary` | The technique in its post-conversion state (new version) | +| `sideEffects.created` | The `subtechnique-of` relationship linking the new subtechnique to its parent | +| `sideEffects.deprecated` | Empty for this workflow | +| `warnings` | Non-fatal issues encountered during the workflow | + +### Error Responses + +#### 400 Bad Request + +| Condition | Details | +|-----------|---------| +| Technique is already a subtechnique | `Technique attack-pattern--... is already a subtechnique` | +| Technique is revoked | `Cannot convert a revoked technique` | +| Technique has child subtechniques | `Technique attack-pattern--... has N subtechnique(s). Rehome or remove the subtechnique-of relationships before converting this technique to a subtechnique.` | +| Parent technique does not exist | `Parent technique with ATT&CK ID T#### not found` | +| `parentTechniqueAttackId` missing | Missing parameter error | +| `parentTechniqueAttackId` invalid format | `Invalid parent technique ATT&CK ID format: .... Must be T####.` | + +#### 404 Not Found + +Returned when the target technique (`stixId`) does not exist. + +--- + +## Convert Subtechnique to Technique + +### Usage + +``` +POST /api/techniques/:stixId/convert-to-technique +``` + +Where `:stixId` is the STIX ID of the subtechnique to convert. + +Requires **editor** role or higher. + +No request body is required. + +### What Happens + +1. **Validation** — The endpoint verifies that: + - The target technique exists and is currently a subtechnique (`x_mitre_is_subtechnique` is `true`) + - The target technique is not revoked + +2. **New version created** — A new version of the technique is saved with: + - `x_mitre_is_subtechnique` set to `false` + - A new ATT&CK ID in technique format (e.g., `T1235`), auto-generated as the next available number + - Updated `external_references` with the new ATT&CK ID and URL + - A new `modified` timestamp + +3. **Relationship deprecated** — Any active `subtechnique-of` relationships where this object is the `source_ref` are deprecated (a new version of each relationship is created with `x_mitre_deprecated` set to `true`). The original relationship versions are preserved in history. + +### Response + +On success, returns **200 OK** with a [workflow response envelope](../../docs/developer/workflow-response-pattern.md): + +```json +{ + "workflow": "convert-to-technique", + "primary": { + "workspace": { + "workflow": { "state": "work-in-progress" }, + "attack_id": "T1235" + }, + "stix": { + "type": "attack-pattern", + "id": "attack-pattern--15dbf668-795c-41e6-8219-f0447c0e64ce", + "name": "Example Subtechnique", + "x_mitre_is_subtechnique": false, + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "T1235", + "url": "https://attack.mitre.org/techniques/T1235" + } + ] + } + }, + "sideEffects": { + "created": [], + "modified": [], + "deprecated": [ + { + "workspace": { "workflow": { "state": "reviewed" } }, + "stix": { + "type": "relationship", + "relationship_type": "subtechnique-of", + "source_ref": "attack-pattern--15dbf668-795c-41e6-8219-f0447c0e64ce", + "target_ref": "attack-pattern--a1234567-abcd-1234-abcd-1234567890ab", + "x_mitre_deprecated": true + } + } + ], + "deleted": { "count": 0, "stixIds": [] } + }, + "warnings": [] +} +``` + +| Field | Description | +|-------|-------------| +| `workflow` | Always `"convert-to-technique"` | +| `primary` | The technique in its post-conversion state (new version) | +| `sideEffects.deprecated` | The `subtechnique-of` relationship(s) that were deprecated | +| `sideEffects.created` | Empty for this workflow | +| `warnings` | Non-fatal issues encountered during the workflow | + +### Error Responses + +#### 400 Bad Request + +| Condition | Details | +|-----------|---------| +| Technique is not a subtechnique | `Technique attack-pattern--... is not a subtechnique` | +| Technique is revoked | `Cannot convert a revoked technique` | + +#### 404 Not Found + +Returned when the target technique (`stixId`) does not exist. diff --git a/migrations/20260406220952-backfill-system-config-created-at.js b/migrations/20260406220952-backfill-system-config-created-at.js new file mode 100644 index 00000000..1914ac75 --- /dev/null +++ b/migrations/20260406220952-backfill-system-config-created-at.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Backfill the `created_at` field on existing system configuration documents. + * + * Previously, the system configuration collection contained a single document + * that was updated in-place. With the move to versioned config documents, each + * document needs a `created_at` timestamp to enable sorting by recency. + * + * This migration sets `created_at` to the current time on any document that + * does not already have it. + */ +module.exports = { + async up(db) { + const result = await db + .collection('systemconfigurations') + .updateMany({ created_at: { $exists: false } }, { $set: { created_at: new Date() } }); + console.log( + `Backfilled created_at on ${result.modifiedCount} system configuration document(s)`, + ); + }, + + async down(db) { + await db.collection('systemconfigurations').updateMany({}, { $unset: { created_at: '' } }); + }, +}; diff --git a/migrations/20260409122703-strip-empty-string-fields.js b/migrations/20260409122703-strip-empty-string-fields.js new file mode 100644 index 00000000..a1e8a856 --- /dev/null +++ b/migrations/20260409122703-strip-empty-string-fields.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Strip empty-string values from existing STIX documents. + * + * The application now drops empty-string fields at the service layer before + * persisting (BaseService.stripEmptyStrings). This migration retroactively + * cleans up any documents that already have empty-string values stored. + * + * Because empty strings can appear on any arbitrary field (top-level or nested), + * we scan each document and build a per-document $unset operation for every + * path whose value is exactly "". + */ + +const collectionNames = ['attackObjects', 'relationships']; + +/** + * Recursively collect dot-notation paths whose value is an empty string. + * + * @param {Object} obj - The (sub-)document to inspect + * @param {string} prefix - Dot-notation prefix for the current nesting level + * @returns {string[]} - Array of dot-notation paths to unset + */ +function findEmptyStringPaths(obj, prefix = '') { + const paths = []; + if (!obj || typeof obj !== 'object') return paths; + + for (const [key, val] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + if (val === '') { + paths.push(fullPath); + } else if (val && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) { + paths.push(...findEmptyStringPaths(val, fullPath)); + } + } + return paths; +} + +module.exports = { + async up(db) { + let totalDocuments = 0; + let totalFields = 0; + + for (const collectionName of collectionNames) { + const collection = db.collection(collectionName); + + // Use a cursor to avoid loading the entire collection into memory. + const cursor = collection.find({}); + + const bulkOps = []; + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + const paths = findEmptyStringPaths(doc); + + if (paths.length === 0) continue; + + const unsetObj = {}; + for (const p of paths) { + unsetObj[p] = ''; + } + + bulkOps.push({ + updateOne: { + filter: { _id: doc._id }, + update: { $unset: unsetObj }, + }, + }); + + totalFields += paths.length; + + // Flush in batches of 500 to limit memory usage. + if (bulkOps.length >= 500) { + const result = await collection.bulkWrite(bulkOps, { ordered: false }); + totalDocuments += result.modifiedCount; + bulkOps.length = 0; + } + } + + // Flush remaining operations. + if (bulkOps.length > 0) { + const result = await collection.bulkWrite(bulkOps, { ordered: false }); + totalDocuments += result.modifiedCount; + } + } + + console.log( + `Stripped empty-string fields from ${totalDocuments} document(s) (${totalFields} field(s) total)`, + ); + }, + + async down() { + // Cannot restore original empty-string values — they carry no meaningful data. + console.log('down migration is a no-op: empty-string values cannot be restored'); + }, +}; diff --git a/migrations/20260409142832-backfill-workspace-validation.js b/migrations/20260409142832-backfill-workspace-validation.js new file mode 100644 index 00000000..a3d117e5 --- /dev/null +++ b/migrations/20260409142832-backfill-workspace-validation.js @@ -0,0 +1,200 @@ +'use strict'; + +/** + * Backfill `workspace.validation` on all STIX documents. + * + * Runs ADM (Attack Data Model) validation against every document in the + * `attackObjects` and `relationships` collections. Documents that fail + * validation get `workspace.validation` set with the error details, ADM/spec + * versions, and a timestamp. Documents that pass have any stale + * `workspace.validation` removed so only invalid documents carry the field. + * + * This ensures pre-existing and manually-created objects receive the same + * validation metadata that the import pipeline writes via + * `BaseService._createFromImport`. + */ + +const { getSchema } = require('../app/lib/validation-schemas'); + +/** + * Recursively convert Date instances to ISO strings. + * MongoDB stores timestamps as BSON Date objects, but the ADM Zod schemas + * expect RFC3339 strings (z.iso.datetime). Without this conversion, + * `created` and `modified` fields always fail with `invalid_type`. + */ +function serializeDates(obj) { + if (obj instanceof Date) return obj.toISOString(); + if (Array.isArray(obj)) return obj.map(serializeDates); + if (obj !== null && typeof obj === 'object') { + const out = {}; + for (const [key, val] of Object.entries(obj)) { + out[key] = serializeDates(val); + } + return out; + } + return obj; +} + +module.exports = { + async up(db) { + const { ATTACK_SPEC_VERSION } = require('@mitre-attack/attack-data-model'); + const admPkg = require('@mitre-attack/attack-data-model/package.json'); + + const BATCH_SIZE = 500; + + // EventBus + bypass listener may not be wired up during migrations, + // so we load the bypass rules directly for filtering. + let bypassRules = []; + try { + const bypassDocs = await db.collection('validationbypassrules').find({}).toArray(); // Mongoose pluralizes "ValidationBypassRule" + bypassRules = bypassDocs || []; + } catch { + // Collection may not exist yet — proceed without bypass rules + } + + const collections = ['attackObjects', 'relationships']; + let totalValidated = 0; + let totalErrored = 0; + let totalCleared = 0; + + for (const collectionName of collections) { + const collection = db.collection(collectionName); + const totalDocs = await collection.countDocuments({}); + let collectionValidated = 0; + + console.log(`[${collectionName}] Starting validation of ${totalDocs} documents...`); + + // Target ALL objects (including non-latest, revoked, and deprecated) + const cursor = collection.find({}).batchSize(BATCH_SIZE); + let ops = []; + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + totalValidated++; + collectionValidated++; + + const stixType = doc.stix?.type; + if (!stixType) continue; + + const status = doc.workspace?.workflow?.state || 'reviewed'; + const schema = getSchema(stixType, status); + if (!schema) continue; + + const result = schema.safeParse(serializeDates(doc.stix)); + + if (result.success) { + // Valid — remove any stale validation errors + if (doc.workspace?.validation) { + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update: { $unset: { 'workspace.validation': '' } }, + }, + }); + totalCleared++; + } + continue; + } + + // Convert Zod issues to error objects + let errors = result.error.issues.map((issue) => ({ + message: `${issue.path.join('.')} is ${issue.message}`, + path: issue.path, + code: issue.code, + })); + + // Apply bypass rules (mirrors ValidationBypassesService.checkBypassRule logic) + if (bypassRules.length > 0) { + errors = errors.filter((error) => { + const errorPathStr = JSON.stringify(error.path.map(String)); + return !bypassRules.some((rule) => { + if (!rule.suppressError && !rule.warningMessage) return false; + if (rule.stixType !== 'all' && rule.stixType !== stixType) return false; + if (rule.errorCode !== error.code) return false; + const rulePathStr = JSON.stringify(rule.fieldPath.map(String)); + return rulePathStr === errorPathStr; + }); + }); + } + + if (errors.length === 0) { + // All errors were bypassed — clear stale validation + if (doc.workspace?.validation) { + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update: { $unset: { 'workspace.validation': '' } }, + }, + }); + totalCleared++; + } + continue; + } + + // Set validation errors on the document + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update: { + $set: { + 'workspace.validation': { + errors: errors.map((e) => ({ + message: e.message, + path: e.path, + code: e.code, + })), + attack_spec_version: ATTACK_SPEC_VERSION, + adm_version: admPkg.version, + validated_at: new Date(), + }, + }, + }, + }, + }); + totalErrored++; + + // Flush batch when it reaches BATCH_SIZE + if (ops.length >= BATCH_SIZE) { + await collection.bulkWrite(ops, { ordered: false }); + ops = []; + } + + // Progress reporting + if (collectionValidated % 10000 === 0) { + console.log( + ` [${collectionName}] ${collectionValidated} / ${totalDocs} processed ` + + `(${totalErrored} errors, ${totalCleared} cleared)`, + ); + } + } + + // Flush remaining ops + if (ops.length > 0) { + await collection.bulkWrite(ops, { ordered: false }); + } + + console.log(`[${collectionName}] Done — ${collectionValidated} documents processed.`); + } + + console.log( + `Validation backfill complete: ${totalValidated} documents validated, ` + + `${totalErrored} with errors, ${totalCleared} stale validations cleared`, + ); + }, + + async down(db) { + // Remove all workspace.validation fields set by this migration + const collections = ['attackObjects', 'relationships']; + for (const collectionName of collections) { + const result = await db + .collection(collectionName) + .updateMany( + { 'workspace.validation': { $exists: true } }, + { $unset: { 'workspace.validation': '' } }, + ); + console.log( + `Removed workspace.validation from ${result.modifiedCount} document(s) in ${collectionName}`, + ); + } + }, +}; diff --git a/migrations/20260410171442-remove-x-mitre-version-from-relationships.js b/migrations/20260410171442-remove-x-mitre-version-from-relationships.js new file mode 100644 index 00000000..8ce7eb6e --- /dev/null +++ b/migrations/20260410171442-remove-x-mitre-version-from-relationships.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Remove the `x_mitre_version` field from all relationship documents. + * + * The ATT&CK specification no longer permits `x_mitre_version` on SROs. + * Legacy relationship documents that still carry this field will fail ADM + * validation when users attempt to update them through the standard POST + * workflow. This migration retroactively strips the field so those documents + * pass validation. + * + * All relationships are updated (including deprecated ones) to ensure data + * consistency across the collection — the field is spec-invalid regardless of + * lifecycle state. + */ + +module.exports = { + async up(db) { + const collection = db.collection('relationships'); + + const result = await collection.updateMany( + { 'stix.x_mitre_version': { $exists: true } }, + { $unset: { 'stix.x_mitre_version': '' } }, + ); + + console.log(`Removed x_mitre_version from ${result.modifiedCount} relationship document(s)`); + }, + + async down() { + // Cannot restore original x_mitre_version values — they are not tracked elsewhere. + console.log('down migration is a no-op: original x_mitre_version values cannot be restored'); + }, +}; diff --git a/migrations/20260413180456-remove-revoked-from-relationships.js b/migrations/20260413180456-remove-revoked-from-relationships.js new file mode 100644 index 00000000..6f69e28d --- /dev/null +++ b/migrations/20260413180456-remove-revoked-from-relationships.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Remove the `revoked` field from all relationship documents. + * + * Relationships are never revoked in ATT&CK — they are only deprecated. + * Approximately half of existing relationship documents carry a legacy + * `stix.revoked` field (always set to `false`). Its presence is a + * historical artifact and serves no purpose. This migration strips it + * from the entire collection for data consistency. + */ + +module.exports = { + async up(db) { + const collection = db.collection('relationships'); + + const result = await collection.updateMany( + { 'stix.revoked': { $exists: true } }, + { $unset: { 'stix.revoked': '' } }, + ); + + console.log(`Removed revoked from ${result.modifiedCount} relationship document(s)`); + }, + + async down() { + // Cannot distinguish which documents originally had the field. + console.log('down migration is a no-op: original revoked values cannot be restored'); + }, +}; diff --git a/migrations/20260416000000-backfill-embedded-relationships.js b/migrations/20260416000000-backfill-embedded-relationships.js new file mode 100644 index 00000000..348ba998 --- /dev/null +++ b/migrations/20260416000000-backfill-embedded-relationships.js @@ -0,0 +1,233 @@ +'use strict'; + +/** + * Backfill `workspace.embedded_relationships` on latest documents. + * + * Legacy databases created before the embedded-relationships feature have no + * `workspace.embedded_relationships` on any document. This migration rebuilds + * them from the authoritative `_ref` / `_refs` STIX fields so that the + * Workbench UI and API consumers can resolve cross-document links without + * performing expensive joins. + * + * **Scope (Phase 1):** + * Detection Strategies → Analytics → Data Components → Data Sources + * + * Only the *latest* version of each STIX object (highest `stix.modified` per + * `stix.id`) is processed. Embedded relationships reference the full lineage + * of an object via `stix.id`; pinning to a specific version would require + * tracking `stix.modified` as well and is deferred for now. + * + * The migration is idempotent — running it multiple times produces the same + * result because it always rebuilds from the source-of-truth STIX fields. + */ + +const TARGETED_TYPES = [ + 'x-mitre-detection-strategy', + 'x-mitre-analytic', + 'x-mitre-data-component', + 'x-mitre-data-source', +]; + +const BATCH_SIZE = 500; + +/** + * Aggregation pipeline that returns one document per `stix.id` — the version + * with the most recent `stix.modified` — filtered to the STIX types we care + * about. + */ +function latestDocsPipeline(types) { + return [ + { $match: { 'stix.type': { $in: types } } }, + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { + $group: { + _id: '$stix.id', + doc: { $first: '$$ROOT' }, + }, + }, + { $replaceRoot: { newRoot: '$doc' } }, + ]; +} + +// ─── Relationship extractors ──────────────────────────────────────────────── +// Each function returns an array of { stix_id, direction } objects describing +// the *outbound* refs that a document declares via its STIX fields. + +function extractDetectionStrategyOutbound(doc) { + return (doc.stix?.x_mitre_analytic_refs || []).map((ref) => ({ + stix_id: ref, + direction: 'outbound', + })); +} + +function extractAnalyticOutbound(doc) { + return (doc.stix?.x_mitre_log_source_references || []) + .filter((ref) => ref.x_mitre_data_component_ref) + .map((ref) => ({ + stix_id: ref.x_mitre_data_component_ref, + direction: 'outbound', + })); +} + +function extractDataComponentOutbound(doc) { + const ref = doc.stix?.x_mitre_data_source_ref; + if (!ref) return []; + return [{ stix_id: ref, direction: 'outbound' }]; +} + +// ─── Reverse-index builders ───────────────────────────────────────────────── +// Build maps of targetStixId → [{ stix_id, attack_id }] so that we can +// efficiently attach *inbound* relationships to the target documents. + +function buildReverseIndex(latestDocs, sourceType, refExtractor) { + const index = new Map(); + for (const doc of latestDocs) { + if (doc.stix?.type !== sourceType) continue; + for (const outbound of refExtractor(doc)) { + if (!index.has(outbound.stix_id)) index.set(outbound.stix_id, []); + index.get(outbound.stix_id).push({ + stix_id: doc.stix.id, + attack_id: doc.workspace?.attack_id || null, + }); + } + } + return index; +} + +module.exports = { + async up(db) { + const collection = db.collection('attackObjects'); + + // ── 1. Load latest documents for all targeted types ─────────────────── + const latestDocs = await collection.aggregate(latestDocsPipeline(TARGETED_TYPES)).toArray(); + + console.log(`Loaded ${latestDocs.length} latest document(s) across targeted types`); + + // ── 2. Build lookup: stix.id → attack_id ─────────────────────────── + const attackIdLookup = new Map(); + for (const doc of latestDocs) { + attackIdLookup.set(doc.stix.id, doc.workspace?.attack_id || null); + } + + // ── 3. Build reverse indexes for inbound relationships ─────────────── + // + // Detection Strategy → Analytics (analytics receive inbound) + // Analytics → Data Components (data components receive inbound) + // Data Components → Data Sources (data sources receive inbound) + const analyticsInbound = buildReverseIndex( + latestDocs, + 'x-mitre-detection-strategy', + extractDetectionStrategyOutbound, + ); + const dataComponentsInbound = buildReverseIndex( + latestDocs, + 'x-mitre-analytic', + extractAnalyticOutbound, + ); + const dataSourcesInbound = buildReverseIndex( + latestDocs, + 'x-mitre-data-component', + extractDataComponentOutbound, + ); + + // ── 4. Build embedded_relationships per document ───────────────────── + let ops = []; + const counts = { set: 0, unset: 0 }; + + for (const doc of latestDocs) { + const type = doc.stix?.type; + const relationships = []; + + // ─ Outbound ─ + let outbound = []; + if (type === 'x-mitre-detection-strategy') { + outbound = extractDetectionStrategyOutbound(doc); + } else if (type === 'x-mitre-analytic') { + outbound = extractAnalyticOutbound(doc); + } else if (type === 'x-mitre-data-component') { + outbound = extractDataComponentOutbound(doc); + } + // Data sources have no outbound refs in this scope + + for (const rel of outbound) { + relationships.push({ + stix_id: rel.stix_id, + attack_id: attackIdLookup.get(rel.stix_id) || null, + direction: 'outbound', + }); + } + + // ─ Inbound ─ + let inbound = []; + if (type === 'x-mitre-analytic') { + inbound = analyticsInbound.get(doc.stix.id) || []; + } else if (type === 'x-mitre-data-component') { + inbound = dataComponentsInbound.get(doc.stix.id) || []; + } else if (type === 'x-mitre-data-source') { + inbound = dataSourcesInbound.get(doc.stix.id) || []; + } + + for (const rel of inbound) { + relationships.push({ + stix_id: rel.stix_id, + attack_id: rel.attack_id, + direction: 'inbound', + }); + } + + // ─ Write ─ + if (relationships.length > 0) { + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update: { $set: { 'workspace.embedded_relationships': relationships } }, + }, + }); + counts.set++; + } else { + // No relationships — ensure stale data is cleared (idempotency) + ops.push({ + updateOne: { + filter: { _id: doc._id, 'workspace.embedded_relationships': { $exists: true } }, + update: { $unset: { 'workspace.embedded_relationships': '' } }, + }, + }); + counts.unset++; + } + + // Flush batch + if (ops.length >= BATCH_SIZE) { + await collection.bulkWrite(ops, { ordered: false }); + ops = []; + } + } + + // Flush remaining ops + if (ops.length > 0) { + await collection.bulkWrite(ops, { ordered: false }); + } + + console.log( + `Backfill complete: set embedded_relationships on ${counts.set} document(s), ` + + `cleared stale data on up to ${counts.unset} document(s)`, + ); + }, + + async down(db) { + // Remove all embedded_relationships set by this migration for the targeted types + const collection = db.collection('attackObjects'); + + for (const type of TARGETED_TYPES) { + const result = await collection.updateMany( + { + 'stix.type': type, + 'workspace.embedded_relationships': { $exists: true }, + }, + { $unset: { 'workspace.embedded_relationships': '' } }, + ); + console.log( + `Removed workspace.embedded_relationships from ${result.modifiedCount} ${type} document(s)`, + ); + } + }, +}; diff --git a/migrations/20260429172049-remove-empty-array-fields.js b/migrations/20260429172049-remove-empty-array-fields.js new file mode 100644 index 00000000..f3627ca7 --- /dev/null +++ b/migrations/20260429172049-remove-empty-array-fields.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Remove empty array values from STIX fields that previously defaulted to []. + * + * Mongoose's default behavior for `[String]` schema fields is to initialize + * them as empty arrays. The STIX 2.1 specification states that list properties + * MUST NOT be empty — they should be absent rather than present as `[]`. + * + * The corresponding model schemas have been updated to `{ type: [String], default: undefined }` + * so new documents will omit these fields when not populated. This migration + * cleans up existing documents that already have empty arrays stored. + */ + +// Every array field that should be omitted rather than stored as []. +const fields = [ + 'stix.external_references', + 'stix.object_marking_refs', + 'stix.aliases', + 'stix.roles', + 'stix.sectors', + 'stix.tactic_refs', + 'stix.x_mitre_aliases', + 'stix.x_mitre_analytic_refs', + 'stix.x_mitre_collection_layers', + 'stix.x_mitre_log_source_references', + 'stix.x_mitre_mutable_elements', + 'stix.x_mitre_contributors', + 'stix.x_mitre_domains', + 'stix.x_mitre_platforms', + 'stix.x_mitre_data_sources', + 'stix.x_mitre_effective_permissions', + 'stix.x_mitre_permissions_required', + 'stix.x_mitre_system_requirements', + 'stix.kill_chain_phases', + 'stix.x_mitre_defense_bypassed', + 'stix.x_mitre_tactic_type', + 'stix.x_mitre_contents', + 'workspace.embedded_relationships', + 'workspace.collections', +]; + +const collectionNames = ['attackObjects', 'relationships']; + +module.exports = { + async up(db) { + let totalModified = 0; + + // Unset each field individually so we only remove fields that are actually + // empty arrays — not populated fields on the same document. + for (const collectionName of collectionNames) { + const collection = db.collection(collectionName); + for (const field of fields) { + const result = await collection.updateMany( + { [field]: { $eq: [] } }, + { $unset: { [field]: '' } }, + ); + if (result.modifiedCount > 0) { + console.log( + `Removed empty ${field} from ${result.modifiedCount} ${collectionName} document(s)`, + ); + totalModified += result.modifiedCount; + } + } + } + + console.log(`Total documents updated: ${totalModified}`); + }, + + async down(db) { + // Restore empty arrays on documents that are missing these fields. + // This is a best-effort reverse — it cannot distinguish between fields that + // were never set vs fields that were unset by the up() migration. + for (const collectionName of collectionNames) { + const collection = db.collection(collectionName); + for (const field of fields) { + await collection.updateMany({ [field]: { $exists: false } }, { $set: { [field]: [] } }); + } + } + }, +}; diff --git a/migrations/20260511130000-normalize-x-mitre-mutable-elements.js b/migrations/20260511130000-normalize-x-mitre-mutable-elements.js new file mode 100644 index 00000000..8bb76d00 --- /dev/null +++ b/migrations/20260511130000-normalize-x-mitre-mutable-elements.js @@ -0,0 +1,448 @@ +'use strict'; + +/** + * Normalize x_mitre_mutable_elements on analytics by removing duplicate + * entries, where uniqueness is defined by the composite (field, description) + * tuple. ADM v4.11.5 introduced a Zod refinement that rejects analytics with + * duplicate mutable elements; this migration brings existing data into + * compliance with that rule. + * + * Why create new versions instead of updating in place? + * - The application treats POST/create as the preferred versioned update path. + * - create() reuses the normal lifecycle hooks, validation, external reference + * rebuilding, and event emission behavior used by the API. + * + * Scope: + * - Only x-mitre-analytic objects are considered + * - Only latest versions are considered (`stix.id` grouped by newest `stix.modified`) + * - Only active versions are considered (not revoked, not deprecated) + * - Only objects whose latest active version contains duplicate mutable + * elements are reposted + * + * Normalization rule: + * - For each analytic, walk x_mitre_mutable_elements in order and keep only + * the first occurrence of each (field, description) tuple. Later duplicates + * are dropped. Order of first occurrences is preserved. + */ + +const mongoose = require('mongoose'); +const config = require('../app/config/config'); +const { + createAutomationRunRecorder, + serializeError, +} = require('../app/lib/automation-run-recorder'); +const logger = require('../app/lib/logger'); +const systemConfigurationRepository = require('../app/repository/system-configurations-repository'); +const validationBypassesService = require('../app/services/system/validation-bypasses-service'); + +const MIGRATION_NAME = '20260511130000-normalize-x-mitre-mutable-elements'; + +const TARGET_TYPE = 'x-mitre-analytic'; + +function dedupeKey(element) { + return JSON.stringify([element?.field ?? null, element?.description ?? null]); +} + +function deduplicateMutableElements(mutableElements) { + if (!Array.isArray(mutableElements)) { + return { changed: false, deduplicated: mutableElements, removedCount: 0 }; + } + + const seen = new Set(); + const deduplicated = []; + + for (const element of mutableElements) { + const key = dedupeKey(element); + if (seen.has(key)) continue; + seen.add(key); + deduplicated.push(element); + } + + return { + changed: deduplicated.length !== mutableElements.length, + deduplicated, + removedCount: mutableElements.length - deduplicated.length, + }; +} + +function nextModifiedTimestamp(existingModified) { + const now = Date.now(); + const existing = new Date(existingModified).getTime(); + const next = Number.isFinite(existing) ? Math.max(now, existing + 1) : now; + return new Date(next).toISOString(); +} + +function latestActiveAnalyticsWithDuplicateMutableElementsPipeline() { + return [ + { $match: { 'stix.type': TARGET_TYPE } }, + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $match: { 'stix.x_mitre_deprecated': { $in: [null, false] } } }, + { $match: { 'stix.revoked': { $in: [null, false] } } }, + { + $match: { + 'stix.x_mitre_mutable_elements': { $type: 'array', $not: { $size: 0 } }, + }, + }, + { + $addFields: { + __mutable_element_count: { $size: '$stix.x_mitre_mutable_elements' }, + __unique_mutable_element_count: { + $size: { + $setUnion: [ + { + $map: { + input: '$stix.x_mitre_mutable_elements', + as: 'el', + in: { field: '$$el.field', description: '$$el.description' }, + }, + }, + [], + ], + }, + }, + }, + }, + { + $match: { + $expr: { $ne: ['$__mutable_element_count', '$__unique_mutable_element_count'] }, + }, + }, + { + $project: { + _id: 0, + __v: 0, + __t: 0, + __mutable_element_count: 0, + __unique_mutable_element_count: 0, + }, + }, + ]; +} + +async function countRemainingLatestActiveAnalyticsWithDuplicateMutableElements(db) { + const [result] = await db + .collection('attackObjects') + .aggregate([ + ...latestActiveAnalyticsWithDuplicateMutableElementsPipeline(), + { $count: 'remaining' }, + ]) + .toArray(); + + return result?.remaining || 0; +} + +function ensureMongooseUsesClient(client) { + if (mongoose.connection.readyState === 0) { + mongoose.connection.setClient(client); + } +} + +async function prepareServiceLayer(client) { + ensureMongooseUsesClient(client); + + await validationBypassesService.loadStaticRules(config.configurationFiles.staticBypassRulesPath); +} + +async function assertOrganizationIdentityConfigured() { + const systemConfig = await systemConfigurationRepository.retrieveOne({ lean: true }); + if (!systemConfig?.organization_identity_ref) { + throw new Error( + 'System configuration is missing organization_identity_ref; cannot create new versions in mutable-elements normalization migration.', + ); + } +} + +module.exports = { + async up(db, client) { + const recorder = await createAutomationRunRecorder(db, { + automationType: 'migration', + name: MIGRATION_NAME, + trigger: { + source: 'startup', + runner: 'migrate-mongo', + }, + scope: { + collections: ['attackObjects'], + object_kinds: ['stix-object'], + target_types: [TARGET_TYPE], + }, + metadata: { + dedup_composite_key: ['field', 'description'], + }, + }); + + const counts = { + scanned_candidates: 0, + attempted_reposts: 0, + updated: 0, + unchanged: 0, + failed: 0, + duplicates_removed: 0, + }; + const warnings = { + existing_validation_issues: 0, + }; + const failures = []; + let verification = {}; + + recorder.log('info', 'Starting migration', { + targetType: TARGET_TYPE, + }); + + try { + await prepareServiceLayer(client); + + const candidateCount = + await countRemainingLatestActiveAnalyticsWithDuplicateMutableElements(db); + if (candidateCount === 0) { + verification = { + remaining_latest_active_analytics_with_duplicate_mutable_elements: 0, + }; + + const summary = { + message: + 'No active latest analytic(s) require mutable-elements deduplication; migration is a no-op.', + }; + + await recorder.finish({ + status: 'completed', + counts, + warnings, + verification, + summary, + errorSummary: null, + }); + + recorder.log('info', summary.message, { + counts, + warnings, + verification, + }); + + return; + } + + await assertOrganizationIdentityConfigured(); + + const analyticsService = require('../app/services/stix/analytics-service'); + + const cursor = db + .collection('attackObjects') + .aggregate(latestActiveAnalyticsWithDuplicateMutableElementsPipeline()); + + while (await cursor.hasNext()) { + const document = await cursor.next(); + counts.scanned_candidates++; + + const previousMutableElements = Array.isArray(document.stix?.x_mitre_mutable_elements) + ? document.stix.x_mitre_mutable_elements + : []; + const { changed, deduplicated, removedCount } = + deduplicateMutableElements(previousMutableElements); + + if (!changed) { + counts.unchanged++; + await recorder.recordItem({ + status: 'unchanged', + action: 'normalize_x_mitre_mutable_elements', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + details: { + previous_modified: document.stix?.modified, + changes: [ + { + field: 'stix.x_mitre_mutable_elements', + before: previousMutableElements, + after: deduplicated, + }, + ], + }, + }); + continue; + } + + const existingValidationErrorCount = document.workspace?.validation?.errors?.length || 0; + const itemWarnings = []; + if (existingValidationErrorCount > 0) { + warnings.existing_validation_issues++; + itemWarnings.push('existing_validation_issues'); + recorder.log('warn', 'Reposting analytic with existing validation issues', { + stixId: document.stix?.id, + validationErrorCount: existingValidationErrorCount, + }); + } + + const repost = JSON.parse(JSON.stringify(document)); + repost.stix.modified = nextModifiedTimestamp(document.stix?.modified); + repost.stix.x_mitre_mutable_elements = deduplicated; + counts.attempted_reposts++; + + try { + const createdDocument = await analyticsService.create(repost, { + import: false, + automationContext: { + automationName: MIGRATION_NAME, + runId: recorder.runId, + }, + }); + + counts.updated++; + counts.duplicates_removed += removedCount; + + await recorder.recordItem({ + status: 'changed', + action: 'normalize_x_mitre_mutable_elements', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + warnings: itemWarnings, + details: { + previous_modified: document.stix?.modified, + new_modified: createdDocument.stix?.modified || repost.stix.modified, + duplicates_removed: removedCount, + ...(existingValidationErrorCount > 0 && { + existing_validation_error_count: existingValidationErrorCount, + }), + changes: [ + { + field: 'stix.x_mitre_mutable_elements', + before: previousMutableElements, + after: deduplicated, + }, + ], + }, + }); + + recorder.log('info', 'Created normalized analytic version', { + stixId: document.stix?.id, + previousModified: document.stix?.modified, + newModified: createdDocument.stix?.modified || repost.stix.modified, + duplicatesRemoved: removedCount, + }); + } catch (error) { + counts.failed++; + failures.push({ + stixId: document.stix?.id, + error: error.message, + }); + + await recorder.recordItem({ + status: 'failed', + action: 'normalize_x_mitre_mutable_elements', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + warnings: itemWarnings, + details: { + previous_modified: document.stix?.modified, + attempted_modified: repost.stix.modified, + duplicates_removed: removedCount, + ...(existingValidationErrorCount > 0 && { + existing_validation_error_count: existingValidationErrorCount, + }), + changes: [ + { + field: 'stix.x_mitre_mutable_elements', + before: previousMutableElements, + after: deduplicated, + }, + ], + }, + error: serializeError(error), + }); + + recorder.log('error', 'Failed to create normalized analytic version', { + stixId: document.stix?.id, + previousModified: document.stix?.modified, + attemptedModified: repost.stix.modified, + error: error.message, + }); + } + } + + verification = { + remaining_latest_active_analytics_with_duplicate_mutable_elements: + await countRemainingLatestActiveAnalyticsWithDuplicateMutableElements(db), + }; + + if (failures.length > 0) { + throw new Error( + `Mutable-elements normalization migration failed for ${failures.length} analytic(s); ` + + `see automationRuns/automationRunItems for details.`, + ); + } + + const summary = { + message: + `Deduplicated x_mitre_mutable_elements on ${counts.updated} active latest analytic(s) ` + + `after scanning ${counts.scanned_candidates} candidate(s); removed ${counts.duplicates_removed} duplicate entry/entries in total.`, + }; + + await recorder.finish({ + status: 'completed', + counts, + warnings, + verification, + summary, + errorSummary: null, + }); + + recorder.log('info', summary.message, { + counts, + warnings, + verification, + }); + } catch (error) { + verification = { + ...verification, + remaining_latest_active_analytics_with_duplicate_mutable_elements: + verification.remaining_latest_active_analytics_with_duplicate_mutable_elements ?? + (await countRemainingLatestActiveAnalyticsWithDuplicateMutableElements(db).catch( + () => null, + )), + }; + + const status = counts.updated > 0 ? 'partial' : 'failed'; + + await recorder.finish({ + status, + counts, + warnings, + verification, + summary: { + message: 'Mutable-elements normalization migration did not complete successfully.', + }, + errorSummary: serializeError(error), + }); + + recorder.log('error', 'Migration failed', { + counts, + warnings, + verification, + error: error.message, + }); + + throw error; + } + }, + + async down() { + // No safe automatic rollback: the up migration creates new historical + // versions via the normal application workflow. + logger.info( + `[${MIGRATION_NAME}] down migration is a no-op: created replacement versions are retained as part of object history`, + ); + }, +}; diff --git a/migrations/20260511140000-demote-noncompliant-asset-related-assets.js b/migrations/20260511140000-demote-noncompliant-asset-related-assets.js new file mode 100644 index 00000000..3f3cea54 --- /dev/null +++ b/migrations/20260511140000-demote-noncompliant-asset-related-assets.js @@ -0,0 +1,440 @@ +'use strict'; + +/** + * Repost active, latest x-mitre-asset objects whose x_mitre_related_assets + * entries violate ADM v4.11.2's new required-field rules on `description` and + * `related_asset_sectors`. The new revision is identical to the prior version + * except that its workflow state is demoted to 'work-in-progress'. + * + * Why create a new revision instead of updating in place? + * - In Workbench, object revisions are first-class. POST/create is the + * canonical versioned update path: each revision is a new MongoDB document, + * uniquely identified by (stix.id, stix.modified). Updating workspace fields + * in place would silently mutate historical state. + * - create() reuses the normal lifecycle hooks, validation, external reference + * rebuilding, and event emission behavior used by the API. + * + * Why demote to 'work-in-progress'? + * - Validation in the service layer is workflow-state-aware: 'work-in-progress' + * maps to ADM's partial schema, which permits the now-required fields to be + * omitted while authors fill them in. That makes the demoted revision the + * documented escape valve for this kind of schema tightening, and it is the + * reason create() succeeds on objects that would otherwise fail validation. + * + * Scope: + * - Only x-mitre-asset objects are considered + * - Only latest versions are considered (`stix.id` grouped by newest `stix.modified`) + * - Only active versions are considered (not revoked, not deprecated) + * - Only versions currently in 'reviewed' or 'awaiting-review' are considered + * - Only versions whose x_mitre_related_assets contains at least one entry + * missing/unset `description` or `related_asset_sectors` + */ + +const mongoose = require('mongoose'); +const config = require('../app/config/config'); +const { + createAutomationRunRecorder, + serializeError, +} = require('../app/lib/automation-run-recorder'); +const logger = require('../app/lib/logger'); +const systemConfigurationRepository = require('../app/repository/system-configurations-repository'); +const validationBypassesService = require('../app/services/system/validation-bypasses-service'); + +const MIGRATION_NAME = '20260511140000-demote-noncompliant-asset-related-assets'; + +const TARGET_TYPE = 'x-mitre-asset'; +const REVIEW_STATES = ['reviewed', 'awaiting-review']; +const DEMOTED_STATE = 'work-in-progress'; + +function isMissingDescription(relatedAsset) { + const value = relatedAsset?.description; + return ( + value === undefined || value === null || (typeof value === 'string' && value.trim() === '') + ); +} + +function isMissingRelatedAssetSectors(relatedAsset) { + const value = relatedAsset?.related_asset_sectors; + return value === undefined || value === null || (Array.isArray(value) && value.length === 0); +} + +function findOffendingRelatedAssets(relatedAssets) { + if (!Array.isArray(relatedAssets)) return []; + + const offenders = []; + relatedAssets.forEach((relatedAsset, index) => { + const missingDescription = isMissingDescription(relatedAsset); + const missingSectors = isMissingRelatedAssetSectors(relatedAsset); + if (missingDescription || missingSectors) { + offenders.push({ + index, + name: relatedAsset?.name, + missing_fields: [ + ...(missingDescription ? ['description'] : []), + ...(missingSectors ? ['related_asset_sectors'] : []), + ], + }); + } + }); + + return offenders; +} + +function nextModifiedTimestamp(existingModified) { + const now = Date.now(); + const existing = new Date(existingModified).getTime(); + const next = Number.isFinite(existing) ? Math.max(now, existing + 1) : now; + return new Date(next).toISOString(); +} + +function latestActiveAssetsInReviewStatesPipeline() { + return [ + { $match: { 'stix.type': TARGET_TYPE } }, + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $match: { 'stix.x_mitre_deprecated': { $in: [null, false] } } }, + { $match: { 'stix.revoked': { $in: [null, false] } } }, + { $match: { 'workspace.workflow.state': { $in: REVIEW_STATES } } }, + { + $match: { + 'stix.x_mitre_related_assets': { $type: 'array', $not: { $size: 0 } }, + }, + }, + { + $match: { + $expr: { + $anyElementTrue: { + $map: { + input: { $ifNull: ['$stix.x_mitre_related_assets', []] }, + as: 'ra', + in: { + $or: [ + { $eq: [{ $ifNull: ['$$ra.description', null] }, null] }, + { $eq: [{ $ifNull: ['$$ra.description', ''] }, ''] }, + { + $eq: [{ $size: { $ifNull: ['$$ra.related_asset_sectors', []] } }, 0], + }, + ], + }, + }, + }, + }, + }, + }, + { $project: { _id: 0, __v: 0, __t: 0 } }, + ]; +} + +async function countRemainingNoncompliantReviewedAssets(db) { + const [result] = await db + .collection('attackObjects') + .aggregate([...latestActiveAssetsInReviewStatesPipeline(), { $count: 'remaining' }]) + .toArray(); + + return result?.remaining || 0; +} + +function ensureMongooseUsesClient(client) { + if (mongoose.connection.readyState === 0) { + mongoose.connection.setClient(client); + } +} + +async function prepareServiceLayer(client) { + ensureMongooseUsesClient(client); + + await validationBypassesService.loadStaticRules(config.configurationFiles.staticBypassRulesPath); +} + +async function assertOrganizationIdentityConfigured() { + const systemConfig = await systemConfigurationRepository.retrieveOne({ lean: true }); + if (!systemConfig?.organization_identity_ref) { + throw new Error( + 'System configuration is missing organization_identity_ref; cannot create new versions in asset related-assets demotion migration.', + ); + } +} + +module.exports = { + async up(db, client) { + const recorder = await createAutomationRunRecorder(db, { + automationType: 'migration', + name: MIGRATION_NAME, + trigger: { + source: 'startup', + runner: 'migrate-mongo', + }, + scope: { + collections: ['attackObjects'], + object_kinds: ['stix-object'], + target_types: [TARGET_TYPE], + review_states: REVIEW_STATES, + }, + metadata: { + required_related_asset_fields: ['description', 'related_asset_sectors'], + demoted_state: DEMOTED_STATE, + }, + }); + + const counts = { + scanned_candidates: 0, + attempted_reposts: 0, + updated: 0, + unchanged: 0, + failed: 0, + }; + const warnings = { + existing_validation_issues: 0, + }; + const failures = []; + let verification = {}; + + recorder.log('info', 'Starting migration', { + targetType: TARGET_TYPE, + reviewStates: REVIEW_STATES, + demotedState: DEMOTED_STATE, + }); + + try { + await prepareServiceLayer(client); + + const candidateCount = await countRemainingNoncompliantReviewedAssets(db); + if (candidateCount === 0) { + verification = { + remaining_latest_active_reviewed_assets_with_noncompliant_related_assets: 0, + }; + + const summary = { + message: 'No active latest reviewed asset(s) require demotion; migration is a no-op.', + }; + + await recorder.finish({ + status: 'completed', + counts, + warnings, + verification, + summary, + errorSummary: null, + }); + + recorder.log('info', summary.message, { + counts, + warnings, + verification, + }); + + return; + } + + await assertOrganizationIdentityConfigured(); + + const assetsService = require('../app/services/stix/assets-service'); + + const cursor = db + .collection('attackObjects') + .aggregate(latestActiveAssetsInReviewStatesPipeline()); + + while (await cursor.hasNext()) { + const document = await cursor.next(); + counts.scanned_candidates++; + + const previousState = document.workspace?.workflow?.state; + const offenders = findOffendingRelatedAssets(document.stix?.x_mitre_related_assets); + + if (offenders.length === 0) { + // Pipeline matched but no offenders identified in application code — + // record as unchanged for observability rather than silently skipping. + counts.unchanged++; + await recorder.recordItem({ + status: 'unchanged', + action: 'demote_workflow_state', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + details: { + previous_modified: document.stix?.modified, + previous_state: previousState, + reason: 'no_offending_related_assets_after_application_recheck', + }, + }); + continue; + } + + const existingValidationErrorCount = document.workspace?.validation?.errors?.length || 0; + const itemWarnings = []; + if (existingValidationErrorCount > 0) { + warnings.existing_validation_issues++; + itemWarnings.push('existing_validation_issues'); + recorder.log('warn', 'Reposting asset with existing validation issues', { + stixId: document.stix?.id, + validationErrorCount: existingValidationErrorCount, + }); + } + + const repost = JSON.parse(JSON.stringify(document)); + repost.stix.modified = nextModifiedTimestamp(document.stix?.modified); + repost.workspace = repost.workspace || {}; + repost.workspace.workflow = repost.workspace.workflow || {}; + repost.workspace.workflow.state = DEMOTED_STATE; + counts.attempted_reposts++; + + try { + const createdDocument = await assetsService.create(repost, { + import: false, + automationContext: { + automationName: MIGRATION_NAME, + runId: recorder.runId, + }, + }); + + counts.updated++; + + await recorder.recordItem({ + status: 'changed', + action: 'demote_workflow_state', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + warnings: itemWarnings, + details: { + previous_modified: document.stix?.modified, + new_modified: createdDocument.stix?.modified || repost.stix.modified, + offending_related_assets: offenders, + ...(existingValidationErrorCount > 0 && { + existing_validation_error_count: existingValidationErrorCount, + }), + changes: [ + { + field: 'workspace.workflow.state', + before: previousState, + after: DEMOTED_STATE, + }, + ], + }, + }); + + recorder.log('info', 'Created demoted asset revision', { + stixId: document.stix?.id, + previousModified: document.stix?.modified, + newModified: createdDocument.stix?.modified || repost.stix.modified, + previousState, + newState: DEMOTED_STATE, + offenderCount: offenders.length, + }); + } catch (error) { + counts.failed++; + failures.push({ + stixId: document.stix?.id, + error: error.message, + }); + + await recorder.recordItem({ + status: 'failed', + action: 'demote_workflow_state', + target: { + kind: 'stix-object', + collection: 'attackObjects', + stix_id: document.stix?.id, + stix_type: document.stix?.type, + }, + warnings: itemWarnings, + details: { + previous_modified: document.stix?.modified, + attempted_modified: repost.stix.modified, + previous_state: previousState, + offending_related_assets: offenders, + ...(existingValidationErrorCount > 0 && { + existing_validation_error_count: existingValidationErrorCount, + }), + }, + error: serializeError(error), + }); + + recorder.log('error', 'Failed to create demoted asset revision', { + stixId: document.stix?.id, + previousModified: document.stix?.modified, + attemptedModified: repost.stix.modified, + error: error.message, + }); + } + } + + verification = { + remaining_latest_active_reviewed_assets_with_noncompliant_related_assets: + await countRemainingNoncompliantReviewedAssets(db), + }; + + if (failures.length > 0) { + throw new Error( + `Asset related-assets demotion migration failed for ${failures.length} object(s); ` + + `see automationRuns/automationRunItems for details.`, + ); + } + + const summary = { + message: + `Demoted ${counts.updated} active latest asset(s) to '${DEMOTED_STATE}' ` + + `after scanning ${counts.scanned_candidates} candidate(s).`, + }; + + await recorder.finish({ + status: 'completed', + counts, + warnings, + verification, + summary, + errorSummary: null, + }); + + recorder.log('info', summary.message, { + counts, + warnings, + verification, + }); + } catch (error) { + verification = { + ...verification, + remaining_latest_active_reviewed_assets_with_noncompliant_related_assets: + verification.remaining_latest_active_reviewed_assets_with_noncompliant_related_assets ?? + (await countRemainingNoncompliantReviewedAssets(db).catch(() => null)), + }; + + const status = counts.updated > 0 ? 'partial' : 'failed'; + + await recorder.finish({ + status, + counts, + warnings, + verification, + summary: { + message: 'Asset related-assets demotion migration did not complete successfully.', + }, + errorSummary: serializeError(error), + }); + + recorder.log('error', 'Migration failed', { + counts, + warnings, + verification, + error: error.message, + }); + + throw error; + } + }, + + async down() { + // No safe automatic rollback: the up migration creates new historical + // revisions via the normal application workflow. + logger.info( + `[${MIGRATION_NAME}] down migration is a no-op: created replacement revisions are retained as part of object history`, + ); + }, +}; diff --git a/package-lock.json b/package-lock.json index c10d055d..12003c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,25 +10,29 @@ "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^13.0.1", + "@mitre-attack/attack-data-model": "^4.11.7", "async": "^3.2.6", "async-await-retry": "^2.1.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "compression": "^1.8.1", + "connect-mongo": "^6.0.0", "convict": "^6.2.4", "cors": "^2.8.5", "express": "^4.21.2", "express-openapi-validator": "^5.5.4", "express-session": "^1.18.2", "helmet": "^8.1.0", + "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", - "migrate-mongo": "^12.1.3", + "migrate-mongo": "^14.0.7", "mongoose": "^8.15.1", "morgan": "^1.10.1", "nanoid": "^5.1.5", "node-cache": "^5.1.2", + "node-schedule": "^2.1.1", "openid-client": "^5.7.1", "passport": "^0.7.0", "passport-anonym-uuid": "^1.0.3", @@ -38,7 +42,8 @@ "superagent": "^10.2.2", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", - "winston": "^3.17.0" + "winston": "^3.17.0", + "zod": "^4.3.6" }, "devDependencies": { "@codedependant/semantic-release-docker": "^5.1.1", @@ -105,9 +110,9 @@ "license": "MIT" }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-13.0.1.tgz", - "integrity": "sha512-91uy6MGWqu7CjcV7tLPMuYh/Wj/RNPBXquSdEaCEpj2H/cFy0Yu+t1EdxExSyaryl1ykhDo30plq9tIm/HVZnw==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-13.0.5.tgz", + "integrity": "sha512-xfh4xVJD62gG6spIc7lwxoWT+l16nZu1ELyU8FkjaP/oD2yP09EvLAU6KhtudN9aML2Khhs9pY6Slr7KGTES3w==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -121,20 +126,24 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -142,7 +151,9 @@ } }, "node_modules/@bcoe/v8-coverage": { - "version": "1.0.1", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -165,159 +176,10 @@ "semver": "^7.3.2" } }, - "node_modules/@codedependant/semantic-release-docker/node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@codedependant/semantic-release-docker/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "license": "MIT", "optional": true, "engines": { @@ -346,40 +208,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/cli/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@commitlint/cli/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@commitlint/config-conventional": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", @@ -450,19 +278,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/format/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@commitlint/is-ignored": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", @@ -515,19 +330,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/load/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@commitlint/message": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", @@ -553,61 +355,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/parse/node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@commitlint/parse/node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "conventional-commits-parser": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@commitlint/parse/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/parse/node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/@commitlint/read": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", @@ -643,16 +390,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@commitlint/rules": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", @@ -706,44 +443,53 @@ "node": ">=v18" } }, - "node_modules/@commitlint/types/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@dabh/diagnostics": { - "version": "2.0.2", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -751,59 +497,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@eslint/config-array/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -814,20 +538,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -838,9 +562,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -854,24 +578,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -879,17 +585,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@eslint/eslintrc/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", - "integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -900,9 +599,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -910,13 +609,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -925,6 +624,8 @@ }, "node_modules/@ewoudenberg/difflib": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ewoudenberg/difflib/-/difflib-0.1.0.tgz", + "integrity": "sha512-OU5P5mJyD3OoWYMWY+yIgwvgNS9cFAU10f+DDuvtogcWQOoJIsQ4Hy2McSfUfhKjq8L0FuWVb4Rt7kgA+XK86A==", "dev": true, "dependencies": { "heap": ">= 0.2.0" @@ -942,6 +643,8 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -949,31 +652,23 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1000,6 +695,8 @@ }, "node_modules/@isaacs/cliui": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { @@ -1014,35 +711,17 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1057,38 +736,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", "engines": { @@ -1097,6 +748,8 @@ }, "node_modules/@jest/expect-utils": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,6 +761,8 @@ }, "node_modules/@jest/schemas": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { @@ -1119,6 +774,8 @@ }, "node_modules/@jest/types": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", "dependencies": { @@ -1133,8 +790,63 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/types/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -1142,25 +854,57 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@mitre-attack/attack-data-model": { + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/@mitre-attack/attack-data-model/-/attack-data-model-4.11.7.tgz", + "integrity": "sha512-UK9Wyxy1FT465v5aNXU1EBCphUBaiGTERax9zeTBa2ETZZq731EZ+cVVYa9VHLq3JGeOpgjmYMc9LbtEn8XDhg==", + "license": "APACHE-2.0", + "dependencies": { + "axios": "^1.9.0", + "uuid": "^10.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@mitre-attack/attack-data-model/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.9", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" @@ -1178,205 +922,184 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@octokit/auth-token": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/core": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.4.tgz", - "integrity": "sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.1.2", - "@octokit/request": "^9.2.1", - "@octokit/request-error": "^6.1.7", - "@octokit/types": "^13.6.2", - "before-after-hook": "^3.0.2", + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/endpoint": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", - "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^13.6.2", + "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/graphql": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.1.tgz", - "integrity": "sha512-n57hXtOoHrhwTWdvhVkdJHdhTv0JstjDbDRhJfwIRNfFqmSo1DaK/mD2syoNUoLCyqSjBpGAKOG0BuwF392slw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/request": "^9.2.2", - "@octokit/types": "^13.8.0", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.4.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.3.tgz", - "integrity": "sha512-tBXaAbXkqVJlRoA/zQVe9mUdb8rScmivqtpv3ovsC5xhje/a+NOCivs7eUhWBwCApJVsR4G5HMeaLbq7PxqZGA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.2.1.tgz", + "integrity": "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^13.7.0" + "@octokit/types": "^15.0.1" }, "engines": { - "node": ">= 18" + "node": ">= 20" }, "peerDependencies": { "@octokit/core": ">=6" } }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz", + "integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.2.tgz", + "integrity": "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^26.0.0" + } + }, "node_modules/@octokit/plugin-retry": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.1.4.tgz", - "integrity": "sha512-7AIP4p9TttKN7ctygG4BtR7rrB0anZqoU9ThXFk8nETqIfvgPUANTSYHqWYknK7W3isw59LpZeLI8pcEwiJdRg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", + "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/request-error": "^6.1.7", - "@octokit/types": "^13.6.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "engines": { - "node": ">= 18" + "node": ">= 20" }, "peerDependencies": { - "@octokit/core": ">=6" + "@octokit/core": ">=7" } }, "node_modules/@octokit/plugin-throttling": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.4.0.tgz", - "integrity": "sha512-IOlXxXhZA4Z3m0EEYtrrACkuHiArHLZ3CvqWwOez/pURNqRuwfoFlTPbN5Muf28pzFuztxPyiUiNwz8KctdZaQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^13.7.0", + "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "engines": { - "node": ">= 18" + "node": ">= 20" }, "peerDependencies": { - "@octokit/core": "^6.1.3" + "@octokit/core": "^7.0.0" } }, "node_modules/@octokit/request": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.2.tgz", - "integrity": "sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/endpoint": "^10.1.3", - "@octokit/request-error": "^6.1.7", - "@octokit/types": "^13.6.2", - "fast-content-type-parse": "^2.0.0", + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/request-error": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", - "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^16.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^27.0.0" } }, "node_modules/@paralleldrive/cuid2": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "license": "MIT", "dependencies": { "@noble/hashes": "^1.1.5" @@ -1384,6 +1107,8 @@ }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, @@ -1392,9 +1117,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", - "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "license": "MIT", "engines": { @@ -1435,9 +1160,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", - "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -1451,6 +1176,8 @@ }, "node_modules/@scarf/scarf": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", "hasInstallScript": true, "license": "Apache-2.0" }, @@ -1484,63 +1211,81 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/commit-analyzer/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-changelog-angular": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", + "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "compare-func": "^2.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@semantic-release/commit-analyzer/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-parser": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "node_modules/@semantic-release/commit-analyzer/node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", + "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" } }, "node_modules/@semantic-release/github": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.1.tgz", - "integrity": "sha512-Z9cr0LgU/zgucbT9cksH0/pX9zmVda9hkDPcgIE0uvjMQ8w/mElDivGjx1w1pEQ+MuQJ5CBq3VCF16S6G4VH3A==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.6.tgz", + "integrity": "sha512-ctDzdSMrT3H+pwKBPdyCPty6Y47X8dSrjd3aPZ5KKIKKWTwZBE9De8GtsH3TyAlw3Uyo2stegMx6rJMXKpJwJA==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/core": "^6.0.0", - "@octokit/plugin-paginate-rest": "^11.0.0", - "@octokit/plugin-retry": "^7.0.0", - "@octokit/plugin-throttling": "^9.0.0", + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^13.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", - "globby": "^14.0.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", "url-join": "^5.0.0" }, "engines": { @@ -1550,118 +1295,207 @@ "semantic-release": ">=24.1.0" } }, - "node_modules/@semantic-release/github/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">=18" } }, - "node_modules/@semantic-release/github/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/@semantic-release/npm": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.2.tgz", + "integrity": "sha512-+M9/Lb35IgnlUO6OSJ40Ie+hUsZLuph2fqXC/qrKn0fMvUU/jiCjpoL6zEm69vzcmaZJ8yNKtMBEKHWN49WBbQ==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^10.9.3", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=20.8.1" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/github/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">= 14" + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/@semantic-release/github/node_modules/mime": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.6.tgz", - "integrity": "sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==", + "node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, - "funding": [ - "https://github.com/sponsors/broofa" - ], "license": "MIT", - "bin": { - "mime": "bin/cli.js" + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, "engines": { - "node": ">=16" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/github/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/@semantic-release/npm/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@semantic-release/npm": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.1.tgz", - "integrity": "sha512-/6nntGSUGK2aTOI0rHPwY3ZjgY9FkXmEHbW9Kr+62NVOsyqpKKeP0lrCH+tphv+EsNdJNmqqwijTEnVWUMQ2Nw==", + "node_modules/@semantic-release/npm/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", - "dependencies": { - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "execa": "^9.0.0", - "fs-extra": "^11.0.0", - "lodash-es": "^4.17.21", - "nerf-dart": "^1.0.0", - "normalize-url": "^8.0.0", - "npm": "^10.5.0", - "rc": "^1.2.8", - "read-pkg": "^9.0.0", - "registry-auth-token": "^5.0.0", - "semver": "^7.1.2", - "tempy": "^3.0.0" + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.8.1" + "node": ">=18" }, - "peerDependencies": { - "semantic-release": ">=20.1.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "node_modules/@semantic-release/npm/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=14.14" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.3.tgz", - "integrity": "sha512-XxAZRPWGwO5JwJtS83bRdoIhCiYIx8Vhr+u231pQAsdFIAbm19rSVJLdnBN+Avvk7CKvNQE/nJ4y7uqKH6WTiw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", + "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", "dev": true, "license": "MIT", "dependencies": { @@ -1683,22 +1517,34 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-changelog-angular": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.0.tgz", + "integrity": "sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "compare-func": "^2.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=18" + } + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-parser": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { @@ -1714,15 +1560,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, @@ -1773,14 +1640,13 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -1794,8 +1660,20 @@ "node": ">=4" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@types/body-parser": { - "version": "1.19.1", + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1803,16 +1681,18 @@ } }, "node_modules/@types/connect": { - "version": "3.4.35", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/conventional-commits-parser": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", - "integrity": "sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1820,22 +1700,27 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/express": { - "version": "5.0.0", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/qs": "*", - "@types/serve-static": "*" + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -1844,23 +1729,23 @@ "@types/send": "*" } }, - "node_modules/@types/express/node_modules/@types/express-serve-static-core": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", "dependencies": { @@ -1869,6 +1754,8 @@ }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1877,30 +1764,43 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.7", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.2", + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/multer": { - "version": "1.4.12", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/node": { - "version": "14.11.8", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", - "peer": true + "dependencies": { + "undici-types": "~7.18.0" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -1910,51 +1810,69 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.9.7", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "license": "MIT" }, "node_modules/@types/range-parser": { - "version": "1.2.4", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.4", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, "node_modules/@types/serve-static": { - "version": "1.13.10", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", "dependencies": { - "@types/mime": "^1", + "@types/http-errors": "*", "@types/node": "*" } }, "node_modules/@types/stack-utils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true, "license": "MIT" }, "node_modules/@types/triple-beam": { "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "11.0.5", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", "license": "MIT", + "peer": true, "dependencies": { "@types/webidl-conversions": "*" } }, "node_modules/@types/yargs": { - "version": "17.0.33", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -1963,6 +1881,8 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT" }, @@ -1979,13 +1899,21 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2003,6 +1931,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", @@ -2021,11 +1959,10 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2037,9 +1974,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-formats": { - "version": "2.1.1", - "dev": true, + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -2054,9 +2006,9 @@ } }, "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -2070,14 +2022,22 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -2102,6 +2062,8 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/argv-formatter": { @@ -2130,12 +2092,28 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, "node_modules/async-await-retry": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/async-await-retry/-/async-await-retry-2.1.0.tgz", + "integrity": "sha512-eP0cVR8SfTussawaGL1edKe6aQJiVo2A8+TFBhpg3GKMHcn3FvAT/CfdaPtXdbsLsca/L7qwhi7e8D7SDFnoNQ==", "license": "MIT", "bin": { "async-await-retry": "index.js" @@ -2156,30 +2134,140 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } }, "node_modules/basic-auth": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", "license": "MIT", "dependencies": { "safe-buffer": "5.1.2" @@ -2188,64 +2276,47 @@ "node": ">= 0.8" } }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "dev": true, "license": "Apache-2.0" }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/on-finished": { - "version": "2.4.1", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bottleneck": { @@ -2256,9 +2327,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2268,6 +2339,8 @@ }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2279,16 +2352,19 @@ }, "node_modules/browser-stdout": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true, "license": "ISC" }, "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">=16.20.1" + "node": ">=20.19.0" } }, "node_modules/buffer-crc32": { @@ -2303,6 +2379,8 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, "node_modules/buffer-from": { @@ -2324,6 +2402,8 @@ }, "node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2331,6 +2411,8 @@ }, "node_modules/c8": { "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", "dev": true, "license": "ISC", "dependencies": { @@ -2361,21 +2443,10 @@ } } }, - "node_modules/c8/node_modules/cliui": { - "version": "8.0.1", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/c8/node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -2391,6 +2462,8 @@ }, "node_modules/c8/node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2405,6 +2478,8 @@ }, "node_modules/c8/node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2419,11 +2494,36 @@ }, "node_modules/c8/node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c8/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/c8/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, "engines": { "node": ">=10" }, @@ -2431,23 +2531,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/c8/node_modules/yargs": { - "version": "17.7.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2479,6 +2562,8 @@ }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -2487,6 +2572,8 @@ }, "node_modules/camelcase": { "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", "engines": { @@ -2497,50 +2584,18 @@ } }, "node_modules/chalk": { - "version": "4.1.0", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/chalk/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/chalk/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -2569,6 +2624,8 @@ }, "node_modules/ci-info": { "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ { @@ -2582,9 +2639,9 @@ } }, "node_modules/clean-stack": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", - "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", "dev": true, "license": "MIT", "dependencies": { @@ -2632,6 +2689,81 @@ "npm": ">=5.0.0" } }, + "node_modules/cli-highlight/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/cli-highlight/node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -2639,6 +2771,66 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-highlight/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", @@ -2655,67 +2847,166 @@ } }, "node_modules/cliui": { - "version": "7.0.4", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", + "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/clone": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", "license": "MIT", "engines": { "node": ">=0.8" } }, "node_modules/color": { - "version": "3.0.0", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.1", - "color-string": "^1.5.2" + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/color-convert": { - "version": "1.9.3", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, "node_modules/color-name": { - "version": "1.1.3", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } }, "node_modules/color-string": { - "version": "1.5.5", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/colors": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", "dev": true, "license": "MIT", "engines": { "node": ">=0.1.90" } }, - "node_modules/colorspace": { - "version": "1.1.2", - "license": "MIT", - "dependencies": { - "color": "3.0.x", - "text-hex": "1.0.x" - } - }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2725,10 +3016,12 @@ } }, "node_modules/commander": { - "version": "9.3.0", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=20" } }, "node_modules/commondir": { @@ -2750,11 +3043,18 @@ } }, "node_modules/component-emitter": { - "version": "1.3.0", - "license": "MIT" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/compressible": { "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" @@ -2781,29 +3081,19 @@ "node": ">= 0.8.0" } }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.2.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/concat-map": { @@ -2828,20 +3118,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -2853,6 +3129,30 @@ "proto-list": "~1.2.1" } }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/connect-mongo": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-6.0.0.tgz", + "integrity": "sha512-mHxfnTiWk7ZtxmHdcrFBKlr7fCtgGoFpx/oe9jFW0yb2NinagsxEeuol78nUWMpnWyYK0nnuXMlU9wrgUjTE6g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "kruptein": "3.0.8" + }, + "engines": { + "node": ">=20.8.0" + }, + "peerDependencies": { + "express-session": "^1.17.1", + "mongodb": ">=5.0.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2865,44 +3165,26 @@ "node": ">= 0.6" } }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/content-type": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/conventional-changelog-angular": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", - "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", "dev": true, "license": "ISC", "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/conventional-changelog-conventionalcommits": { @@ -2919,12 +3201,13 @@ } }, "node_modules/conventional-changelog-writer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.1.tgz", - "integrity": "sha512-hlqcy3xHred2gyYg/zXSMXraY2mjAYYo0msUCpK+BGyaVJMFCKWVXPIHiaacGO2GGp13kvHWXFhYmxT4QQqW3Q==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", @@ -2937,6 +3220,19 @@ "node": ">=18" } }, + "node_modules/conventional-changelog-writer/node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/conventional-commits-filter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", @@ -2948,19 +3244,22 @@ } }, "node_modules/conventional-commits-parser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.1.0.tgz", - "integrity": "sha512-5nxDo7TwKB5InYBl4ZC//1g9GRwB/F3TXOGR9hgUjMGfvSP4Vu5NkpNro2+1+TIEy1vwxApl5ircECr2ri5JIw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", "dev": true, "license": "MIT", "dependencies": { - "meow": "^13.0.0" + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" }, "bin": { - "conventional-commits-parser": "dist/cli/index.js" + "conventional-commits-parser": "cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/convert-hrtime": { @@ -2977,15 +3276,16 @@ } }, "node_modules/convert-source-map": { - "version": "1.7.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.1" - } + "license": "MIT" }, "node_modules/convict": { "version": "6.2.4", + "resolved": "https://registry.npmjs.org/convict/-/convict-6.2.4.tgz", + "integrity": "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ==", "license": "Apache-2.0", "dependencies": { "lodash.clonedeep": "^4.5.0", @@ -2997,36 +3297,50 @@ }, "node_modules/convict/node_modules/yargs-parser": { "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/cookie": { - "version": "1.0.2", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, "node_modules/cookiejar": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "license": "MIT" }, "node_modules/core-util-is": { - "version": "1.0.2", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -3034,15 +3348,18 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3065,13 +3382,13 @@ } }, "node_modules/cosmiconfig-typescript-loader": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.1.0.tgz", - "integrity": "sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", + "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", "dev": true, "license": "MIT", "dependencies": { - "jiti": "^2.4.1" + "jiti": "^2.6.1" }, "engines": { "node": ">=v18" @@ -3082,8 +3399,22 @@ "typescript": ">=5" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3137,22 +3468,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/date-fns": { - "version": "2.28.0", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=0.11" + "node": ">=6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/debug": { - "version": "2.6.9", + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/deep-extend": { @@ -3167,11 +3510,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3179,6 +3526,8 @@ }, "node_modules/depd": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3216,6 +3565,8 @@ }, "node_modules/diff-sequences": { "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, "license": "MIT", "engines": { @@ -3250,6 +3601,8 @@ }, "node_modules/dreamopt": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", + "integrity": "sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==", "dev": true, "dependencies": { "wordwrap": ">=0.0.2" @@ -3282,13 +3635,50 @@ "readable-stream": "^2.0.2" } }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -3296,10 +3686,14 @@ }, "node_modules/ee-first": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/emojilib": { @@ -3311,6 +3705,8 @@ }, "node_modules/enabled": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, "node_modules/encodeurl": { @@ -3333,9 +3729,9 @@ } }, "node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3346,9 +3742,9 @@ } }, "node_modules/env-ci": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.1.0.tgz", - "integrity": "sha512-Z8dnwSDbV1XYM9SBF2J0GcNVvmfmfh3a49qddGIROhBoVro6MZVTji15z/sJbQ2ko2ei8n988EU1wzoLU/tF+g==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", "dev": true, "license": "MIT", "dependencies": { @@ -3419,6 +3815,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/env-ci/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/env-ci/node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -3435,6 +3844,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/env-ci/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/env-ci/node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", @@ -3448,6 +3873,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/env-ci/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/env-ci/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -3485,22 +3923,15 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3512,6 +3943,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3529,8 +3962,25 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { - "version": "3.1.1", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -3544,42 +3994,45 @@ "license": "MIT" }, "node_modules/escape-string-regexp": { - "version": "2.0.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", - "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.28.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3591,7 +4044,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3614,12 +4067,11 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3631,14 +4083,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.1.tgz", - "integrity": "sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3662,9 +4114,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3679,20 +4131,22 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.1", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3706,46 +4160,63 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/debug": { - "version": "4.3.4", + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=6.0" + "node": ">=8" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=7.0.0" } }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -3759,17 +4230,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3779,6 +4239,8 @@ }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -3791,13 +4253,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/ms": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/eslint/node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3812,6 +4271,8 @@ }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -3824,30 +4285,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "Apache-2.0", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3856,7 +4327,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3881,6 +4354,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3889,6 +4364,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3904,78 +4381,44 @@ "node": ">= 0.6" } }, - "node_modules/execa": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.2.tgz", - "integrity": "sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.3", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.0", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bare-events": "^2.7.0" } }, - "node_modules/execa/node_modules/is-plain-obj": { + "node_modules/execa": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, "node_modules/expect": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", "dependencies": { @@ -3990,40 +4433,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -4037,13 +4479,13 @@ } }, "node_modules/express-openapi-validator": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-5.5.7.tgz", - "integrity": "sha512-QcV941hD3GHnCOg72y6kgqA7XsDkmZOGf7tvoIw9IyUlIRvJv/XDpYFnK7rOggt+ZUaM2P/9wsZCmdMXpr7z2A==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-5.6.2.tgz", + "integrity": "sha512-fkDn4+ImUC4HTJ1g0cek/ItqYhmEO19AglJd2Iw2OJco0jLIbxIlDGVazmXbvvYeziU4Bnah2h+S2tb6NtWg8w==", "license": "MIT", "dependencies": { - "@apidevtools/json-schema-ref-parser": "^12.0.1", - "@types/multer": "^1.4.12", + "@apidevtools/json-schema-ref-parser": "^14.2.1", + "@types/multer": "^2.0.0", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", @@ -4052,127 +4494,108 @@ "lodash.clonedeep": "^4.5.0", "lodash.get": "^4.4.2", "media-typer": "^1.1.0", - "multer": "^2.0.1", + "multer": "^2.0.2", "ono": "^7.1.3", - "path-to-regexp": "^8.2.0", - "qs": "^6.14.0" + "path-to-regexp": "^8.3.0", + "qs": "^6.14.1" }, "peerDependencies": { "express": "*" } }, "node_modules/express-openapi-validator/node_modules/@apidevtools/json-schema-ref-parser": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-12.0.2.tgz", - "integrity": "sha512-SoZWqQz4YMKdw4kEMfG5w6QAy+rntjsoAT1FtvZAnVEnCR4uy9YSuDBNoVAFHgzSz0dJbISLLCSrGR2Zd7bcvA==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", + "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", "license": "MIT", "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" }, "engines": { - "node": ">= 16" + "node": ">= 20" }, "funding": { "url": "https://github.com/sponsors/philsturgeon" - } - }, - "node_modules/express-openapi-validator/node_modules/ajv-draft-04": { - "version": "1.0.0", - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "peerDependencies": { + "@types/json-schema": "^7.0.15" } }, - "node_modules/express-openapi-validator/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/express-openapi-validator/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", "depd": "~2.0.0", "on-headers": "~1.1.0", "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", + "safe-buffer": "~5.2.1", "uid-safe": "~2.1.5" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-session/node_modules/cookie": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "license": "MIT" + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } }, - "node_modules/express-session/node_modules/safe-buffer": { - "version": "5.2.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/express/node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -4180,12 +4603,23 @@ } }, "node_modules/express/node_modules/cookie": { - "version": "0.7.1", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, "node_modules/express/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4207,29 +4641,19 @@ "node": ">= 0.6" } }, - "node_modules/express/node_modules/on-finished": { - "version": "2.4.1", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/express/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -4239,40 +4663,20 @@ } }, "node_modules/express/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/express/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/express/node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4287,9 +4691,9 @@ } }, "node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", "dev": true, "funding": [ { @@ -4305,10 +4709,14 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true, "license": "Apache-2.0" }, @@ -4319,23 +4727,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4345,29 +4736,37 @@ }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.3", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fecha": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, "node_modules/figures": { @@ -4401,6 +4800,8 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4412,6 +4813,8 @@ }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -4422,35 +4825,38 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" + "ms": "2.0.0" } }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -4514,32 +4920,9 @@ } }, "node_modules/find-up-simple": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz", - "integrity": "sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/find-up/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, "license": "MIT", "engines": { @@ -4568,6 +4951,8 @@ }, "node_modules/flat": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, "license": "BSD-3-Clause", "bin": { @@ -4576,6 +4961,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -4587,26 +4974,22 @@ } }, "node_modules/flatted": { - "version": "3.3.2", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, - "node_modules/fn-args": { - "version": "5.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/fn.name": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -4624,11 +5007,13 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -4638,12 +5023,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { - "version": "4.0.0", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -4669,6 +5071,8 @@ }, "node_modules/forwarded": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4694,8 +5098,44 @@ "readable-stream": "^2.0.0" } }, + "node_modules/from2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/from2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/from2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/fs-extra": { - "version": "10.1.0", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -4703,11 +5143,13 @@ "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=14.14" } }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4728,6 +5170,8 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { @@ -4772,13 +5216,16 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4799,10 +5246,21 @@ "traverse": "0.6.8" } }, + "node_modules/git-log-parser/node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, "node_modules/git-raw-commits": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, "license": "MIT", "dependencies": { @@ -4817,31 +5275,11 @@ "node": ">=16" } }, - "node_modules/git-raw-commits/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/git-raw-commits/node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/glob": { - "version": "10.4.5", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4860,18 +5298,22 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4879,11 +5321,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -4908,16 +5352,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/global-directory/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4931,76 +5365,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/ignore": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", - "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/globby/node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5015,6 +5379,9 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/handlebars": { @@ -5041,6 +5408,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -5059,8 +5428,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5071,6 +5457,8 @@ }, "node_modules/he": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, "license": "MIT", "bin": { @@ -5079,6 +5467,8 @@ }, "node_modules/heap": { "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", "dev": true, "license": "MIT" }, @@ -5102,22 +5492,22 @@ } }, "node_modules/hook-std": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", - "integrity": "sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/hosted-git-info": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", - "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", "dev": true, "license": "ISC", "dependencies": { @@ -5136,21 +5526,29 @@ }, "node_modules/html-escaper": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -5162,54 +5560,39 @@ "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", + }, "engines": { "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/human-signals": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", - "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=18.18.0" + "node": ">=8.12.0" } }, "node_modules/husky": { @@ -5229,15 +5612,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -5251,7 +5638,9 @@ } }, "node_modules/import-fresh": { - "version": "3.3.0", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5265,6 +5654,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-from-esm": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", @@ -5279,35 +5678,10 @@ "node": ">=18.20" } }, - "node_modules/import-from-esm/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/import-from-esm/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", "funding": { @@ -5317,6 +5691,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -5337,9 +5713,9 @@ } }, "node_modules/index-to-position": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", - "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, "license": "MIT", "engines": { @@ -5351,14 +5727,19 @@ }, "node_modules/inherits": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/into-stream": { "version": "7.0.0", @@ -5379,17 +5760,24 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { "node": ">= 0.10" } }, "node_modules/is-arrayish": { - "version": "0.3.2", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -5398,6 +5786,8 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -5405,6 +5795,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -5416,6 +5808,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -5432,8 +5826,20 @@ "node": ">=8" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, "license": "MIT", "engines": { @@ -5441,10 +5847,15 @@ } }, "node_modules/is-stream": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-text-path": { @@ -5462,6 +5873,8 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", "engines": { @@ -5473,11 +5886,15 @@ }, "node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, @@ -5500,6 +5917,8 @@ }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5508,6 +5927,8 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5520,7 +5941,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5533,6 +5956,8 @@ }, "node_modules/jackspeak": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5545,91 +5970,313 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/java-properties": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", - "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">= 0.6.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-diff": { - "version": "29.7.0", + "node_modules/jest-message-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", + "node_modules/jest-message-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "color-convert": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-util": { - "version": "29.7.0", + "node_modules/jest-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "color-name": "~1.1.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, + "node_modules/jest-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -5638,6 +6285,8 @@ }, "node_modules/jose": { "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5645,11 +6294,15 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5660,11 +6313,15 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-diff": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-1.0.6.tgz", + "integrity": "sha512-tcFIPRdlc35YkYdGxcamJjllUhXWv4n2rK9oJ2RsAzV4FBkuV4ojKEDgcZ+kpKxDmJKv+PFK65+1tVVOnSeEqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5701,11 +6358,23 @@ }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", "dev": true, "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -5742,10 +6411,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -5761,26 +6432,23 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "license": "MIT" - }, "node_modules/jwa": { - "version": "1.4.1", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jwks-rsa": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", - "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", "license": "MIT", "dependencies": { - "@types/express": "^4.17.20", "@types/jsonwebtoken": "^9.0.4", "debug": "^4.3.4", "jose": "^4.15.4", @@ -5791,45 +6459,20 @@ "node": ">=14" } }, - "node_modules/jwks-rsa/node_modules/@types/express": { - "version": "4.17.21", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/jwks-rsa/node_modules/debug": { - "version": "4.3.4", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/jwks-rsa/node_modules/ms": { - "version": "2.1.2", - "license": "MIT" - }, "node_modules/jws": { - "version": "3.2.2", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "node_modules/jwt-decode": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", "engines": { "node": ">=18" @@ -5846,18 +6489,36 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, + "node_modules/kruptein": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.0.8.tgz", + "integrity": "sha512-0CyalFA0Cjp3jnziMp0u1uLZW2/ouhQ0mEMfYlroBXNe86na1RwAuwBcdRAegeWZNMfQy/G5fN47g/Axjtqrfw==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">8" + } + }, "node_modules/kuler": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5869,7 +6530,9 @@ } }, "node_modules/limiter": { - "version": "1.1.5" + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -5925,13 +6588,15 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "dev": true, "license": "MIT" }, @@ -5951,6 +6616,8 @@ }, "node_modules/lodash.clonedeep": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, "node_modules/lodash.escaperegexp": { @@ -5960,50 +6627,47 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.filter": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", - "integrity": "sha512-pXYUy7PR8BCLwX5mgJ/aNtyOvuJTdZAo9EQFUvMIYugqmJxnrYaANvTbgndOzHSCSR0wnlBBfRXJL5SbWxo3FQ==", - "license": "MIT" - }, - "node_modules/lodash.find": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz", - "integrity": "sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==", - "license": "MIT" - }, "node_modules/lodash.get": { "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", "license": "MIT" }, "node_modules/lodash.includes": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", - "license": "MIT" - }, - "node_modules/lodash.isempty": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", - "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -6013,14 +6677,10 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.last": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lodash.last/-/lodash.last-3.0.0.tgz", - "integrity": "sha512-14mq7rSkCxG4XMy9lF2FbIOqqgF0aH0NfPuQ3LPR3vIh0kHnUvIYP70dqa1Hf47zyXfQ8FzAg0MYOQeSuE1R7A==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, @@ -6033,6 +6693,8 @@ }, "node_modules/lodash.once": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -6070,14 +6732,10 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.values": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz", - "integrity": "sha512-r0RwvdCv8id9TUblb/O7rYPwVy6lerCbcawrfdo9iC/1t1wsNMJknO79WNBgwkH0hIeJ08jmvvESbFpNb4jH0Q==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { @@ -6091,8 +6749,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", "license": "MIT", "dependencies": { "@colors/colors": "1.6.0", @@ -6108,17 +6821,23 @@ }, "node_modules/logform/node_modules/@colors/colors": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "license": "MIT", "engines": { "node": ">=0.1.90" } }, - "node_modules/logform/node_modules/ms": { - "version": "2.1.3", + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", "license": "MIT" }, "node_modules/lru-cache": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -6129,14 +6848,45 @@ }, "node_modules/lru-memoizer": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", "license": "MIT", "dependencies": { "lodash.clonedeep": "^4.5.0", "lru-cache": "6.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-dir": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -6155,7 +6905,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6185,32 +6934,6 @@ "marked": ">=1 <16" } }, - "node_modules/marked-terminal/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/marked-terminal/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6231,16 +6954,18 @@ }, "node_modules/memory-pager": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", "license": "MIT" }, "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=16.10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6262,18 +6987,10 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/methods": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6281,6 +6998,8 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -6292,48 +7011,44 @@ } }, "node_modules/migrate-mongo": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/migrate-mongo/-/migrate-mongo-12.1.3.tgz", - "integrity": "sha512-UHXkNVVNKaPSXAHvzcCgvc41FpSqUxTlaBNSl+DpGBDxbIZ1B85ql2k2rphXSsh8zKhHS6qHrboLabYuuu/8Eg==", + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/migrate-mongo/-/migrate-mongo-14.0.7.tgz", + "integrity": "sha512-+p7XfJDNaXPTHeo7v/ldYmVLMy8xYda0KMXSqkMUzlVndS39rMGBQHfyLrdmOKMjgciWyWpjekXzRQuj1B7HqA==", "license": "MIT", "dependencies": { - "cli-table3": "^0.6.1", - "commander": "^9.1.0", - "date-fns": "^2.28.0", - "fn-args": "^5.0.0", - "fs-extra": "^10.0.1", - "lodash.filter": "^4.6.0", - "lodash.find": "^4.6.0", - "lodash.get": "^4.4.2", - "lodash.isempty": "^4.4.0", - "lodash.last": "^3.0.0", - "lodash.values": "^4.3.0", - "p-each-series": "^2.2.0" + "cli-table3": "^0.6.5", + "commander": "^14.0.2" }, "bin": { "migrate-mongo": "bin/migrate-mongo.js" }, "engines": { - "node": ">=8" + "node": ">=20.0.0" }, "peerDependencies": { "mongodb": "^4.4.1 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], "license": "MIT", "bin": { - "mime": "cli.js" + "mime": "bin/cli.js" }, "engines": { - "node": ">=4" + "node": ">=16" } }, "node_modules/mime-db": { - "version": "1.52.0", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6341,6 +7056,8 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6349,23 +7066,35 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6379,35 +7108,26 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mocha": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.5.0.tgz", - "integrity": "sha512-VKDjhy6LMTKm0WgNEdlY77YVsD49LZnPSXJAaPNL9NRYQADxvORsyG1DIQY6v53BKTnlNbEE2MbVCDbnxr4K3w==", + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "license": "MIT", "dependencies": { @@ -6419,6 +7139,7 @@ "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", @@ -6427,7 +7148,7 @@ "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", + "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" @@ -6441,59 +7162,19 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/mocha/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "4.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mocha/node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -6509,6 +7190,8 @@ }, "node_modules/mocha/node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -6522,13 +7205,13 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6537,13 +7220,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, "node_modules/mocha/node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6558,6 +7238,8 @@ }, "node_modules/mocha/node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -6570,8 +7252,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6584,35 +7278,246 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "node_modules/mocha/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "license": "Apache-2.0", + "peer": true, "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.4.3.tgz", + "integrity": "sha512-CDZvFisXvGIigsIw5gqH6r9NI/zxGa/uRdutgUL/isuJh+inj0YXb7Ykw6oFMFzqgTJWb7x0I5DpzrqCstBWpg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.4.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.4.3.tgz", + "integrity": "sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.11", + "https-proxy-agent": "^7.0.6", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.7", + "tslib": "^2.8.1", + "yauzl": "^3.2.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", + "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=16.20.1" } }, - "node_modules/mongodb": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.16.0.tgz", - "integrity": "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==", + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.3", - "mongodb-connection-string-url": "^3.0.0" + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" }, "engines": { "node": ">=16.20.1" @@ -6623,7 +7528,7 @@ "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", + "snappy": "^7.3.2", "socks": "^2.7.1" }, "peerDependenciesMeta": { @@ -6650,146 +7555,63 @@ } } }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.1", + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" - } - }, - "node_modules/mongodb-memory-server": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.1.4.tgz", - "integrity": "sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "mongodb-memory-server-core": "10.1.4", - "tslib": "^2.7.0" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-memory-server-core": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.1.4.tgz", - "integrity": "sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-mutex": "^0.5.0", - "camelcase": "^6.3.0", - "debug": "^4.3.7", - "find-cache-dir": "^3.3.2", - "follow-redirects": "^1.15.9", - "https-proxy-agent": "^7.0.5", - "mongodb": "^6.9.0", - "new-find-package-json": "^2.0.0", - "semver": "^7.6.3", - "tar-stream": "^3.1.7", - "tslib": "^2.7.0", - "yauzl": "^3.1.3" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "whatwg-url": "^14.1.0 || ^13.0.0" } }, - "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 0.8.0" } }, - "node_modules/mongodb-memory-server-core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mongoose": { - "version": "8.15.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.15.1.tgz", - "integrity": "sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==", + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "bson": "^6.10.3", - "kareem": "2.6.3", - "mongodb": "~6.16.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" + "ms": "2.0.0" } }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" + "ee-first": "1.1.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8" } }, "node_modules/mpath": { "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -6807,49 +7629,29 @@ "node": ">=14.0.0" } }, - "node_modules/mquery/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mquery/node_modules/ms": { + "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/ms": { - "version": "2.0.0", - "license": "MIT" - }, "node_modules/multer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", - "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -6887,9 +7689,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", - "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", "funding": [ { "type": "github", @@ -6906,13 +7708,15 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6945,33 +7749,10 @@ "node": ">=12.22.0" } }, - "node_modules/new-find-package-json/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/new-find-package-json/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/node-cache": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", "license": "MIT", "dependencies": { "clone": "2.x" @@ -6996,6 +7777,29 @@ "node": ">=18" } }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/node-uuid": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz", + "integrity": "sha512-TkCET/3rr9mUuRp+CpO7qfgT++aAxfDRaalQhwPFzI9BY/2rCDn6OfpZOVggi1AXfTPpfkTrg5f5WQx5G1uLxA==", + "deprecated": "Use uuid module instead", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -7032,9 +7836,9 @@ "license": "ISC" }, "node_modules/normalize-url": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", - "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "dev": true, "license": "MIT", "engines": { @@ -7045,9 +7849,9 @@ } }, "node_modules/npm": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz", - "integrity": "sha512-iriPEPIkoMYUy3F6f3wwSZAU93E0Eg6cHwIR6jzzOXWSy+SD/rOODEs74cVONHKSx2obXtuUoyidVEhISrisgQ==", + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.6.tgz", + "integrity": "sha512-EHxr81fXY1K9yyLklI2gc9WuhMSh2e4PXuVG/VXJoHSrH4Lbrv01V/Nhkqu+mvm+58UMh59YBtvHU2wb4ikCUw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -7129,71 +7933,71 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^8.0.0", + "@npmcli/arborist": "^8.0.3", "@npmcli/config": "^9.0.0", "@npmcli/fs": "^4.0.0", "@npmcli/map-workspaces": "^4.0.2", - "@npmcli/package-json": "^6.1.0", - "@npmcli/promise-spawn": "^8.0.2", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "@sigstore/tuf": "^3.0.0", - "abbrev": "^3.0.0", + "@npmcli/package-json": "^6.2.0", + "@npmcli/promise-spawn": "^8.0.3", + "@npmcli/redact": "^3.2.2", + "@npmcli/run-script": "^9.1.0", + "@sigstore/tuf": "^3.1.1", + "abbrev": "^3.0.1", "archy": "~1.0.0", "cacache": "^19.0.1", - "chalk": "^5.3.0", - "ci-info": "^4.1.0", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^10.4.5", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.0.2", + "hosted-git-info": "^8.1.0", "ini": "^5.0.0", "init-package-json": "^7.0.2", - "is-cidr": "^5.1.0", + "is-cidr": "^5.1.1", "json-parse-even-better-errors": "^4.0.0", "libnpmaccess": "^9.0.0", - "libnpmdiff": "^7.0.0", - "libnpmexec": "^9.0.0", - "libnpmfund": "^6.0.0", + "libnpmdiff": "^7.0.3", + "libnpmexec": "^9.0.3", + "libnpmfund": "^6.0.3", "libnpmhook": "^11.0.0", "libnpmorg": "^7.0.0", - "libnpmpack": "^8.0.0", - "libnpmpublish": "^10.0.1", + "libnpmpack": "^8.0.3", + "libnpmpublish": "^10.0.2", "libnpmsearch": "^8.0.0", "libnpmteam": "^7.0.0", "libnpmversion": "^7.0.0", "make-fetch-happen": "^14.0.3", - "minimatch": "^9.0.5", - "minipass": "^7.1.1", + "minimatch": "^9.0.9", + "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^11.0.0", - "nopt": "^8.0.0", - "normalize-package-data": "^7.0.0", + "node-gyp": "^11.5.0", + "nopt": "^8.1.0", + "normalize-package-data": "^7.0.1", "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.1", - "npm-package-arg": "^12.0.0", + "npm-install-checks": "^7.1.2", + "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-profile": "^11.0.1", "npm-registry-fetch": "^18.0.2", "npm-user-validate": "^3.0.0", - "p-map": "^4.0.0", + "p-map": "^7.0.4", "pacote": "^19.0.1", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "qrcode-terminal": "^0.12.0", - "read": "^4.0.0", - "semver": "^7.6.3", + "read": "^4.1.0", + "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "ssri": "^12.0.0", "supports-color": "^9.4.0", - "tar": "^6.2.1", + "tar": "^7.5.11", "text-table": "~0.2.0", "tiny-relative-date": "^1.3.0", "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.0", + "validate-npm-package-name": "^6.0.2", "which": "^5.0.0", "write-file-atomic": "^6.0.0" }, @@ -7206,33 +8010,16 @@ } }, "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" + "path-key": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/npm/node_modules/@isaacs/cliui": { @@ -7253,7 +8040,7 @@ } }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", + "version": "6.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -7288,12 +8075,12 @@ } }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", + "version": "7.2.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -7337,7 +8124,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "8.0.0", + "version": "8.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -7372,6 +8159,7 @@ "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", + "promise-retry": "^2.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", @@ -7417,7 +8205,7 @@ } }, "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.1", + "version": "6.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -7427,7 +8215,6 @@ "lru-cache": "^10.0.1", "npm-pick-manifest": "^10.0.0", "proc-log": "^5.0.0", - "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^5.0.0" @@ -7484,7 +8271,7 @@ } }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { - "version": "20.0.0", + "version": "20.0.1", "dev": true, "inBundle": true, "license": "ISC", @@ -7505,7 +8292,7 @@ "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", - "tar": "^6.1.11" + "tar": "^7.5.10" }, "bin": { "pacote": "bin/index.js" @@ -7533,7 +8320,7 @@ } }, "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.1.0", + "version": "6.2.0", "dev": true, "inBundle": true, "license": "ISC", @@ -7542,16 +8329,16 @@ "glob": "^10.2.2", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", - "normalize-package-data": "^7.0.0", "proc-log": "^5.0.0", - "semver": "^7.5.3" + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", + "version": "8.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -7563,19 +8350,19 @@ } }, "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.0", + "version": "4.0.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^7.0.0" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.0.0", + "version": "3.2.2", "dev": true, "inBundle": true, "license": "ISC", @@ -7584,7 +8371,7 @@ } }, "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.0.2", + "version": "9.1.0", "dev": true, "inBundle": true, "license": "ISC", @@ -7611,21 +8398,21 @@ } }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.3.2", + "version": "0.4.3", "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "3.0.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/protobuf-specs": "^0.4.1", "tuf-js": "^3.0.1" }, "engines": { @@ -7642,37 +8429,21 @@ } }, "node_modules/npm/node_modules/abbrev": { - "version": "3.0.0", + "version": "3.0.1", "dev": true, "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/aggregate-error": { - "version": "3.1.0", + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">= 14" } }, "node_modules/npm/node_modules/ansi-regex": { @@ -7685,7 +8456,7 @@ } }, "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", + "version": "6.2.3", "dev": true, "inBundle": true, "license": "MIT", @@ -7697,7 +8468,7 @@ } }, "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", + "version": "2.1.0", "dev": true, "inBundle": true, "license": "ISC" @@ -7743,7 +8514,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", + "version": "2.0.2", "dev": true, "inBundle": true, "license": "MIT", @@ -7774,83 +8545,8 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/p-map": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/npm/node_modules/chalk": { - "version": "5.3.0", + "version": "5.6.2", "dev": true, "inBundle": true, "license": "MIT", @@ -7862,16 +8558,16 @@ } }, "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", + "version": "3.0.0", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/npm/node_modules/ci-info": { - "version": "4.1.0", + "version": "4.4.0", "dev": true, "funding": [ { @@ -7886,7 +8582,7 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.1", + "version": "4.1.3", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -7897,15 +8593,6 @@ "node": ">=14" } }, - "node_modules/npm/node_modules/clean-stack": { - "version": "2.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/npm/node_modules/cli-columns": { "version": "4.0.0", "dev": true, @@ -7994,7 +8681,7 @@ } }, "node_modules/npm/node_modules/debug": { - "version": "4.3.7", + "version": "4.4.3", "dev": true, "inBundle": true, "license": "MIT", @@ -8011,7 +8698,7 @@ } }, "node_modules/npm/node_modules/diff": { - "version": "5.2.0", + "version": "5.2.2", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8057,7 +8744,7 @@ "license": "MIT" }, "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.1", + "version": "3.1.3", "dev": true, "inBundle": true, "license": "Apache-2.0" @@ -8071,13 +8758,30 @@ "node": ">= 4.9.1" } }, + "node_modules/npm/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.0", + "version": "3.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -8100,7 +8804,7 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "10.4.5", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8126,7 +8830,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.0.2", + "version": "8.1.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8138,7 +8842,7 @@ } }, "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", + "version": "4.2.0", "dev": true, "inBundle": true, "license": "BSD-2-Clause" @@ -8157,12 +8861,12 @@ } }, "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.5", + "version": "7.0.6", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -8203,15 +8907,6 @@ "node": ">=0.8.19" } }, - "node_modules/npm/node_modules/indent-string": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/ini": { "version": "5.0.0", "dev": true, @@ -8240,14 +8935,10 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", + "version": "10.1.0", "dev": true, "inBundle": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -8265,7 +8956,7 @@ } }, "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.0", + "version": "5.1.1", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -8306,12 +8997,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "4.0.0", "dev": true, @@ -8365,31 +9050,31 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "7.0.0", + "version": "7.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.0", + "@npmcli/arborist": "^8.0.3", "@npmcli/installed-package-contents": "^3.0.0", "binary-extensions": "^2.3.0", "diff": "^5.1.0", "minimatch": "^9.0.4", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0", - "tar": "^6.2.1" + "tar": "^7.5.11" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "9.0.0", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.0", + "@npmcli/arborist": "^8.0.3", "@npmcli/run-script": "^9.0.1", "ci-info": "^4.0.0", "npm-package-arg": "^12.0.0", @@ -8405,12 +9090,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "6.0.0", + "version": "6.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.0" + "@npmcli/arborist": "^8.0.3" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -8443,12 +9128,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "8.0.0", + "version": "8.0.3", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^8.0.0", + "@npmcli/arborist": "^8.0.3", "@npmcli/run-script": "^9.0.1", "npm-package-arg": "^12.0.0", "pacote": "^19.0.0" @@ -8458,7 +9143,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "10.0.1", + "version": "10.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8545,22 +9230,13 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/npm/node_modules/minimatch": { - "version": "9.0.5", + "version": "9.0.9", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -8570,10 +9246,10 @@ } }, "node_modules/npm/node_modules/minipass": { - "version": "7.1.2", + "version": "7.1.3", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -8591,7 +9267,7 @@ } }, "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.0", + "version": "4.0.1", "dev": true, "inBundle": true, "license": "MIT", @@ -8607,19 +9283,6 @@ "encoding": "^0.1.13" } }, - "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/npm/node_modules/minipass-flush": { "version": "1.0.5", "dev": true, @@ -8644,6 +9307,12 @@ "node": ">=8" } }, + "node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", "dev": true, @@ -8668,6 +9337,12 @@ "node": ">=8" } }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, "node_modules/npm/node_modules/minipass-sized": { "version": "1.0.3", "dev": true, @@ -8692,171 +9367,89 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "11.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", + "node_modules/npm/node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "inBundle": true, + "license": "ISC" }, - "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { - "version": "3.0.1", + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" + "minipass": "^7.1.2" }, "engines": { "node": ">= 18" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", "dev": true, "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT" }, - "node_modules/npm/node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", "dev": true, "inBundle": true, "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, "engines": { - "node": ">=18" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/npm/node_modules/nopt": { - "version": "8.0.0", + "node_modules/npm/node_modules/node-gyp": { + "version": "11.5.0", "dev": true, "inBundle": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "abbrev": "^2.0.0" + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" }, "bin": { - "nopt": "bin/nopt.js" + "node-gyp": "bin/node-gyp.js" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/nopt/node_modules/abbrev": { - "version": "2.0.0", + "node_modules/npm/node_modules/nopt": { + "version": "8.1.0", "dev": true, "inBundle": true, "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.0", + "version": "7.0.1", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -8891,7 +9484,7 @@ } }, "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.1", + "version": "7.1.2", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -8912,7 +9505,7 @@ } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.0", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8985,19 +9578,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/npm/node_modules/npm-user-validate": { "version": "3.0.0", "dev": true, @@ -9008,15 +9588,12 @@ } }, "node_modules/npm/node_modules/p-map": { - "version": "4.0.0", + "version": "7.0.4", "dev": true, "inBundle": true, "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9029,7 +9606,7 @@ "license": "BlueOak-1.0.0" }, "node_modules/npm/node_modules/pacote": { - "version": "19.0.1", + "version": "19.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -9050,7 +9627,7 @@ "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", - "tar": "^6.1.11" + "tar": "^7.5.10" }, "bin": { "pacote": "bin/index.js" @@ -9098,8 +9675,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/npm/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.2", + "version": "7.1.1", "dev": true, "inBundle": true, "license": "MIT", @@ -9147,12 +9736,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/promise-inflight": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC" - }, "node_modules/npm/node_modules/promise-retry": { "version": "2.0.1", "dev": true, @@ -9187,7 +9770,7 @@ } }, "node_modules/npm/node_modules/read": { - "version": "4.0.0", + "version": "4.1.0", "dev": true, "inBundle": true, "license": "ISC", @@ -9229,21 +9812,6 @@ "node": ">= 4" } }, - "node_modules/npm/node_modules/rimraf": { - "version": "5.0.10", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", "dev": true, @@ -9252,7 +9820,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.6.3", + "version": "7.7.4", "dev": true, "inBundle": true, "license": "ISC", @@ -9297,29 +9865,29 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "3.0.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.0.0", + "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "@sigstore/sign": "^3.0.0", - "@sigstore/tuf": "^3.0.0", - "@sigstore/verify": "^2.0.0" + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/bundle": { - "version": "3.0.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2" + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -9335,15 +9903,15 @@ } }, "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/sign": { - "version": "3.0.0", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.0.0", + "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "make-fetch-happen": "^14.0.1", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", "proc-log": "^5.0.0", "promise-retry": "^2.0.1" }, @@ -9352,14 +9920,14 @@ } }, "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/verify": { - "version": "2.0.0", + "version": "2.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^3.0.0", + "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2" + "@sigstore/protobuf-specs": "^0.4.1" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -9376,12 +9944,12 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.3", + "version": "2.8.7", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -9390,12 +9958,12 @@ } }, "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.4", + "version": "8.0.5", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -9440,17 +10008,11 @@ } }, "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.20", + "version": "3.0.23", "dev": true, "inBundle": true, "license": "CC0-1.0" }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "dev": true, - "inBundle": true, - "license": "BSD-3-Clause" - }, "node_modules/npm/node_modules/ssri": { "version": "12.0.0", "dev": true, @@ -9530,53 +10092,19 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", + "version": "7.5.11", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/npm/node_modules/text-table": { @@ -9591,6 +10119,22 @@ "inBundle": true, "license": "MIT" }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/npm/node_modules/treeverse": { "version": "3.0.0", "dev": true, @@ -9601,14 +10145,14 @@ } }, "node_modules/npm/node_modules/tuf-js": { - "version": "3.0.1", + "version": "3.1.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -9678,7 +10222,7 @@ } }, "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -9708,12 +10252,12 @@ } }, "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", + "version": "3.1.5", "dev": true, "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/npm/node_modules/wrap-ansi": { @@ -9767,7 +10311,7 @@ } }, "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", + "version": "6.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -9802,12 +10346,12 @@ } }, "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", + "version": "7.2.0", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -9830,22 +10374,28 @@ } }, "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", + "version": "5.0.0", "dev": true, "inBundle": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9864,16 +10414,18 @@ } }, "node_modules/oidc-token-hash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", - "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" } }, "node_modules/on-finished": { - "version": "2.3.0", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -9893,6 +10445,8 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" @@ -9900,22 +10454,24 @@ }, "node_modules/one-time": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", "license": "MIT", "dependencies": { "fn.name": "1.x.x" } }, "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=12" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9923,6 +10479,8 @@ }, "node_modules/ono": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", "license": "MIT", "dependencies": { "@jsdevtools/ono": "7.1.3" @@ -9930,6 +10488,8 @@ }, "node_modules/openapi-schema-validator": { "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-schema-validator/-/openapi-schema-validator-12.1.3.tgz", + "integrity": "sha512-xTHOmxU/VQGUgo7Cm0jhwbklOKobXby+/237EG967+3TQEYJztMgX9Q5UE2taZKwyKPUq0j11dngpGjUuxz1hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9939,8 +10499,28 @@ "openapi-types": "^12.1.3" } }, + "node_modules/openapi-schema-validator/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/openapi-types": { "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, "license": "MIT" }, @@ -9959,8 +10539,19 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -9975,11 +10566,30 @@ "node": ">= 0.8.0" } }, - "node_modules/p-each-series": { - "version": "2.2.0", + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, "engines": { - "node": ">=8" + "node": ">=16.17" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10027,19 +10637,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-limit/node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-locate": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", @@ -10057,9 +10654,9 @@ } }, "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -10082,23 +10679,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=4" } }, "node_modules/package-json-from-dist": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -10172,11 +10786,15 @@ }, "node_modules/parse5-query-domtree": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse5-query-domtree/-/parse5-query-domtree-1.0.2.tgz", + "integrity": "sha512-5mmp13wtARQonN1RInX4R3p5Jx2AQtd2lYlTMk6G84ZZBUIHMNpvMmTzkEZ+kRbIvZDwX8uQopZIrFkawfJy3Q==", "dev": true, "license": "ISC" }, "node_modules/parseurl": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -10184,6 +10802,8 @@ }, "node_modules/passport": { "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", "dependencies": { "passport-strategy": "1.x.x", @@ -10200,6 +10820,8 @@ }, "node_modules/passport-anonym-uuid": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/passport-anonym-uuid/-/passport-anonym-uuid-1.0.3.tgz", + "integrity": "sha512-bsu59r5hyU0zwqJ3EMdbN0O7O4oZmcjFNhVKXF2PAgPlm1TTJUizJnrkErj4mjOM/mtNE4FwbW4TusrXZJ4lLQ==", "dependencies": { "node-uuid": "^1.4.7", "passport-strategy": "1.x.x" @@ -10208,14 +10830,10 @@ "node": ">= 0.4.0" } }, - "node_modules/passport-anonym-uuid/node_modules/node-uuid": { - "version": "1.4.8", - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/passport-http": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/passport-http/-/passport-http-0.3.0.tgz", + "integrity": "sha512-OwK9DkqGVlJfO8oD0Bz1VDIo+ijD3c1ZbGGozIZw+joIP0U60pXY7goB+8wiDWtNqHpkTaQiJ9Ux1jE3Ykmpuw==", "dependencies": { "passport-strategy": "1.x.x" }, @@ -10225,6 +10843,8 @@ }, "node_modules/passport-http-bearer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz", + "integrity": "sha512-SELQM+dOTuMigr9yu8Wo4Fm3ciFfkMq5h/ZQ8ffi4ELgZrX1xh9PlglqZdcUZ1upzJD/whVyt+YWF62s3U6Ipw==", "dependencies": { "passport-strategy": "1.x.x" }, @@ -10234,20 +10854,26 @@ }, "node_modules/passport-strategy": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", "engines": { "node": ">= 0.4.0" } }, "node_modules/path-exists": { - "version": "4.0.0", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -10256,6 +10882,8 @@ }, "node_modules/path-scurry": { "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10271,17 +10899,16 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -10294,7 +10921,9 @@ } }, "node_modules/pause": { - "version": "0.0.1" + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pend": { "version": "1.2.0", @@ -10305,11 +10934,15 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -10396,16 +11029,6 @@ "node": ">=4" } }, - "node_modules/pkg-conf/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/pkg-conf/node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -10485,8 +11108,30 @@ "node": ">=8" } }, + "node_modules/pkg-dir/node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-dir/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -10494,12 +11139,11 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10511,7 +11155,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -10523,6 +11169,8 @@ }, "node_modules/pretty-format": { "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10535,9 +11183,9 @@ } }, "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10552,6 +11200,8 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, "license": "MIT" }, @@ -10564,6 +11214,8 @@ }, "node_modules/proxy-addr": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -10573,10 +11225,16 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -10586,15 +11244,17 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -10606,29 +11266,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/random-bytes": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -10636,6 +11277,8 @@ }, "node_modules/randombytes": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10652,18 +11295,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -10682,6 +11325,13 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -10694,11 +11344,15 @@ }, "node_modules/react-is": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, "node_modules/read-json-lines-sync": { "version": "2.2.5", + "resolved": "https://registry.npmjs.org/read-json-lines-sync/-/read-json-lines-sync-2.2.5.tgz", + "integrity": "sha512-yTQK/fkO0ZIKSMC26F3OKHwVnUZ9PVLSh0flrliwpBgoWPxck5SK3qIlZEfrBb1Uahh518p4637pK7e0EfutrQ==", "dev": true, "license": "MIT" }, @@ -10741,29 +11395,16 @@ } }, "node_modules/read-pkg/node_modules/parse-json": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", - "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "index-to-position": "^0.1.2", - "type-fest": "^4.7.1" - }, - "engines": { - "node": ">=18" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -10772,17 +11413,17 @@ } }, "node_modules/readable-stream": { - "version": "2.3.7", - "dev": true, + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/readdirp": { @@ -10800,13 +11441,13 @@ } }, "node_modules/registry-auth-token": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", - "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "dev": true, "license": "MIT", "dependencies": { - "@pnpm/npm-conf": "^2.1.0" + "@pnpm/npm-conf": "^3.0.2" }, "engines": { "node": ">=14" @@ -10814,6 +11455,8 @@ }, "node_modules/require-directory": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", "engines": { @@ -10822,35 +11465,27 @@ }, "node_modules/require-from-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve-from": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -10865,17 +11500,12 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", "license": "MIT" }, "node_modules/safe-stable-stringify": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { "node": ">=10" @@ -10888,17 +11518,16 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "24.2.5", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.5.tgz", - "integrity": "sha512-9xV49HNY8C0/WmPWxTlaNleiXhWb//qfMzG2c5X8/k7tuWcu8RssbuS+sujb/h7PiWSXv53mrQvV9hrO9b7vuQ==", + "version": "24.2.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.9.tgz", + "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", - "@semantic-release/npm": "^12.0.0", + "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", @@ -10909,7 +11538,7 @@ "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", - "hook-std": "^3.0.0", + "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", @@ -10921,7 +11550,7 @@ "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", - "semver-diff": "^4.0.0", + "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, @@ -10932,50 +11561,130 @@ "node": ">=20.8.1" } }, - "node_modules/semantic-release/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/semantic-release/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/semantic-release/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" + "node": ">=18" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/semantic-release/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/semantic-release/node_modules/p-each-series": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", - "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "node_modules/semantic-release/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", "engines": { @@ -10985,39 +11694,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/semantic-release/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/semantic-release/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "node_modules/semantic-release/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "engines": { + "node": ">=18" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11027,9 +11746,10 @@ } }, "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-5.0.0.tgz", + "integrity": "sha512-0HbGtOm+S7T6NGQ/pxJSJipJvc4DK3FcRVMRkhsIwJDJ4Jcz5DQC1cPPzB5GhzyHjwttW878HaWQq46CkL3cqg==", + "deprecated": "Deprecated as the semver package now supports this built-in.", "dev": true, "license": "MIT", "dependencies": { @@ -11056,58 +11776,60 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">= 0.8" + "node": ">=4" } }, "node_modules/serialize-javascript": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -11115,31 +11837,37 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -11151,6 +11879,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -11236,15 +11966,11 @@ "license": "MIT" }, "node_modules/signal-exit": { - "version": "4.1.0", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "ISC" }, "node_modules/signale": { "version": "1.4.0", @@ -11289,6 +12015,23 @@ "node": ">=4" } }, + "node_modules/signale/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/signale/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, "node_modules/signale/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -11335,13 +12078,6 @@ "node": ">=4" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/sinon": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-20.0.0.tgz", @@ -11375,12 +12111,20 @@ }, "node_modules/slash": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11393,6 +12137,8 @@ }, "node_modules/sparse-bitfield": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "license": "MIT", "dependencies": { "memory-pager": "^1.0.2" @@ -11435,24 +12181,26 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, "node_modules/split2": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", - "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", - "dependencies": { - "through2": "~2.0.0" + "engines": { + "node": ">= 10.x" } }, "node_modules/stack-trace": { "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", "license": "MIT", "engines": { "node": "*" @@ -11460,6 +12208,8 @@ }, "node_modules/stack-utils": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11469,8 +12219,20 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/statuses": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -11487,6 +12249,39 @@ "readable-stream": "^2.0.2" } }, + "node_modules/stream-combiner2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-combiner2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-combiner2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -11496,28 +12291,30 @@ } }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { - "version": "1.1.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, "node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11531,6 +12328,8 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11542,8 +12341,42 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11552,9 +12385,27 @@ "node": ">=8" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -11564,6 +12415,16 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11575,20 +12436,19 @@ } }, "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -11599,13 +12459,14 @@ } }, "node_modules/super-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", - "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", "dev": true, "license": "MIT", "dependencies": { "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" }, "engines": { @@ -11616,42 +12477,29 @@ } }, "node_modules/superagent": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.2.tgz", - "integrity": "sha512-vWMq11OwWCC84pQaFPzF/VO3BrjkCeewuvJgt1jfV0499Z1QSAWN4EqfMM5WlFDDX9/oP8JjlDKpblrmEoyu4Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "license": "MIT", "dependencies": { - "component-emitter": "^1.3.0", + "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", - "debug": "^4.3.4", + "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", + "form-data": "^4.0.5", "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/superagent/node_modules/debug": { - "version": "4.3.4", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=14.18.0" } }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "license": "MIT", "bin": { "mime": "cli.js" @@ -11660,26 +12508,35 @@ "node": ">=4.0.0" } }, - "node_modules/superagent/node_modules/ms": { - "version": "2.1.2", - "license": "MIT" - }, "node_modules/supertest": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", - "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { + "cookie-signature": "^1.2.2", "methods": "^1.1.2", - "superagent": "^10.2.1" + "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" } }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -11707,7 +12564,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.18.2", + "version": "5.32.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz", + "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -11715,6 +12574,8 @@ }, "node_modules/swagger-ui-express": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", "license": "MIT", "dependencies": { "swagger-ui-dist": ">=5.0.0" @@ -11727,13 +12588,13 @@ } }, "node_modules/synckit": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", - "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.4" + "@pkgr/core": "^0.2.9" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -11743,17 +12604,28 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -11765,9 +12637,9 @@ } }, "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11810,44 +12682,63 @@ } }, "node_modules/test-exclude": { - "version": "7.0.1", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", - "minimatch": "^9.0.4" + "minimatch": "^10.2.2" }, "engines": { "node": ">=18" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.1", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11869,6 +12760,8 @@ }, "node_modules/text-hex": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, "node_modules/thenify": { @@ -11912,6 +12805,39 @@ "xtend": "~4.0.1" } }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/time-span": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", @@ -11929,14 +12855,67 @@ } }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11948,19 +12927,23 @@ }, "node_modules/toidentifier": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { "node": ">=0.6" } }, "node_modules/tr46": { - "version": "4.1.1", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", "dependencies": { - "punycode": "^2.3.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/traverse": { @@ -11978,6 +12961,8 @@ }, "node_modules/triple-beam": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -11985,6 +12970,8 @@ }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" }, @@ -12000,6 +12987,8 @@ }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -12020,9 +13009,9 @@ } }, "node_modules/type-fest": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", - "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -12046,25 +13035,20 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typedarray": { @@ -12074,9 +13058,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -12104,6 +13088,8 @@ }, "node_modules/uid-safe": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", "license": "MIT", "dependencies": { "random-bytes": "~1.0.0" @@ -12125,6 +13111,12 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", @@ -12136,9 +13128,9 @@ } }, "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, "license": "MIT", "engines": { @@ -12165,14 +13157,17 @@ } }, "node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "dev": true, "license": "ISC" }, "node_modules/universalify": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -12209,10 +13204,14 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -12232,13 +13231,15 @@ } }, "node_modules/v8-to-istanbul": { - "version": "9.1.0", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0" + "convert-source-map": "^2.0.0" }, "engines": { "node": ">=10.12.0" @@ -12257,31 +13258,46 @@ }, "node_modules/vary": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/whatwg-url": { - "version": "13.0.0", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", "dependencies": { - "tr46": "^4.1.1", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -12295,11 +13311,13 @@ } }, "node_modules/winston": { - "version": "3.17.0", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", @@ -12316,6 +13334,8 @@ }, "node_modules/winston-transport": { "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", "license": "MIT", "dependencies": { "logform": "^2.7.0", @@ -12326,39 +13346,19 @@ "node": ">= 12.0.0" } }, - "node_modules/winston-transport/node_modules/readable-stream": { - "version": "3.6.2", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/winston/node_modules/@colors/colors": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "license": "MIT", "engines": { "node": ">=0.1.90" } }, - "node_modules/winston/node_modules/readable-stream": { - "version": "3.6.0", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -12367,25 +13367,31 @@ }, "node_modules/wordwrap": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, "license": "MIT" }, "node_modules/workerpool": { - "version": "6.5.1", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "7.0.0", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -12394,6 +13400,8 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -12408,8 +13416,20 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -12424,6 +13444,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12435,45 +13457,73 @@ }, "node_modules/wrap-ansi-cjs/node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=7.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/xtend": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -12481,6 +13531,8 @@ }, "node_modules/y18n": { "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", "engines": { @@ -12489,27 +13541,33 @@ }, "node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/yargs": { - "version": "16.2.0", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^7.0.2", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "string-width": "^4.2.0", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-parser": { "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { @@ -12518,6 +13576,8 @@ }, "node_modules/yargs-unparser": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "license": "MIT", "dependencies": { @@ -12530,29 +13590,10 @@ "node": ">=10" } }, - "node_modules/yargs-unparser/node_modules/decamelize": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "20.2.9", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yauzl": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", - "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz", + "integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==", "dev": true, "license": "MIT", "dependencies": { @@ -12564,20 +13605,22 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "dev": true, "license": "MIT", "engines": { @@ -12586,6 +13629,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0819fc3a..da48cfd1 100644 --- a/package.json +++ b/package.json @@ -26,37 +26,43 @@ "prettier:fix": "npm run prettier -- --write", "format": "npm run prettier:fix && npm run lint:fix", "start": "node ./bin/www", - "test": "npm run test:openapi && npm run test:config && npm run test:api", - "test:api": "mocha --timeout 10000 --recursive ./app/tests/api", - "test:config": "mocha --timeout 10000 --recursive ./app/tests/config", - "test:import": "mocha --timeout 10000 --recursive ./app/tests/import", - "test:openapi": "mocha --timeout 10000 ./app/tests/openapi", + "test": "npm run test:openapi && npm run test:config && npm run test:api && npm run test:middleware", + "test:api": "mocha --timeout 20000 --recursive ./app/tests/api", + "test:config": "mocha --timeout 20000 --recursive ./app/tests/config", + "test:import": "mocha --timeout 20000 --recursive ./app/tests/import", + "test:openapi": "mocha --timeout 20000 ./app/tests/openapi", + "test:middleware": "mocha --timeout 20000 ./app/tests/middleware --exit", "test:authn": "./app/tests/run-mocha-separate-jobs.sh ./app/tests/authn", "test:fuzz": "mocha --timeout 10000 --recursive ./app/tests/fuzz", "test:scheduler": "mocha --timeout 60000 --recursive ./app/tests/scheduler", + "test:file": "mocha --timeout 10000", "check:lockfile": "bash scripts/check-package-lock.sh" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^13.0.1", + "@mitre-attack/attack-data-model": "^4.11.7", "async": "^3.2.6", "async-await-retry": "^2.1.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "compression": "^1.8.1", + "connect-mongo": "^6.0.0", "convict": "^6.2.4", "cors": "^2.8.5", "express": "^4.21.2", "express-openapi-validator": "^5.5.4", "express-session": "^1.18.2", "helmet": "^8.1.0", + "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", - "migrate-mongo": "^12.1.3", + "migrate-mongo": "^14.0.7", "mongoose": "^8.15.1", "morgan": "^1.10.1", "nanoid": "^5.1.5", "node-cache": "^5.1.2", + "node-schedule": "^2.1.1", "openid-client": "^5.7.1", "passport": "^0.7.0", "passport-anonym-uuid": "^1.0.3", @@ -66,7 +72,8 @@ "superagent": "^10.2.2", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", - "winston": "^3.17.0" + "winston": "^3.17.0", + "zod": "^4.3.6" }, "devDependencies": { "@codedependant/semantic-release-docker": "^5.1.1", diff --git a/template.env b/template.env index 15463ce9..bc5c8f75 100644 --- a/template.env +++ b/template.env @@ -1,5 +1,155 @@ -AUTHN_MECHANISM=anonymous -DATABASE_URL=mongodb://localhost/attack-workspace -JSON_CONFIG_PATH=/some/path/to/rest-api-service-config.json -PORT=8080 -LOG_LEVEL=debug \ No newline at end of file +# Attack Workbench REST API - Environment Configuration Template +# Guidance: +# - Booleans: use true or false +# - Lists: use comma-separated values + +# Server +# PORT (int) - HTTP server port +# Default: 3000 +#PORT=3000 + +# Database (REQUIRED) +# DATABASE_URL (string) - MongoDB connection string +# Example (Docker): +#DATABASE_URL=mongodb://attack-workbench-database/attack-workspace +# Example (local): +#DATABASE_URL=mongodb://localhost:27017/attack-workspace +DATABASE_URL= + +# CORS_ALLOWED_ORIGINS (domains) - Allowed origins for REST API +# Accepts: +# * : allow any origin +# disable : disable CORS +# Comma-separated list of origins (http/https), e.g.: +# http://localhost:3000,https://example.com,https://sub.domain.org:8443 +# Supports localhost, private IPv4 (10.x, 172.16-31.x, 192.168.x), and FQDNs. +# Default: * +#CORS_ALLOWED_ORIGINS=* + +# Application +# NODE_ENV (string) - Environment name +# Options: development, production, test +# Default: development +#NODE_ENV=development + +# WB_REST_DATABASE_MIGRATION_ENABLE (bool) - Auto-run DB migrations on startup +# Default: true +#WB_REST_DATABASE_MIGRATION_ENABLE=true + +# Logging +# LOG_LEVEL (string) - Console log level +# Options: error, warn, http, info, verbose, debug +# Default: info +#LOG_LEVEL=info + +# Workbench Collection Indexes +# DEFAULT_INTERVAL (int, seconds) - Default polling interval for new indexes +# Note: does not affect existing indexes +# Default: 300 +#DEFAULT_INTERVAL=300 + +# Configuration Files +# JSON_CONFIG_PATH (string) - Path to a JSON file with additional configuration. +# Use this to provide arrays for service accounts and OIDC clients +# Default: empty (disabled) +#JSON_CONFIG_PATH= + +# ALLOWED_VALUES_PATH (string) - Path to allowed values configuration file +# Default: ./app/config/allowed-values.json +#ALLOWED_VALUES_PATH=./app/config/allowed-values.json + +# WB_REST_STATIC_MARKING_DEFS_PATH (string) - Directory of static marking definition JSON files +# Default: ./app/lib/default-static-marking-definitions/ +#WB_REST_STATIC_MARKING_DEFS_PATH=./app/lib/default-static-marking-definitions/ + +# Scheduler +# ENABLE_SCHEDULER (bool) - Enable background scheduler +# Default: true +#ENABLE_SCHEDULER=true + +# CHECK_WORKBENCH_INTERVAL (int, seconds) - Scheduler start interval +# Default: 10 +#CHECK_WORKBENCH_INTERVAL=10 + +# Validation +# VALIDATE_WITH_ADM_SCHEMAS (bool) - Validate POST/PUT bodies against the ATT&CK Data Model +# When true, requests that fail ADM validation are rejected before persistence. +# Default: false +#VALIDATE_WITH_ADM_SCHEMAS=false + +# VALIDATE_OBJECTS_CRON (string) - Cron pattern for the scheduled re-validation task +# Re-validates every SDO/SRO against the current ADM and refreshes workspace.validation. +# Standard 5-field cron syntax (minute hour day-of-month month day-of-week). +# Skipped entirely when ENABLE_SCHEDULER=false. +# Default: 0 3 * * * (daily at 3:00 AM) +#VALIDATE_OBJECTS_CRON=0 3 * * * + +# ADM_LOG_LEVEL (enum) - Verbosity of the @mitre-attack/attack-data-model library's logger +# Read by the ADM library directly; independent of LOG_LEVEL and not configurable via JSON. +# Options: debug, info, warn, error, silent (inclusive — info enables info+warn+error) +# Set to error or silent to suppress per-relationship deprecation warnings during scheduler runs. +# Default: warn +#ADM_LOG_LEVEL=warn + +# Session +# SESSION_SECRET (string) - Secret to sign session cookies. +# Default: securely generated at startup (changes on restart; not recommended for production). +# Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" +#SESSION_SECRET= + +# MONGOSTORE_CRYPTO_SECRET (string) - Secret to encrypt session data in MongoDB. +# Default: securely generated at startup (changes on restart; not recommended for production). +# Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" +#MONGOSTORE_CRYPTO_SECRET= + +# User Authentication +# AUTHN_MECHANISM (enum) - User login mechanism +# Options: anonymous, oidc +# Default: anonymous +#AUTHN_MECHANISM=anonymous + +# OIDC settings (required if AUTHN_MECHANISM=oidc) +# AUTHN_OIDC_ISSUER_URL (string) - OIDC issuer URL (e.g., https://idp.example.com) +# Default: empty +#AUTHN_OIDC_ISSUER_URL= +# AUTHN_OIDC_CLIENT_ID (string) - OIDC client ID +# Default: empty +#AUTHN_OIDC_CLIENT_ID= +# AUTHN_OIDC_CLIENT_SECRET (string) - OIDC client secret +# Default: empty +#AUTHN_OIDC_CLIENT_SECRET= +# AUTHN_OIDC_REDIRECT_ORIGIN (string) - Origin used to build redirect URI +# Example: http://localhost:3000 -> http://localhost:3000/authn/oidc/callback +# Default: http://localhost:3000 +#AUTHN_OIDC_REDIRECT_ORIGIN=http://localhost:3000 + +# Service Authentication +# OIDC Client Credentials (service-to-service) +# SERVICE_ACCOUNT_OIDC_ENABLE (bool) - Enable client credentials flow +# Default: false +#SERVICE_ACCOUNT_OIDC_ENABLE=false +# JWKS_URI (string) - JWKS endpoint for IdP public keys (required if enabled) +# Default: empty +#JWKS_URI= + +# Challenge API Key (token exchange) +# WB_REST_SERVICE_ACCOUNT_CHALLENGE_APIKEY_ENABLE (bool) - Enable challenge flow +# Default: false +#WB_REST_SERVICE_ACCOUNT_CHALLENGE_APIKEY_ENABLE=false +# WB_REST_TOKEN_SIGNING_SECRET (string) - Token signing secret +# Default: securely generated at startup (changes on restart; set for production) +#WB_REST_TOKEN_SIGNING_SECRET= +# WB_REST_TOKEN_TIMEOUT (int, seconds) - Access token lifetime +# Default: 300 +#WB_REST_TOKEN_TIMEOUT=300 + +# Basic API Key (no challenge) +# WB_REST_SERVICE_ACCOUNT_BASIC_APIKEY_ENABLE (bool) - Enable basic apikey auth +# Default: false +#WB_REST_SERVICE_ACCOUNT_BASIC_APIKEY_ENABLE=false + +# TLS/Certificates +# NODE_EXTRA_CA_CERTS (string) - Path to additional CA certs in PEM format +# Useful when MongoDB or IdP uses a private CA +# Default: empty +#NODE_EXTRA_CA_CERTS=