@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/README.md +51 -0
- package/dist/index.d.mts +1210 -0
- package/dist/index.d.ts +1210 -0
- package/dist/index.js +1768 -0
- package/dist/index.mjs +1632 -0
- package/package.json +27 -0
- package/src/finders.ts +712 -0
- package/src/getters.ts +409 -0
- package/src/graph.ts +601 -0
- package/src/helpers.ts +31 -0
- package/src/index.ts +28 -0
- package/src/keys.ts +286 -0
- package/src/levels.ts +262 -0
- package/src/types.generated.ts +176 -0
- package/tests/getters-finders.test.ts +461 -0
- package/tests/graph.test.ts +398 -0
- package/tests/keys-levels.test.ts +298 -0
- package/tsconfig.json +4 -0
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";
|