@cyvest/cyvest-js 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/graph.ts ADDED
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Graph and relationship traversal utilities for Cyvest Investigation.
3
+ *
4
+ * These functions provide graph-like traversal of observable relationships,
5
+ * useful for understanding connections and preparing data for visualization.
6
+ */
7
+
8
+ import type {
9
+ CyvestInvestigation,
10
+ Observable,
11
+ Relationship,
12
+ Level,
13
+ RelationshipDirection,
14
+ } from "./types.generated";
15
+
16
+ /**
17
+ * Edge representation for graph operations.
18
+ */
19
+ export interface GraphEdge {
20
+ /** Source observable key */
21
+ source: string;
22
+ /** Target observable key */
23
+ target: string;
24
+ /** Relationship type label */
25
+ type: string;
26
+ /** Relationship direction */
27
+ direction: RelationshipDirection;
28
+ }
29
+
30
+ /**
31
+ * Graph node representation.
32
+ */
33
+ export interface GraphNode {
34
+ /** Observable key (unique identifier) */
35
+ id: string;
36
+ /** Observable type */
37
+ type: string;
38
+ /** Observable value */
39
+ value: string;
40
+ /** Security level */
41
+ level: Level;
42
+ /** Numeric score */
43
+ score: number;
44
+ /** Whether internal */
45
+ internal: boolean;
46
+ /** Whether whitelisted */
47
+ whitelisted: boolean;
48
+ }
49
+
50
+ /**
51
+ * Full graph representation of an investigation.
52
+ */
53
+ export interface InvestigationGraph {
54
+ /** All nodes (observables) */
55
+ nodes: GraphNode[];
56
+ /** All edges (relationships) */
57
+ edges: GraphEdge[];
58
+ }
59
+
60
+ // ============================================================================
61
+ // Relationship Traversal
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Get all related observables for a given observable.
66
+ *
67
+ * Returns observables that are directly connected via any relationship,
68
+ * regardless of direction.
69
+ *
70
+ * @param inv - The investigation to search
71
+ * @param observableKey - Key of the source observable
72
+ * @returns Array of related observables
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const related = getRelatedObservables(investigation, "obs:email-addr:test@example.com");
77
+ * ```
78
+ */
79
+ export function getRelatedObservables(
80
+ inv: CyvestInvestigation,
81
+ observableKey: string
82
+ ): Observable[] {
83
+ const observable = inv.observables[observableKey];
84
+ if (!observable) {
85
+ return [];
86
+ }
87
+
88
+ const relatedKeys = new Set<string>();
89
+
90
+ // Get outbound relationships from this observable
91
+ for (const rel of observable.relationships) {
92
+ relatedKeys.add(rel.target_key);
93
+ }
94
+
95
+ // Get inbound relationships (observables pointing to this one)
96
+ for (const [key, obs] of Object.entries(inv.observables)) {
97
+ if (key === observableKey) continue;
98
+ for (const rel of obs.relationships) {
99
+ if (rel.target_key === observableKey) {
100
+ relatedKeys.add(key);
101
+ break;
102
+ }
103
+ }
104
+ }
105
+
106
+ // Resolve keys to observables
107
+ return Array.from(relatedKeys)
108
+ .map((key) => inv.observables[key])
109
+ .filter((obs): obs is Observable => obs !== undefined);
110
+ }
111
+
112
+ /**
113
+ * Get observables related by outbound relationships (children).
114
+ *
115
+ * @param inv - The investigation to search
116
+ * @param observableKey - Key of the source observable
117
+ * @returns Array of child observables
118
+ */
119
+ export function getObservableChildren(
120
+ inv: CyvestInvestigation,
121
+ observableKey: string
122
+ ): Observable[] {
123
+ const observable = inv.observables[observableKey];
124
+ if (!observable) {
125
+ return [];
126
+ }
127
+
128
+ return observable.relationships
129
+ .filter((rel) => rel.direction === "outbound" || rel.direction === "bidirectional")
130
+ .map((rel) => inv.observables[rel.target_key])
131
+ .filter((obs): obs is Observable => obs !== undefined);
132
+ }
133
+
134
+ /**
135
+ * Get observables related by inbound relationships (parents).
136
+ *
137
+ * @param inv - The investigation to search
138
+ * @param observableKey - Key of the target observable
139
+ * @returns Array of parent observables
140
+ */
141
+ export function getObservableParents(
142
+ inv: CyvestInvestigation,
143
+ observableKey: string
144
+ ): Observable[] {
145
+ const parents: Observable[] = [];
146
+
147
+ for (const [key, obs] of Object.entries(inv.observables)) {
148
+ if (key === observableKey) continue;
149
+
150
+ for (const rel of obs.relationships) {
151
+ if (
152
+ rel.target_key === observableKey &&
153
+ (rel.direction === "outbound" || rel.direction === "bidirectional")
154
+ ) {
155
+ parents.push(obs);
156
+ break;
157
+ }
158
+ }
159
+ }
160
+
161
+ return parents;
162
+ }
163
+
164
+ /**
165
+ * Get related observables filtered by relationship type.
166
+ *
167
+ * @param inv - The investigation to search
168
+ * @param observableKey - Key of the source observable
169
+ * @param relationshipType - Type of relationship to filter (e.g., "related-to", "uses")
170
+ * @returns Array of related observables
171
+ */
172
+ export function getRelatedObservablesByType(
173
+ inv: CyvestInvestigation,
174
+ observableKey: string,
175
+ relationshipType: string
176
+ ): Observable[] {
177
+ const observable = inv.observables[observableKey];
178
+ if (!observable) {
179
+ return [];
180
+ }
181
+
182
+ const normalizedType = relationshipType.toLowerCase();
183
+ const relatedKeys = new Set<string>();
184
+
185
+ // Outbound with matching type
186
+ for (const rel of observable.relationships) {
187
+ if (rel.relationship_type.toLowerCase() === normalizedType) {
188
+ relatedKeys.add(rel.target_key);
189
+ }
190
+ }
191
+
192
+ // Inbound with matching type
193
+ for (const [key, obs] of Object.entries(inv.observables)) {
194
+ if (key === observableKey) continue;
195
+ for (const rel of obs.relationships) {
196
+ if (
197
+ rel.target_key === observableKey &&
198
+ rel.relationship_type.toLowerCase() === normalizedType
199
+ ) {
200
+ relatedKeys.add(key);
201
+ break;
202
+ }
203
+ }
204
+ }
205
+
206
+ return Array.from(relatedKeys)
207
+ .map((key) => inv.observables[key])
208
+ .filter((obs): obs is Observable => obs !== undefined);
209
+ }
210
+
211
+ /**
212
+ * Get related observables filtered by direction.
213
+ *
214
+ * @param inv - The investigation to search
215
+ * @param observableKey - Key of the source observable
216
+ * @param direction - Direction to filter by
217
+ * @returns Array of related observables
218
+ */
219
+ export function getRelatedObservablesByDirection(
220
+ inv: CyvestInvestigation,
221
+ observableKey: string,
222
+ direction: RelationshipDirection
223
+ ): Observable[] {
224
+ const observable = inv.observables[observableKey];
225
+ if (!observable) {
226
+ return [];
227
+ }
228
+
229
+ const relatedKeys = new Set<string>();
230
+
231
+ if (direction === "outbound" || direction === "bidirectional") {
232
+ for (const rel of observable.relationships) {
233
+ if (rel.direction === direction || rel.direction === "bidirectional") {
234
+ relatedKeys.add(rel.target_key);
235
+ }
236
+ }
237
+ }
238
+
239
+ if (direction === "inbound" || direction === "bidirectional") {
240
+ for (const [key, obs] of Object.entries(inv.observables)) {
241
+ if (key === observableKey) continue;
242
+ for (const rel of obs.relationships) {
243
+ if (
244
+ rel.target_key === observableKey &&
245
+ (rel.direction === "outbound" || rel.direction === "bidirectional")
246
+ ) {
247
+ relatedKeys.add(key);
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ return Array.from(relatedKeys)
255
+ .map((key) => inv.observables[key])
256
+ .filter((obs): obs is Observable => obs !== undefined);
257
+ }
258
+
259
+ // ============================================================================
260
+ // Graph Construction
261
+ // ============================================================================
262
+
263
+ /**
264
+ * Build a graph representation of all observables and their relationships.
265
+ *
266
+ * Useful for visualization libraries like vis.js, d3, or cytoscape.
267
+ *
268
+ * @param inv - The investigation
269
+ * @returns Graph with nodes and edges
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * const graph = getObservableGraph(investigation);
274
+ * console.log(`Nodes: ${graph.nodes.length}, Edges: ${graph.edges.length}`);
275
+ *
276
+ * // Use with vis.js:
277
+ * const network = new vis.Network(container, {
278
+ * nodes: graph.nodes.map(n => ({ id: n.id, label: n.value })),
279
+ * edges: graph.edges.map(e => ({ from: e.source, to: e.target, label: e.type }))
280
+ * });
281
+ * ```
282
+ */
283
+ export function getObservableGraph(inv: CyvestInvestigation): InvestigationGraph {
284
+ const nodes: GraphNode[] = [];
285
+ const edges: GraphEdge[] = [];
286
+ const seenEdges = new Set<string>();
287
+
288
+ // Build nodes
289
+ for (const [key, obs] of Object.entries(inv.observables)) {
290
+ nodes.push({
291
+ id: key,
292
+ type: obs.type,
293
+ value: obs.value,
294
+ level: obs.level,
295
+ score: obs.score,
296
+ internal: obs.internal,
297
+ whitelisted: obs.whitelisted,
298
+ });
299
+
300
+ // Build edges from relationships
301
+ for (const rel of obs.relationships) {
302
+ // Create a unique edge key to avoid duplicates
303
+ const edgeKey =
304
+ rel.direction === "bidirectional"
305
+ ? [key, rel.target_key].sort().join("--")
306
+ : `${key}--${rel.target_key}`;
307
+
308
+ if (!seenEdges.has(edgeKey)) {
309
+ seenEdges.add(edgeKey);
310
+ edges.push({
311
+ source: key,
312
+ target: rel.target_key,
313
+ type: rel.relationship_type,
314
+ direction: rel.direction,
315
+ });
316
+ }
317
+ }
318
+ }
319
+
320
+ return { nodes, edges };
321
+ }
322
+
323
+ // ============================================================================
324
+ // Root & Orphan Detection
325
+ // ============================================================================
326
+
327
+ /**
328
+ * Find the root observable(s) of the investigation.
329
+ *
330
+ * Root observables are those that have no incoming relationships
331
+ * (nothing points to them as a target).
332
+ *
333
+ * @param inv - The investigation
334
+ * @returns Array of root observables
335
+ */
336
+ export function findRootObservables(inv: CyvestInvestigation): Observable[] {
337
+ const targetKeys = new Set<string>();
338
+
339
+ // Collect all target keys from relationships
340
+ for (const obs of Object.values(inv.observables)) {
341
+ for (const rel of obs.relationships) {
342
+ if (rel.direction === "outbound" || rel.direction === "bidirectional") {
343
+ targetKeys.add(rel.target_key);
344
+ }
345
+ }
346
+ }
347
+
348
+ // Find observables that are never targets
349
+ return Object.values(inv.observables).filter(
350
+ (obs) => !targetKeys.has(obs.key)
351
+ );
352
+ }
353
+
354
+ /**
355
+ * Find orphan observables (not connected to any other observable).
356
+ *
357
+ * @param inv - The investigation
358
+ * @returns Array of orphan observables
359
+ */
360
+ export function findOrphanObservables(inv: CyvestInvestigation): Observable[] {
361
+ const connectedKeys = new Set<string>();
362
+
363
+ // Mark all observables that have relationships
364
+ for (const obs of Object.values(inv.observables)) {
365
+ if (obs.relationships.length > 0) {
366
+ connectedKeys.add(obs.key);
367
+ for (const rel of obs.relationships) {
368
+ connectedKeys.add(rel.target_key);
369
+ }
370
+ }
371
+ }
372
+
373
+ // Return observables not in the connected set
374
+ return Object.values(inv.observables).filter(
375
+ (obs) => !connectedKeys.has(obs.key)
376
+ );
377
+ }
378
+
379
+ /**
380
+ * Find leaf observables (have incoming but no outgoing relationships).
381
+ *
382
+ * @param inv - The investigation
383
+ * @returns Array of leaf observables
384
+ */
385
+ export function findLeafObservables(inv: CyvestInvestigation): Observable[] {
386
+ const hasOutbound = new Set<string>();
387
+ const isTarget = new Set<string>();
388
+
389
+ for (const obs of Object.values(inv.observables)) {
390
+ for (const rel of obs.relationships) {
391
+ if (rel.direction === "outbound" || rel.direction === "bidirectional") {
392
+ hasOutbound.add(obs.key);
393
+ isTarget.add(rel.target_key);
394
+ }
395
+ }
396
+ }
397
+
398
+ // Leaves are targets that have no outbound relationships
399
+ return Object.values(inv.observables).filter(
400
+ (obs) => isTarget.has(obs.key) && !hasOutbound.has(obs.key)
401
+ );
402
+ }
403
+
404
+ // ============================================================================
405
+ // Path Finding
406
+ // ============================================================================
407
+
408
+ /**
409
+ * Check if two observables are connected (directly or transitively).
410
+ *
411
+ * @param inv - The investigation
412
+ * @param sourceKey - Starting observable key
413
+ * @param targetKey - Target observable key
414
+ * @returns True if a path exists from source to target
415
+ */
416
+ export function areConnected(
417
+ inv: CyvestInvestigation,
418
+ sourceKey: string,
419
+ targetKey: string
420
+ ): boolean {
421
+ if (sourceKey === targetKey) return true;
422
+
423
+ const visited = new Set<string>();
424
+ const queue = [sourceKey];
425
+
426
+ while (queue.length > 0) {
427
+ const current = queue.shift()!;
428
+ if (visited.has(current)) continue;
429
+ visited.add(current);
430
+
431
+ const obs = inv.observables[current];
432
+ if (!obs) continue;
433
+
434
+ for (const rel of obs.relationships) {
435
+ if (rel.target_key === targetKey) {
436
+ return true;
437
+ }
438
+ if (!visited.has(rel.target_key)) {
439
+ queue.push(rel.target_key);
440
+ }
441
+ }
442
+ }
443
+
444
+ return false;
445
+ }
446
+
447
+ /**
448
+ * Find the shortest path between two observables.
449
+ *
450
+ * @param inv - The investigation
451
+ * @param sourceKey - Starting observable key
452
+ * @param targetKey - Target observable key
453
+ * @returns Array of observable keys representing the path, or null if no path exists
454
+ */
455
+ export function findPath(
456
+ inv: CyvestInvestigation,
457
+ sourceKey: string,
458
+ targetKey: string
459
+ ): string[] | null {
460
+ if (sourceKey === targetKey) return [sourceKey];
461
+
462
+ const visited = new Set<string>();
463
+ const queue: { key: string; path: string[] }[] = [
464
+ { key: sourceKey, path: [sourceKey] },
465
+ ];
466
+
467
+ while (queue.length > 0) {
468
+ const { key: current, path } = queue.shift()!;
469
+ if (visited.has(current)) continue;
470
+ visited.add(current);
471
+
472
+ const obs = inv.observables[current];
473
+ if (!obs) continue;
474
+
475
+ for (const rel of obs.relationships) {
476
+ if (rel.target_key === targetKey) {
477
+ return [...path, targetKey];
478
+ }
479
+ if (!visited.has(rel.target_key)) {
480
+ queue.push({ key: rel.target_key, path: [...path, rel.target_key] });
481
+ }
482
+ }
483
+ }
484
+
485
+ return null;
486
+ }
487
+
488
+ /**
489
+ * Get all observables reachable from a starting point.
490
+ *
491
+ * @param inv - The investigation
492
+ * @param startKey - Starting observable key
493
+ * @param maxDepth - Maximum traversal depth (default: Infinity)
494
+ * @returns Array of reachable observables
495
+ */
496
+ export function getReachableObservables(
497
+ inv: CyvestInvestigation,
498
+ startKey: string,
499
+ maxDepth = Infinity
500
+ ): Observable[] {
501
+ const visited = new Set<string>();
502
+ const result: Observable[] = [];
503
+
504
+ function traverse(key: string, depth: number): void {
505
+ if (depth > maxDepth || visited.has(key)) return;
506
+ visited.add(key);
507
+
508
+ const obs = inv.observables[key];
509
+ if (!obs) return;
510
+
511
+ result.push(obs);
512
+
513
+ for (const rel of obs.relationships) {
514
+ traverse(rel.target_key, depth + 1);
515
+ }
516
+ }
517
+
518
+ traverse(startKey, 0);
519
+ return result;
520
+ }
521
+
522
+ // ============================================================================
523
+ // Relationship Type Utilities
524
+ // ============================================================================
525
+
526
+ /**
527
+ * Get all unique relationship types used in the investigation.
528
+ *
529
+ * @param inv - The investigation
530
+ * @returns Array of unique relationship type strings
531
+ */
532
+ export function getAllRelationshipTypes(inv: CyvestInvestigation): string[] {
533
+ const types = new Set<string>();
534
+
535
+ for (const obs of Object.values(inv.observables)) {
536
+ for (const rel of obs.relationships) {
537
+ types.add(rel.relationship_type);
538
+ }
539
+ }
540
+
541
+ return Array.from(types);
542
+ }
543
+
544
+ /**
545
+ * Count relationships by type.
546
+ *
547
+ * @param inv - The investigation
548
+ * @returns Object mapping relationship type to count
549
+ */
550
+ export function countRelationshipsByType(
551
+ inv: CyvestInvestigation
552
+ ): Record<string, number> {
553
+ const counts: Record<string, number> = {};
554
+
555
+ for (const obs of Object.values(inv.observables)) {
556
+ for (const rel of obs.relationships) {
557
+ counts[rel.relationship_type] = (counts[rel.relationship_type] || 0) + 1;
558
+ }
559
+ }
560
+
561
+ return counts;
562
+ }
563
+
564
+ /**
565
+ * Get all relationships for an observable.
566
+ *
567
+ * @param inv - The investigation
568
+ * @param observableKey - Observable key
569
+ * @returns Object with outbound, inbound, and all relationships
570
+ */
571
+ export function getRelationshipsForObservable(
572
+ inv: CyvestInvestigation,
573
+ observableKey: string
574
+ ): {
575
+ outbound: Relationship[];
576
+ inbound: Array<Relationship & { source_key: string }>;
577
+ all: Array<Relationship & { source_key?: string }>;
578
+ } {
579
+ const observable = inv.observables[observableKey];
580
+ const outbound: Relationship[] = observable?.relationships || [];
581
+
582
+ const inbound: Array<Relationship & { source_key: string }> = [];
583
+
584
+ for (const [key, obs] of Object.entries(inv.observables)) {
585
+ if (key === observableKey) continue;
586
+ for (const rel of obs.relationships) {
587
+ if (rel.target_key === observableKey) {
588
+ inbound.push({ ...rel, source_key: key });
589
+ }
590
+ }
591
+ }
592
+
593
+ return {
594
+ outbound,
595
+ inbound,
596
+ all: [
597
+ ...outbound,
598
+ ...inbound,
599
+ ],
600
+ };
601
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,31 @@
1
+ import Ajv2020, { type ValidateFunction } from "ajv/dist/2020";
2
+ import addFormats from "ajv-formats";
3
+ import schema from "../../../../schema/cyvest.schema.json" assert { type: "json" };
4
+ import type { CyvestInvestigation } from "./types.generated";
5
+
6
+ // Use Ajv2020 for draft 2020-12 schema support
7
+ const ajv = new Ajv2020({ allErrors: true });
8
+ addFormats(ajv);
9
+
10
+ let validateFn: ValidateFunction | null = null;
11
+
12
+ function getValidator(): ValidateFunction {
13
+ if (!validateFn) {
14
+ validateFn = ajv.compile(schema);
15
+ }
16
+ return validateFn;
17
+ }
18
+
19
+ export function parseCyvest(json: unknown): CyvestInvestigation {
20
+ const validate = getValidator();
21
+ if (!validate(json)) {
22
+ const msg = ajv.errorsText(validate.errors || []);
23
+ throw new Error(`Invalid Cyvest payload: ${msg}`);
24
+ }
25
+ return json as CyvestInvestigation;
26
+ }
27
+
28
+ export function isCyvest(json: unknown): json is CyvestInvestigation {
29
+ const validate = getValidator();
30
+ return !!validate(json);
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cyvest JavaScript/TypeScript SDK
3
+ *
4
+ * A library for working with Cyvest Investigation data structures.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // Core types (auto-generated from JSON schema)
10
+ export * from "./types.generated";
11
+
12
+ // JSON parsing and validation
13
+ export * from "./helpers";
14
+
15
+ // Key generation utilities
16
+ export * from "./keys";
17
+
18
+ // Level and scoring utilities
19
+ export * from "./levels";
20
+
21
+ // Entity getters
22
+ export * from "./getters";
23
+
24
+ // Query and filter functions
25
+ export * from "./finders";
26
+
27
+ // Graph and relationship traversal
28
+ export * from "./graph";