Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/schemas/common/property-schemas/generics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,48 @@ export const stixListOfString = z.array(nonEmptyRequiredString).min(1, {
error:
'Empty lists are prohibited in STIX and MUST NOT be used as a substitute for omitting the property if it is optional. The list MUST be present and MUST have at least one value.',
});

/**
* A utility function that creates a Zod array schema with a uniqueness constraint.
*
* This function wraps any Zod schema in an array validator that ensures all items
* are unique (no duplicate values). Uniqueness is determined by JavaScript's Set
* equality, which uses SameValueZero comparison.
*
* @param schema - The Zod schema type for individual array elements
* @returns A Zod array schema that validates element uniqueness
*
* @example
* ```typescript
* const uniqueStrings = uniqueArray(z.string());
* uniqueStrings.parse(["a", "b", "c"]); // ["a", "b", "c"]
* uniqueStrings.parse(["a", "b", "a"]); // throws error
*
* const uniqueNumbers = uniqueArray(z.number());
* uniqueNumbers.parse([1, 2, 3]); // [1, 2, 3]
* uniqueNumbers.parse([1, 2, 2]); // throws error
* ```
*/
export function uniqueArray<T extends z.ZodType>(schema: T) {
return z.array(schema).check((ctx) => {
const seen = new Map<z.infer<T>, number>();
const duplicates: z.infer<T>[] = [];

ctx.value.forEach((item, index) => {
if (seen.has(item)) {
duplicates.push(item);
} else {
seen.set(item, index);
}
});

if (duplicates.length > 0) {
ctx.issues.push({
code: 'custom',
message: `Duplicate values found: ${duplicates.map((d) => JSON.stringify(d)).join(', ')}`,
// path: [], // not sure how to dynamically determine key name
input: ctx.value,
});
}
});
}
15 changes: 7 additions & 8 deletions src/schemas/common/property-schemas/stix-attribution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod/v4';
import { uniqueArray } from './generics.js';
import { createStixIdValidator, stixIdentifierSchema } from './stix-id.js';

/**
Expand All @@ -15,14 +16,12 @@ import { createStixIdValidator, stixIdentifierSchema } from './stix-id.js';
* objectMarkingRefsSchema.parse(['identity--12345']); // Invalid - must be marking-definition
* ```
*/
export const objectMarkingRefsSchema = z
.array(
stixIdentifierSchema.startsWith(
'marking-definition--',
'Identifier must start with "marking-definition--"',
),
)
.meta({ description: 'The list of marking-definition objects to be applied to this object.' });
export const objectMarkingRefsSchema = uniqueArray(
stixIdentifierSchema.startsWith(
'marking-definition--',
'Identifier must start with "marking-definition--"',
),
).meta({ description: 'The list of marking-definition objects to be applied to this object.' });

// TODO add JSDoc
export type ObjectMarkingRefs = z.infer<typeof objectMarkingRefsSchema>;
Expand Down
7 changes: 4 additions & 3 deletions src/schemas/sdo/detection-strategy.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createAttackExternalReferencesSchema,
createStixIdValidator,
createStixTypeValidator,
uniqueArray,
xMitreContributorsSchema,
xMitreDomainsSchema,
xMitreModifiedByRefSchema,
Expand All @@ -27,9 +28,9 @@ export const detectionStrategySchema = attackBaseDomainObjectSchema

x_mitre_contributors: xMitreContributorsSchema,

x_mitre_analytic_refs: z
.array(createStixIdValidator('x-mitre-analytic'))
.min(1, { error: 'At least one analytic ref is required' }),
x_mitre_analytic_refs: uniqueArray(createStixIdValidator('x-mitre-analytic')).min(1, {
error: 'At least one analytic ref is required',
}),

x_mitre_domains: xMitreDomainsSchema,
})
Expand Down
4 changes: 2 additions & 2 deletions src/schemas/sdo/matrix.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createStixIdValidator,
createStixTypeValidator,
descriptionSchema,
uniqueArray,
xMitreDomainsSchema,
xMitreModifiedByRefSchema,
} from '../common/property-schemas/index.js';
Expand All @@ -15,8 +16,7 @@ import {
//
//==============================================================================

export const xMitreTacticRefsSchema = z
.array(createStixIdValidator('x-mitre-tactic'))
export const xMitreTacticRefsSchema = uniqueArray(createStixIdValidator('x-mitre-tactic'))
.min(1, { error: 'At least one tactic ref is required' })
.meta({
description:
Expand Down
4 changes: 2 additions & 2 deletions test/objects/detection-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ describe('detectionStrategySchema', () => {
...minimalDetectionStrategy,
x_mitre_analytic_refs: [analyticId, analyticId, analyticId],
};
// Schema doesn't prevent duplicates, so this should pass
expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).not.toThrow();
// Schema prevents duplicates, so this should throw
expect(() => detectionStrategySchema.parse(detectionStrategyWithDuplicates)).toThrow();
});

it('should handle large number of analytics', () => {
Expand Down
6 changes: 5 additions & 1 deletion test/utils/attack-data.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';

describe('Global Tests', () => {
describe('ATT&CK Testing Data', () => {
Expand Down Expand Up @@ -43,6 +43,8 @@ describe('Global Tests', () => {
'x-mitre-matrix',
'x-mitre-tactic',
'x-mitre-asset',
'x-mitre-analytic',
'x-mitre-detection-strategy'
];

const presentTypes = Object.keys(globalThis.attackData.objectsByType);
Expand Down Expand Up @@ -129,6 +131,8 @@ describe('Global Tests', () => {
'x-mitre-data-source',
'x-mitre-data-component',
'x-mitre-asset',
'x-mitre-analytic',
'x-mitre-detection-strategy'
];
const sdoCount = sdoTypes.reduce(
(sum, type) => sum + (globalThis.attackData.objectsByType[type]?.length || 0),
Expand Down