|
5 | 5 | attackIdPatterns, |
6 | 6 | type Aliases, |
7 | 7 | type AttackObject, |
| 8 | + type Collection, |
8 | 9 | type ExternalReferences, |
9 | 10 | type KillChainPhase, |
10 | 11 | type StixBundle, |
@@ -190,6 +191,167 @@ export function createFirstBundleObjectRefinement() { |
190 | 191 | }; |
191 | 192 | } |
192 | 193 |
|
| 194 | +/** |
| 195 | + * Creates a refinement function for validating that objects in an array have no duplicates |
| 196 | + * based on specified keys |
| 197 | + * |
| 198 | + * @param arrayPath - The path to the array property in the context value (e.g., ['objects']). Use [] for direct array validation. |
| 199 | + * @param keys - The keys to use for duplicate detection (e.g., ['id'] or ['source_name', 'external_id']). Use [] for primitive arrays. |
| 200 | + * @param errorMessage - Optional custom error message template. Use {keys} for key values, {value} for primitives, and {index} for position |
| 201 | + * @returns A refinement function for duplicate validation |
| 202 | + * |
| 203 | + * @remarks |
| 204 | + * This function validates that objects in an array are unique based on one or more key fields. |
| 205 | + * It creates a composite key from the specified fields and checks for duplicates. |
| 206 | + * |
| 207 | + * **Supports three validation modes:** |
| 208 | + * 1. Object arrays with single key: `keys = ['id']` |
| 209 | + * 2. Object arrays with composite keys: `keys = ['source_name', 'external_id']` |
| 210 | + * 3. Primitive arrays: `keys = []` (validates the values themselves) |
| 211 | + * |
| 212 | + * @example |
| 213 | + * ```typescript |
| 214 | + * // Single key validation |
| 215 | + * const validateUniqueIds = validateNoDuplicates(['objects'], ['id']); |
| 216 | + * const schema = baseSchema.check(validateUniqueIds); |
| 217 | + * |
| 218 | + * // Composite key validation |
| 219 | + * const validateUniqueRefs = validateNoDuplicates( |
| 220 | + * ['external_references'], |
| 221 | + * ['source_name', 'external_id'], |
| 222 | + * 'Duplicate reference found with source_name="{source_name}" and external_id="{external_id}"' |
| 223 | + * ); |
| 224 | + * |
| 225 | + * // Primitive array validation (e.g., array of strings) |
| 226 | + * const validateUniqueStrings = validateNoDuplicates( |
| 227 | + * [], |
| 228 | + * [], |
| 229 | + * 'Duplicate value "{value}" found' |
| 230 | + * ); |
| 231 | + * ``` |
| 232 | + */ |
| 233 | +export function validateNoDuplicates(arrayPath: string[], keys: string[], errorMessage?: string) { |
| 234 | + return (ctx: z.core.ParsePayload<unknown>): void => { |
| 235 | + // Navigate to the array using the path |
| 236 | + let arr: unknown = ctx.value; |
| 237 | + for (const pathSegment of arrayPath) { |
| 238 | + if (arr && typeof arr === 'object') { |
| 239 | + arr = (arr as Record<string, unknown>)[pathSegment]; |
| 240 | + } else { |
| 241 | + return; |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + // If array doesn't exist or is not an array, skip validation |
| 246 | + if (!Array.isArray(arr)) { |
| 247 | + return; |
| 248 | + } |
| 249 | + |
| 250 | + const seen = new Map<string, number>(); |
| 251 | + |
| 252 | + arr.forEach((item, index) => { |
| 253 | + // Create composite key from specified keys |
| 254 | + // If keys array is empty, treat each item as a primitive value |
| 255 | + const keyValues = |
| 256 | + keys.length === 0 |
| 257 | + ? [String(item)] |
| 258 | + : keys.map((key) => { |
| 259 | + const value = item?.[key]; |
| 260 | + return value !== undefined ? String(value) : ''; |
| 261 | + }); |
| 262 | + const compositeKey = keyValues.join('||'); |
| 263 | + |
| 264 | + if (seen.has(compositeKey)) { |
| 265 | + // Build key-value pairs for error message |
| 266 | + const keyValuePairs = keys.reduce( |
| 267 | + (acc, key, i) => { |
| 268 | + acc[key] = keyValues[i]; |
| 269 | + return acc; |
| 270 | + }, |
| 271 | + {} as Record<string, string>, |
| 272 | + ); |
| 273 | + |
| 274 | + // Generate error message |
| 275 | + let message = errorMessage; |
| 276 | + if (!message) { |
| 277 | + if (keys.length === 0) { |
| 278 | + // Primitive array (no keys) |
| 279 | + message = `Duplicate value "${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; |
| 280 | + } else if (keys.length === 1) { |
| 281 | + message = `Duplicate object with ${keys[0]}="${keyValues[0]}" found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; |
| 282 | + } else { |
| 283 | + const keyPairs = keys.map((key, i) => `${key}="${keyValues[i]}"`).join(', '); |
| 284 | + message = `Duplicate object with ${keyPairs} found at index ${index}. Previously seen at index ${seen.get(compositeKey)}.`; |
| 285 | + } |
| 286 | + } else { |
| 287 | + // Replace placeholders in custom message |
| 288 | + message = message.replace(/\{(\w+)\}/g, (match, key) => { |
| 289 | + if (key === 'index') return String(index); |
| 290 | + if (key === 'value' && keys.length === 0) return keyValues[0]; |
| 291 | + return keyValuePairs[key] ?? match; |
| 292 | + }); |
| 293 | + } |
| 294 | + |
| 295 | + ctx.issues.push({ |
| 296 | + code: 'custom', |
| 297 | + message, |
| 298 | + path: keys.length === 0 ? [...arrayPath, index] : [...arrayPath, index, ...keys], |
| 299 | + input: keys.length === 0 ? item : keys.length === 1 ? item?.[keys[0]] : keyValuePairs, |
| 300 | + }); |
| 301 | + } else { |
| 302 | + seen.set(compositeKey, index); |
| 303 | + } |
| 304 | + }); |
| 305 | + }; |
| 306 | +} |
| 307 | + |
| 308 | +/** |
| 309 | + * Creates a refinement function for validating that all STIX IDs referenced in x_mitre_contents |
| 310 | + * exist in the bundle's objects array |
| 311 | + * |
| 312 | + * @returns A refinement function for x_mitre_contents reference validation |
| 313 | + * |
| 314 | + * @remarks |
| 315 | + * This function validates that every STIX ID referenced in the collection's x_mitre_contents |
| 316 | + * property (which acts as a table of contents for the bundle) has a corresponding object |
| 317 | + * in the bundle's objects array. This ensures referential integrity within the bundle. |
| 318 | + * |
| 319 | + * The function expects: |
| 320 | + * - The first object in the bundle to be a Collection (x-mitre-collection type) |
| 321 | + * - Each object_ref in x_mitre_contents to match an id in the objects array |
| 322 | + * |
| 323 | + * @example |
| 324 | + * ```typescript |
| 325 | + * const schema = stixBundleSchema.check(validateXMitreContentsReferences()); |
| 326 | + * ``` |
| 327 | + */ |
| 328 | +export function validateXMitreContentsReferences() { |
| 329 | + return (ctx: z.core.ParsePayload<StixBundle>): void => { |
| 330 | + // Get the collection object (first object in bundle) |
| 331 | + const collectionObject = ctx.value.objects[0]; |
| 332 | + const collectionContents = (collectionObject as Collection).x_mitre_contents; |
| 333 | + |
| 334 | + if (!collectionContents) { |
| 335 | + return; |
| 336 | + } |
| 337 | + |
| 338 | + // Create a set of all object IDs in the bundle for efficient lookup |
| 339 | + const objectIds = new Set(ctx.value.objects.map((obj) => (obj as AttackObject).id)); |
| 340 | + |
| 341 | + // Validate each reference in x_mitre_contents |
| 342 | + collectionContents.forEach((contentRef: { object_ref: string }, index: number) => { |
| 343 | + if (!objectIds.has(contentRef.object_ref)) { |
| 344 | + ctx.issues.push({ |
| 345 | + code: 'custom', |
| 346 | + message: `STIX ID "${contentRef.object_ref}" referenced in x_mitre_contents is not present in the bundle's objects array`, |
| 347 | + path: ['objects', 0, 'x_mitre_contents', index, 'object_ref'], |
| 348 | + input: contentRef.object_ref, |
| 349 | + }); |
| 350 | + } |
| 351 | + }); |
| 352 | + }; |
| 353 | +} |
| 354 | + |
193 | 355 | /** |
194 | 356 | * Creates a refinement function for validating ATT&CK ID in external references |
195 | 357 | * |
|
0 commit comments