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
34 changes: 34 additions & 0 deletions src/refinements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,40 @@ export function createFirstBundleObjectRefinement() {
};
}

/**
* Creates a refinement function for validating that all objects in a STIX bundle have unique IDs
*
* @returns A refinement function for unique object ID validation
*
* @remarks
* This function validates that each object in the bundle's 'objects' array has a unique 'id' property.
* Duplicate IDs violate STIX specifications and can cause data integrity issues.
*
* @example
* ```typescript
* const validateUniqueObjects = createUniqueObjectsOnlyRefinement();
* const schema = stixBundleSchema.check(validateUniqueObjects);
* ```
*/
export function createUniqueObjectsOnlyRefinement() {
return (ctx: z.core.ParsePayload<StixBundle>): void => {
const seen = new Set<string>();
ctx.value.objects.forEach((item, index) => {
const id = (item as AttackObject).id;
if (seen.has(id)) {
ctx.issues.push({
code: 'custom',
message: `Duplicate object with id "${id}" found. Each object in the bundle must have a unique id.`,
path: ['objects', index, 'id'],
input: id,
});
} else {
seen.add(id);
}
});
};
}

/**
* Creates a refinement function for validating ATT&CK ID in external references
*
Expand Down
6 changes: 5 additions & 1 deletion src/schemas/sdo/stix-bundle.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { z } from 'zod/v4';
import { createFirstBundleObjectRefinement } from '../../refinements/index.js';
import {
createFirstBundleObjectRefinement,
createUniqueObjectsOnlyRefinement,
} from '../../refinements/index.js';
import {
createStixIdValidator,
createStixTypeValidator,
Expand Down Expand Up @@ -189,6 +192,7 @@ export const stixBundleSchema = z
.strict()
.check((ctx) => {
createFirstBundleObjectRefinement()(ctx);
createUniqueObjectsOnlyRefinement()(ctx);
});

export type StixBundle = z.infer<typeof stixBundleSchema>;
257 changes: 257 additions & 0 deletions test/objects/stix-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,263 @@ describe('StixBundleSchema', () => {

expect(() => stixBundleSchema.parse(invalidFirstObjectBundle)).toThrow();
});

describe('Uniqueness Constraint', () => {
it('should accept bundle with unique object IDs (true positive)', () => {
const technique1: Technique = {
id: `attack-pattern--${uuidv4()}`,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 1',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1001',
},
],
};

const technique2: Technique = {
id: `attack-pattern--${uuidv4()}`,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 2',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1002',
},
],
};

const bundleWithUniqueObjects = {
...minimalBundle,
objects: [minimalCollection, technique1, technique2],
};

expect(() => stixBundleSchema.parse(bundleWithUniqueObjects)).not.toThrow();
});

it('should reject bundle with duplicate object IDs (true negative)', () => {
const duplicateId = `attack-pattern--${uuidv4()}`;

const technique1: Technique = {
id: duplicateId,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 1',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1001',
},
],
};

const technique2: Technique = {
id: duplicateId, // Same ID as technique1
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 2',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1002',
},
],
};

const bundleWithDuplicateObjects = {
...minimalBundle,
objects: [minimalCollection, technique1, technique2],
};

expect(() => stixBundleSchema.parse(bundleWithDuplicateObjects)).toThrow(
/Duplicate object with id/,
);
});

it('should report the duplicate ID in error message', () => {
const duplicateId = `attack-pattern--${uuidv4()}`;

const technique1: Technique = {
id: duplicateId,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 1',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1001',
},
],
};

const technique2: Technique = {
id: duplicateId,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 2',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1002',
},
],
};

const bundleWithDuplicateObjects = {
...minimalBundle,
objects: [minimalCollection, technique1, technique2],
};

try {
stixBundleSchema.parse(bundleWithDuplicateObjects);
expect.fail('Expected schema to throw for duplicate IDs');
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.issues[0].message;
expect(errorMessage).toContain(duplicateId);
} else {
throw error;
}
}
});

it('should handle multiple duplicates in a single bundle', () => {
const duplicateId1 = `attack-pattern--${uuidv4()}`;
const duplicateId2 = `attack-pattern--${uuidv4()}`;

const technique1: Technique = {
id: duplicateId1,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 1',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1001',
},
],
};

const technique2: Technique = {
id: duplicateId1, // Duplicate of technique1
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 2',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1002',
},
],
};

const technique3: Technique = {
id: duplicateId2,
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 3',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1003',
},
],
};

const technique4: Technique = {
id: duplicateId2, // Duplicate of technique3
type: 'attack-pattern',
spec_version: '2.1',
created: '2021-01-01T00:00:00.000Z' as StixCreatedTimestamp,
modified: '2021-01-01T00:00:00.000Z' as StixModifiedTimestamp,
name: 'Test Technique 4',
x_mitre_attack_spec_version: '2.1.0',
x_mitre_version: '1.0',
x_mitre_domains: ['enterprise-attack'],
x_mitre_is_subtechnique: false,
external_references: [
{
source_name: 'mitre-attack',
external_id: 'T1004',
},
],
};

const bundleWithMultipleDuplicates = {
...minimalBundle,
objects: [minimalCollection, technique1, technique2, technique3, technique4],
};

try {
stixBundleSchema.parse(bundleWithMultipleDuplicates);
expect.fail('Expected schema to throw for multiple duplicate IDs');
} catch (error) {
if (error instanceof z.ZodError) {
// Should have at least 2 errors (one for each duplicate pair)
expect(error.issues.length).toBeGreaterThanOrEqual(2);
} else {
throw error;
}
}
});
});
});

// GitHub Actions often fails without an increased timeout for this test
Expand Down