Skip to content

Commit 316c040

Browse files
authored
Merge pull request #450 from center-for-threat-informed-defense/update-zod
Update zod
2 parents 797f0c3 + f091c85 commit 316c040

21 files changed

+258
-265
lines changed

app/lib/validation-middleware.js

Lines changed: 41 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ const { z, ZodError } = require('zod');
44
const { StatusCodes } = require('http-status-codes');
55
const logger = require('../lib/logger');
66
const { processValidationIssues } = require('../services/system/validate-service');
7+
const { getSchema } = require('../services/system/validate-service');
78
const {
89
createAttackIdSchema,
910
stixTypeToAttackIdMapping,
1011
} = require('@mitre-attack/attack-data-model/dist/schemas/common/property-schemas/attack-id');
11-
12+
const { BadRequestError } = require('../exceptions');
1213
/**
1314
* Basic workspace schema (without rigid attack ID validation)
1415
* @type {z.ZodObject}
@@ -57,111 +58,15 @@ function createWorkspaceSchema(stixType) {
5758
return workspaceSchema;
5859
}
5960

60-
function extractStringLiteralFromStixTypeZodSchema(zodSchema) {
61-
// Method 1: Direct shape access (works for most schemas)
62-
if (zodSchema.shape?.type?.def?.values?.[0]) {
63-
return zodSchema.shape.type.def.values[0];
64-
}
65-
// Method 2: Through _zod.def.in.def (works for schemas with .transform())
66-
else if (zodSchema._zod?.def?.in?.def?.shape?.type?.def?.values?.[0]) {
67-
return zodSchema._zod.def.in.def.shape.type.def.values[0];
68-
}
69-
// Method 3: Works for schemas that support multiple types, i.e., softwareSchema -> [tool, malware]
70-
else if (zodSchema.shape?.type.def.options) {
71-
const stixTypes = [];
72-
for (const opt of zodSchema.shape.type.def.options) {
73-
stixTypes.push(opt.def.values[0]);
74-
}
75-
return stixTypes;
76-
} else {
77-
throw new Error('Could not extract STIX type from schema');
78-
}
79-
}
80-
81-
/**
82-
* Factory function that creates a combined workspace+STIX schema with conditional partial validation
83-
* @param {z.ZodObject} stixSchema - The STIX object schema to validate against
84-
* @param {string} workflowState - The workflow state to determine validation strictness
85-
* @param {string[]} omitStixFields - Array of STIX field names to omit from validation
86-
* @returns {z.ZodObject} Combined schema with workspace and conditional stix validation
87-
*/
8861
/**
89-
* Factory function that creates a combined workspace+STIX schema with conditional partial validation
90-
* @param {z.ZodObject} stixSchema - The STIX object schema to validate against
91-
* @param {string} workflowState - The workflow state to determine validation strictness
92-
* @param {string[]} omitStixFields - Array of STIX field names to omit from validation
93-
* @returns {z.ZodObject} Combined schema with workspace and conditional stix validation
94-
*/
95-
function createWorkspaceStixSchema(
96-
stixSchema,
97-
workflowState,
98-
omitStixFields = ['x_mitre_attack_spec_version', 'external_references'],
99-
) {
100-
logger.debug('Creating combined workspace+STIX schema:', { workflowState, omitStixFields });
101-
102-
try {
103-
// Extract the STIX type from the schema with fallback for transformed schemas
104-
const stixTypeStringLiteral = extractStringLiteralFromStixTypeZodSchema(stixSchema);
105-
106-
logger.debug('Extracted STIX type from schema:', { stixTypeStringLiteral });
107-
108-
// Apply partial validation for work-in-progress, full validation otherwise
109-
const usePartialValidation = workflowState === 'work-in-progress';
110-
logger.debug('Validation mode:', { workflowState, usePartialValidation });
111-
112-
let stixValidationSchema = usePartialValidation ? stixSchema.partial() : stixSchema;
113-
114-
// Build omit object from array of field names
115-
if (omitStixFields.length > 0) {
116-
const omitObject = omitStixFields.reduce((acc, field) => {
117-
acc[field] = true;
118-
return acc;
119-
}, {});
120-
stixValidationSchema = stixValidationSchema.omit(omitObject);
121-
}
122-
123-
const combinedSchema = z.object({
124-
workspace: createWorkspaceSchema(stixTypeStringLiteral),
125-
stix: stixValidationSchema,
126-
});
127-
128-
logger.debug('Successfully created combined schema');
129-
return combinedSchema;
130-
} catch (error) {
131-
logger.warn('Could not extract STIX type from schema, using basic validation:', {
132-
error: error.message,
133-
workflowState,
134-
omitStixFields,
135-
});
136-
137-
let stixValidationSchema =
138-
workflowState === 'work-in-progress' ? stixSchema.partial() : stixSchema;
139-
140-
// Apply omit in error case as well
141-
if (omitStixFields.length > 0) {
142-
const omitObject = omitStixFields.reduce((acc, field) => {
143-
acc[field] = true;
144-
return acc;
145-
}, {});
146-
stixValidationSchema = stixValidationSchema.omit(omitObject);
147-
}
148-
149-
return z.object({
150-
workspace: workspaceSchema,
151-
stix: stixValidationSchema,
152-
});
153-
}
154-
}
155-
156-
/**
157-
* Middleware for parsing the request body using a specified STIX schema from the ATT&CK Data Model.
158-
* Both the `workspace` and `stix` keys are checked.
159-
* @param {z.ZodObject|z.ZodObject[]} oneOrMoreZodSchemas - Single schema or array of schemas to validate against
62+
* Middleware for validating the request body against a pre-composed STIX schema.
63+
* Wraps the STIX schema with a workspace schema and parses the request body.
64+
* @param {z.ZodObject} stixSchema - Pre-composed STIX schema (with omit/partial/checks already applied)
16065
* @param {Object} options - Configuration options
16166
* @param {boolean} options.enabled - Whether validation is enabled (defaults to true)
16267
* @returns {Function} Express middleware function
16368
*/
164-
function middleware(oneOrMoreZodSchemas, options = {}) {
69+
function middleware(stixSchema, options = {}) {
16570
const { enabled = true } = options;
16671

16772
return (req, res, next) => {
@@ -181,59 +86,13 @@ function middleware(oneOrMoreZodSchemas, options = {}) {
18186
});
18287

18388
try {
184-
// Extract workflow state from request body
185-
const workflowState = req.body?.workspace?.workflow?.state || 'reviewed'; // Default to strict validation
186-
logger.debug('Determined workflow state:', {
187-
workflowState,
188-
isDefault: !req.body?.workspace?.workflow?.state,
189-
});
190-
191-
// Determine which schema to use based on request STIX type
192-
const requestStixType = req.body?.stix?.type;
193-
logger.debug('Request STIX type:', { requestStixType });
194-
195-
let finalSchema;
196-
197-
// Handle array of schemas - find the one that matches the request type
198-
if (Array.isArray(oneOrMoreZodSchemas)) {
199-
logger.debug('Multiple schemas provided, finding matching schema for request type');
200-
201-
for (const schema of oneOrMoreZodSchemas) {
202-
try {
203-
const schemaStixType = extractStringLiteralFromStixTypeZodSchema(schema);
204-
logger.debug('Checking schema with type:', { schemaStixType });
205-
206-
// Check if this schema matches the request type
207-
if (
208-
(typeof schemaStixType === 'string' && schemaStixType === requestStixType) ||
209-
(Array.isArray(schemaStixType) && schemaStixType.includes(requestStixType))
210-
) {
211-
logger.debug('Found matching schema for request type:', {
212-
requestStixType,
213-
schemaStixType,
214-
});
215-
finalSchema = schema;
216-
break;
217-
}
218-
} catch (error) {
219-
logger.debug('Could not extract type from schema, skipping:', { error: error.message });
220-
continue;
221-
}
222-
}
89+
const stixType = req.body?.stix?.type;
22390

224-
if (!finalSchema) {
225-
throw new Error(
226-
`No matching schema found for STIX type: ${requestStixType}. Available schemas: ${oneOrMoreZodSchemas.length}`,
227-
);
228-
}
229-
} else {
230-
// Single schema - use it directly
231-
logger.debug('Single schema provided, using directly');
232-
finalSchema = oneOrMoreZodSchemas;
233-
}
234-
235-
// Create schema with conditional validation based on workflow state
236-
const combinedSchema = createWorkspaceStixSchema(finalSchema, workflowState);
91+
// Wrap the pre-composed STIX schema with the workspace schema
92+
const combinedSchema = z.object({
93+
workspace: createWorkspaceSchema(stixType),
94+
stix: stixSchema,
95+
});
23796

23897
logger.debug('Attempting to parse request body with combined schema');
23998
combinedSchema.parse(req.body);
@@ -303,22 +162,47 @@ function middleware(oneOrMoreZodSchemas, options = {}) {
303162
/**
304163
* Pre-configured validation middleware factory that uses runtime configuration.
305164
* The middleware reads the config value at request time to support dynamic config changes (e.g., during tests).
165+
*
166+
* @param {string|string[]} expectedStixType - The STIX type(s) this endpoint accepts
167+
* (e.g. "attack-pattern" or ["tool", "malware"] for software)
168+
* @returns {Function} Express middleware function
306169
*/
307-
function validateWorkspaceStixData(oneOrMoreZodSchemas) {
170+
function validateWorkspaceStixData(expectedStixType) {
171+
const allowedTypes = Array.isArray(expectedStixType) ? expectedStixType : [expectedStixType];
172+
308173
return (req, res, next) => {
309174
// Read config at request time to allow dynamic changes
310175
const config = require('../config/config');
311176
const enabled = config.validateRequests.withAttackDataModel;
312-
const middlewareFn = middleware(oneOrMoreZodSchemas, { enabled });
177+
const requestStixType = req.body?.stix?.type;
178+
const workflowState = req.body?.workspace?.workflow?.state || 'reviewed';
179+
180+
// Verify the request's STIX type is one this endpoint accepts
181+
if (!allowedTypes.includes(requestStixType)) {
182+
return next(
183+
new BadRequestError(
184+
`Unexpected STIX type "${requestStixType}". This endpoint accepts: ${allowedTypes.join(', ')}`,
185+
),
186+
);
187+
}
188+
189+
const finalSchema = getSchema(requestStixType, workflowState);
190+
if (!finalSchema) {
191+
return next(
192+
new BadRequestError(
193+
`No schema found for STIX type "${requestStixType}". Request body is probably invalid.`,
194+
),
195+
);
196+
}
197+
198+
const middlewareFn = middleware(finalSchema, { enabled });
313199
return middlewareFn(req, res, next);
314200
};
315201
}
316202

317203
module.exports = {
318204
/** Express middleware factory for workspace+STIX validation */
319205
validateWorkspaceStixData,
320-
/** Factory function for creating combined workspace+STIX schemas */
321-
createWorkspaceStixSchema,
322206
/** Basic workspace schema without dynamic attackId validation */
323207
workspaceSchema,
324208
};

app/lib/validation-schemas.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use strict';
2+
3+
const {
4+
tacticSchema,
5+
6+
/** techniques */
7+
techniqueBaseSchema,
8+
9+
/** groups */
10+
groupBaseSchema,
11+
12+
/** malware */
13+
malwareBaseSchema,
14+
15+
/** tools */
16+
toolBaseSchema,
17+
18+
/** campaigns */
19+
campaignBaseSchema,
20+
21+
/** relationships */
22+
relationshipBaseSchema,
23+
24+
/** simple schemas (no checks/refinements) */
25+
identitySchema,
26+
mitigationSchema,
27+
assetSchema,
28+
dataSourceSchema,
29+
dataComponentSchema,
30+
detectionStrategySchema,
31+
analyticSchema,
32+
matrixSchema,
33+
collectionSchema,
34+
markingDefinitionSchema,
35+
} = require('@mitre-attack/attack-data-model/dist');
36+
37+
// The ADM exports bundles of refinements (checks) for any schemas which support partial schema derivatives.
38+
// e.g., The technique.schema module exports: (1) techniqueBaseSchema, (2) techniquePartialSchema, (3) techniqueChecks
39+
//
40+
// This enables users to easily compose custom schemas w/o running into the Zod restriction introduced in v4.3.6 where
41+
// `.omit`, `.pick`, and `.partial` throw when `.check` is chained on.
42+
// (Details: https://github.com/mitre-attack/attack-data-model/pull/65)
43+
//
44+
// In Workbench, this is specifically necessary because the ADM validation middleware needs to omit checking fields
45+
// which only the backend sets, e.g., `x_mitre_attack_spec_version` is set by the backend, therefore it's never passed/set in
46+
// the req.body of POST/create requests, therefore we need to avoid scrutinizing that field in the ADM validation middleware.
47+
//
48+
// Composition order (for schemas with checks):
49+
// base schema → .omit() → .partial() (if WIP) → .check(checks)
50+
// This ensures .omit() and .partial() are called BEFORE .check(), avoiding the Zod restriction.
51+
52+
const {
53+
techniqueChecks,
54+
} = require('@mitre-attack/attack-data-model/dist/schemas/sdo/technique.schema');
55+
const { groupChecks } = require('@mitre-attack/attack-data-model/dist/schemas/sdo/group.schema');
56+
const {
57+
campaignChecks,
58+
} = require('@mitre-attack/attack-data-model/dist/schemas/sdo/campaign.schema');
59+
const {
60+
relationshipChecks,
61+
} = require('@mitre-attack/attack-data-model/dist/schemas/sro/relationship.schema');
62+
const {
63+
malwareChecks,
64+
} = require('@mitre-attack/attack-data-model/dist/schemas/sdo/malware.schema');
65+
const { toolChecks } = require('@mitre-attack/attack-data-model/dist/schemas/sdo/tool.schema');
66+
67+
const STIX_SCHEMAS = {
68+
'x-mitre-tactic': tacticSchema,
69+
'attack-pattern': {
70+
base: techniqueBaseSchema,
71+
checks: techniqueChecks,
72+
},
73+
'intrusion-set': {
74+
base: groupBaseSchema,
75+
checks: groupChecks,
76+
},
77+
malware: {
78+
base: malwareBaseSchema,
79+
checks: malwareChecks,
80+
},
81+
tool: {
82+
base: toolBaseSchema,
83+
checks: toolChecks,
84+
},
85+
campaign: {
86+
base: campaignBaseSchema,
87+
checks: campaignChecks,
88+
},
89+
relationship: {
90+
base: relationshipBaseSchema,
91+
checks: relationshipChecks,
92+
},
93+
identity: identitySchema,
94+
'course-of-action': mitigationSchema,
95+
'marking-definition': markingDefinitionSchema,
96+
'x-mitre-asset': assetSchema,
97+
'x-mitre-data-source': dataSourceSchema,
98+
'x-mitre-data-component': dataComponentSchema,
99+
'x-mitre-detection-strategy': detectionStrategySchema,
100+
'x-mitre-analytic': analyticSchema,
101+
'x-mitre-matrix': matrixSchema,
102+
'x-mitre-collection': collectionSchema,
103+
};
104+
105+
module.exports = {
106+
STIX_SCHEMAS,
107+
};

app/routes/analytics-routes.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
const express = require('express');
44

5-
const { analyticSchema } = require('@mitre-attack/attack-data-model');
6-
75
const analyticsController = require('../controllers/analytics-controller');
86
const authn = require('../lib/authn-middleware');
97
const authz = require('../lib/authz-middleware');
@@ -21,7 +19,7 @@ router
2119
.post(
2220
authn.authenticate,
2321
authz.requireRole(authz.editorOrHigher),
24-
validateWorkspaceStixData(analyticSchema),
22+
validateWorkspaceStixData('x-mitre-analytic'),
2523
analyticsController.create,
2624
);
2725

@@ -44,7 +42,7 @@ router
4442
.put(
4543
authn.authenticate,
4644
authz.requireRole(authz.editorOrHigher),
47-
validateWorkspaceStixData(analyticSchema),
45+
validateWorkspaceStixData('x-mitre-analytic'),
4846
analyticsController.updateFull,
4947
)
5048
.delete(

0 commit comments

Comments
 (0)