@cyvest/cyvest-js 4.4.1 → 5.0.0

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/getters.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Getter utilities for retrieving entities from a Cyvest Investigation.
3
3
  *
4
4
  * These functions provide type-safe access to observables, checks, threat intel,
5
- * enrichments, and containers by their keys.
5
+ * enrichments, and tags by their keys.
6
6
  */
7
7
 
8
8
  import type {
@@ -11,8 +11,11 @@ import type {
11
11
  Check,
12
12
  ThreatIntel,
13
13
  Enrichment,
14
- Container,
14
+ Tag,
15
+ Level,
15
16
  } from "./types.generated";
17
+ import { generateObservableKey, isTagChildOf } from "./keys";
18
+ import { getLevelFromScore } from "./levels";
16
19
 
17
20
  /**
18
21
  * Get an observable by its key.
@@ -68,6 +71,32 @@ export function getObservableByTypeValue(
68
71
  return undefined;
69
72
  }
70
73
 
74
+ /**
75
+ * Get the root observable of the investigation.
76
+ *
77
+ * The root observable is identified using the `root_type` from data extraction
78
+ * metadata combined with value="root".
79
+ *
80
+ * @param inv - The investigation
81
+ * @returns The root observable, or undefined if not found
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const root = getRootObservable(investigation);
86
+ * if (root) {
87
+ * console.log(`Root: ${root.type} = ${root.value}`);
88
+ * }
89
+ * ```
90
+ */
91
+ export function getRootObservable(inv: CyvestInvestigation): Observable | undefined {
92
+ const rootType = inv.data_extraction.root_type;
93
+ if (!rootType) {
94
+ return undefined;
95
+ }
96
+ const rootKey = generateObservableKey(rootType, "root");
97
+ return inv.observables[rootKey];
98
+ }
99
+
71
100
  /**
72
101
  * Get a check by its key.
73
102
  *
@@ -84,60 +113,32 @@ export function getCheck(
84
113
  inv: CyvestInvestigation,
85
114
  key: string
86
115
  ): Check | undefined {
87
- for (const checks of Object.values(inv.checks)) {
88
- for (const check of checks) {
89
- if (check.key === key) {
90
- return check;
91
- }
92
- }
93
- }
94
- return undefined;
116
+ return inv.checks[key];
95
117
  }
96
118
 
97
119
  /**
98
- * Get a check by its ID and scope.
120
+ * Get a check by its name.
99
121
  *
100
122
  * @param inv - The investigation to search
101
- * @param checkId - Check identifier
102
- * @param scope - Check scope
123
+ * @param checkName - Check name
103
124
  * @returns The check or undefined if not found
104
125
  *
105
126
  * @example
106
127
  * ```ts
107
- * const check = getCheckByIdScope(investigation, "sender_verification", "email_headers");
128
+ * const check = getCheckByName(investigation, "sender_verification");
108
129
  * ```
109
130
  */
110
- export function getCheckByIdScope(
131
+ export function getCheckByName(
111
132
  inv: CyvestInvestigation,
112
- checkId: string,
113
- scope: string
133
+ checkName: string
114
134
  ): Check | undefined {
115
- const normalizedId = checkId.trim().toLowerCase();
116
- const normalizedScope = scope.trim().toLowerCase();
117
-
118
- const scopeChecks = inv.checks[normalizedScope] || inv.checks[scope];
119
- if (scopeChecks) {
120
- return scopeChecks.find(
121
- (c) => c.check_id.toLowerCase() === normalizedId
122
- );
123
- }
124
-
125
- // Fallback: search all scopes
126
- for (const checks of Object.values(inv.checks)) {
127
- for (const check of checks) {
128
- if (
129
- check.check_id.toLowerCase() === normalizedId &&
130
- check.scope.toLowerCase() === normalizedScope
131
- ) {
132
- return check;
133
- }
134
- }
135
- }
136
- return undefined;
135
+ const normalizedName = checkName.trim().toLowerCase();
136
+ const key = `chk:${normalizedName}`;
137
+ return inv.checks[key];
137
138
  }
138
139
 
139
140
  /**
140
- * Get all checks as a flat array (not grouped by scope).
141
+ * Get all checks as an array.
141
142
  *
142
143
  * @param inv - The investigation
143
144
  * @returns Array of all checks
@@ -149,11 +150,7 @@ export function getCheckByIdScope(
149
150
  * ```
150
151
  */
151
152
  export function getAllChecks(inv: CyvestInvestigation): Check[] {
152
- const result: Check[] = [];
153
- for (const checks of Object.values(inv.checks)) {
154
- result.push(...checks);
155
- }
156
- return result;
153
+ return Object.values(inv.checks);
157
154
  }
158
155
 
159
156
  /**
@@ -252,81 +249,62 @@ export function getAllEnrichments(inv: CyvestInvestigation): Enrichment[] {
252
249
  }
253
250
 
254
251
  /**
255
- * Get a container by its key.
252
+ * Get a tag by its key.
256
253
  *
257
254
  * @param inv - The investigation to search
258
- * @param key - Container key (e.g., "ctr:email/headers")
259
- * @returns The container or undefined if not found
255
+ * @param key - Tag key (e.g., "tag:header:auth")
256
+ * @returns The tag or undefined if not found
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * const tag = getTag(investigation, "tag:header:auth");
261
+ * if (tag) {
262
+ * console.log(tag.name, tag.direct_level);
263
+ * }
264
+ * ```
260
265
  */
261
- export function getContainer(
266
+ export function getTag(
262
267
  inv: CyvestInvestigation,
263
268
  key: string
264
- ): Container | undefined {
265
- // First check top-level containers
266
- if (inv.containers[key]) {
267
- return inv.containers[key];
268
- }
269
-
270
- // Search recursively in sub-containers
271
- function searchSubContainers(containers: Record<string, Container>): Container | undefined {
272
- for (const container of Object.values(containers)) {
273
- if (container.key === key) {
274
- return container;
275
- }
276
- const found = searchSubContainers(container.sub_containers);
277
- if (found) return found;
278
- }
279
- return undefined;
280
- }
281
-
282
- return searchSubContainers(inv.containers);
269
+ ): Tag | undefined {
270
+ return inv.tags[key];
283
271
  }
284
272
 
285
273
  /**
286
- * Get a container by its path.
274
+ * Get a tag by its name.
287
275
  *
288
276
  * @param inv - The investigation to search
289
- * @param path - Container path
290
- * @returns The container or undefined if not found
277
+ * @param name - Tag name (e.g., "header:auth:dkim")
278
+ * @returns The tag or undefined if not found
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * const tag = getTagByName(investigation, "header:auth:dkim");
283
+ * ```
291
284
  */
292
- export function getContainerByPath(
285
+ export function getTagByName(
293
286
  inv: CyvestInvestigation,
294
- path: string
295
- ): Container | undefined {
296
- const normalizedPath = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
297
-
298
- function searchContainers(containers: Record<string, Container>): Container | undefined {
299
- for (const container of Object.values(containers)) {
300
- if (container.path.toLowerCase() === normalizedPath) {
301
- return container;
302
- }
303
- const found = searchContainers(container.sub_containers);
304
- if (found) return found;
305
- }
306
- return undefined;
307
- }
308
-
309
- return searchContainers(inv.containers);
287
+ name: string
288
+ ): Tag | undefined {
289
+ const normalizedName = name.trim().toLowerCase();
290
+ const key = `tag:${normalizedName}`;
291
+ return inv.tags[key];
310
292
  }
311
293
 
312
294
  /**
313
- * Get all containers as a flat array (including sub-containers).
295
+ * Get all tags as an array.
314
296
  *
315
297
  * @param inv - The investigation
316
- * @returns Array of all containers
298
+ * @returns Array of all tags
299
+ *
300
+ * @example
301
+ * ```ts
302
+ * const allTags = getAllTags(investigation);
303
+ * console.log(`Total tags: ${allTags.length}`);
304
+ * ```
317
305
  */
318
- export function getAllContainers(inv: CyvestInvestigation): Container[] {
319
- const result: Container[] = [];
320
-
321
- function collectContainers(containers: Record<string, Container>): void {
322
- for (const container of Object.values(containers)) {
323
- result.push(container);
324
- collectContainers(container.sub_containers);
325
- }
326
- }
327
-
328
- collectContainers(inv.containers);
329
- return result;
306
+ export function getAllTags(inv: CyvestInvestigation): Tag[] {
307
+ return Object.values(inv.tags);
330
308
  }
331
309
 
332
310
  /**
@@ -377,7 +355,7 @@ export interface InvestigationCounts {
377
355
  checks: number;
378
356
  threatIntels: number;
379
357
  enrichments: number;
380
- containers: number;
358
+ tags: number;
381
359
  whitelists: number;
382
360
  }
383
361
 
@@ -393,7 +371,7 @@ export function getCounts(inv: CyvestInvestigation): InvestigationCounts {
393
371
  checks: getAllChecks(inv).length,
394
372
  threatIntels: Object.keys(inv.threat_intels).length,
395
373
  enrichments: Object.keys(inv.enrichments).length,
396
- containers: getAllContainers(inv).length,
374
+ tags: getAllTags(inv).length,
397
375
  whitelists: inv.whitelists.length,
398
376
  };
399
377
  }
@@ -420,3 +398,97 @@ export function getStartedAt(inv: CyvestInvestigation): string | undefined {
420
398
  );
421
399
  return event?.timestamp;
422
400
  }
401
+
402
+ // ============================================================================
403
+ // Tag Aggregation
404
+ // ============================================================================
405
+
406
+ /**
407
+ * Get direct child tags of a given tag.
408
+ *
409
+ * @param inv - The investigation
410
+ * @param tagName - Parent tag name
411
+ * @returns Array of direct child tags
412
+ *
413
+ * @example
414
+ * ```ts
415
+ * const children = getTagChildren(investigation, "bodies");
416
+ * // Returns tags like "bodies:urls", "bodies:domains" (but not "bodies:urls:something")
417
+ * ```
418
+ */
419
+ export function getTagChildren(inv: CyvestInvestigation, tagName: string): Tag[] {
420
+ return Object.values(inv.tags).filter((tag) => isTagChildOf(tag.name, tagName));
421
+ }
422
+
423
+ /**
424
+ * Get all descendant tags of a given tag (any depth).
425
+ *
426
+ * @param inv - The investigation
427
+ * @param tagName - Ancestor tag name
428
+ * @returns Array of all descendant tags
429
+ *
430
+ * @example
431
+ * ```ts
432
+ * const descendants = getTagDescendants(investigation, "bodies");
433
+ * // Returns all tags starting with "bodies:"
434
+ * ```
435
+ */
436
+ export function getTagDescendants(inv: CyvestInvestigation, tagName: string): Tag[] {
437
+ const prefix = tagName + ":";
438
+ return Object.values(inv.tags).filter((tag) => tag.name.startsWith(prefix));
439
+ }
440
+
441
+ /**
442
+ * Get the aggregated score for a tag including all descendant tags.
443
+ *
444
+ * The aggregated score includes:
445
+ * - The tag's direct_score (from its direct checks)
446
+ * - Recursively, the aggregated scores of all child tags
447
+ *
448
+ * @param inv - The investigation
449
+ * @param tagName - Name of the tag
450
+ * @returns Total aggregated score, or 0 if tag not found
451
+ *
452
+ * @example
453
+ * ```ts
454
+ * const score = getTagAggregatedScore(investigation, "bodies");
455
+ * // Includes scores from bodies, bodies:urls, bodies:domains, etc.
456
+ * ```
457
+ */
458
+ export function getTagAggregatedScore(inv: CyvestInvestigation, tagName: string): number {
459
+ const tag = getTagByName(inv, tagName);
460
+ if (!tag) {
461
+ return 0;
462
+ }
463
+
464
+ // Start with direct score
465
+ let total = tag.direct_score;
466
+
467
+ // Add scores from direct children (they will recursively add their children)
468
+ const children = getTagChildren(inv, tagName);
469
+ for (const child of children) {
470
+ total += getTagAggregatedScore(inv, child.name);
471
+ }
472
+
473
+ return total;
474
+ }
475
+
476
+ /**
477
+ * Get the aggregated level for a tag including all descendant tags.
478
+ *
479
+ * The level is calculated from the aggregated score using the standard
480
+ * score-to-level mapping.
481
+ *
482
+ * @param inv - The investigation
483
+ * @param tagName - Name of the tag
484
+ * @returns Level based on aggregated score
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * const level = getTagAggregatedLevel(investigation, "bodies");
489
+ * // Returns "MALICIOUS" if aggregated score >= 5, etc.
490
+ * ```
491
+ */
492
+ export function getTagAggregatedLevel(inv: CyvestInvestigation, tagName: string): Level {
493
+ return getLevelFromScore(getTagAggregatedScore(inv, tagName));
494
+ }
package/src/graph.ts CHANGED
@@ -325,15 +325,15 @@ export function getObservableGraph(inv: CyvestInvestigation): InvestigationGraph
325
325
  // ============================================================================
326
326
 
327
327
  /**
328
- * Find the root observable(s) of the investigation.
328
+ * Find source observables in the investigation graph.
329
329
  *
330
- * Root observables are those that have no incoming relationships
330
+ * Source observables are those that have no incoming relationships
331
331
  * (nothing points to them as a target).
332
332
  *
333
333
  * @param inv - The investigation
334
- * @returns Array of root observables
334
+ * @returns Array of source observables
335
335
  */
336
- export function findRootObservables(inv: CyvestInvestigation): Observable[] {
336
+ export function findSourceObservables(inv: CyvestInvestigation): Observable[] {
337
337
  const targetKeys = new Set<string>();
338
338
 
339
339
  // Collect all target keys from relationships
package/src/keys.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * Key type prefixes used in Cyvest.
10
10
  */
11
- export type KeyType = "obs" | "chk" | "ti" | "enr" | "ctr";
11
+ export type KeyType = "obs" | "chk" | "ti" | "enr" | "tag";
12
12
 
13
13
  /**
14
14
  * Normalize a string value for consistent key generation.
@@ -58,22 +58,20 @@ export function generateObservableKey(obsType: string, value: string): string {
58
58
  /**
59
59
  * Generate a unique key for a check.
60
60
  *
61
- * Format: chk:{check_id}:{scope}
61
+ * Format: chk:{check_name}
62
62
  *
63
- * @param checkId - Identifier of the check
64
- * @param scope - Scope of the check
63
+ * @param checkName - Name of the check
65
64
  * @returns Unique check key
66
65
  *
67
66
  * @example
68
67
  * ```ts
69
- * generateCheckKey("sender_verification", "email_headers")
70
- * // => "chk:sender_verification:email_headers"
68
+ * generateCheckKey("sender_verification")
69
+ * // => "chk:sender_verification"
71
70
  * ```
72
71
  */
73
- export function generateCheckKey(checkId: string, scope: string): string {
74
- const normalizedId = normalizeValue(checkId);
75
- const normalizedScope = normalizeValue(scope);
76
- return `chk:${normalizedId}:${normalizedScope}`;
72
+ export function generateCheckKey(checkName: string): string {
73
+ const normalizedName = normalizeValue(checkName);
74
+ return `chk:${normalizedName}`;
77
75
  }
78
76
 
79
77
  /**
@@ -127,31 +125,88 @@ export function generateEnrichmentKey(name: string, context?: string): string {
127
125
  }
128
126
 
129
127
  /**
130
- * Generate a unique key for a container.
128
+ * Generate a unique key for a tag.
131
129
  *
132
- * Format: ctr:{normalized_path}
130
+ * Format: tag:{normalized_name}
133
131
  *
134
- * @param path - Path of the container (can use / or . as separator)
135
- * @returns Unique container key
132
+ * @param name - Name of the tag (can use : as hierarchy delimiter)
133
+ * @returns Unique tag key
136
134
  *
137
135
  * @example
138
136
  * ```ts
139
- * generateContainerKey("email/headers")
140
- * // => "ctr:email/headers"
137
+ * generateTagKey("header:auth:dkim")
138
+ * // => "tag:header:auth:dkim"
141
139
  * ```
142
140
  */
143
- export function generateContainerKey(path: string): string {
144
- // Normalize path separators
145
- let normalizedPath = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
146
- normalizedPath = normalizeValue(normalizedPath);
147
- return `ctr:${normalizedPath}`;
141
+ export function generateTagKey(name: string): string {
142
+ const normalizedName = normalizeValue(name);
143
+ return `tag:${normalizedName}`;
144
+ }
145
+
146
+ /**
147
+ * Get all ancestor tag names from a hierarchical tag name.
148
+ *
149
+ * @param name - Tag name with : delimiter
150
+ * @returns Array of ancestor tag names
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * getTagAncestors("header:auth:dkim")
155
+ * // => ["header", "header:auth"]
156
+ * ```
157
+ */
158
+ export function getTagAncestors(name: string): string[] {
159
+ const parts = name.split(":");
160
+ const ancestors: string[] = [];
161
+ for (let i = 0; i < parts.length - 1; i++) {
162
+ ancestors.push(parts.slice(0, i + 1).join(":"));
163
+ }
164
+ return ancestors;
165
+ }
166
+
167
+ /**
168
+ * Check if a tag is a direct child of another tag.
169
+ *
170
+ * @param childName - Potential child tag name
171
+ * @param parentName - Potential parent tag name
172
+ * @returns True if childName is a direct child of parentName
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * isTagChildOf("header:auth", "header") // => true
177
+ * isTagChildOf("header:auth:dkim", "header") // => false (grandchild)
178
+ * ```
179
+ */
180
+ export function isTagChildOf(childName: string, parentName: string): boolean {
181
+ if (!childName.startsWith(parentName + ":")) {
182
+ return false;
183
+ }
184
+ const remaining = childName.slice(parentName.length + 1);
185
+ return !remaining.includes(":");
186
+ }
187
+
188
+ /**
189
+ * Check if a tag is a descendant of another tag (any depth).
190
+ *
191
+ * @param descendantName - Potential descendant tag name
192
+ * @param ancestorName - Potential ancestor tag name
193
+ * @returns True if descendantName is a descendant of ancestorName
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * isTagDescendantOf("header:auth:dkim", "header") // => true
198
+ * isTagDescendantOf("header", "header") // => false (same)
199
+ * ```
200
+ */
201
+ export function isTagDescendantOf(descendantName: string, ancestorName: string): boolean {
202
+ return descendantName.startsWith(ancestorName + ":");
148
203
  }
149
204
 
150
205
  /**
151
206
  * Extract the type prefix from a key.
152
207
  *
153
208
  * @param key - The key to parse
154
- * @returns Type prefix (obs, chk, ti, enr, ctr) or null if invalid
209
+ * @returns Type prefix (obs, chk, ti, enr, tag) or null if invalid
155
210
  *
156
211
  * @example
157
212
  * ```ts
@@ -162,7 +217,7 @@ export function generateContainerKey(path: string): string {
162
217
  export function parseKeyType(key: string): KeyType | null {
163
218
  if (key.includes(":")) {
164
219
  const prefix = key.split(":", 1)[0] as KeyType;
165
- if (["obs", "chk", "ti", "enr", "ctr"].includes(prefix)) {
220
+ if (["obs", "chk", "ti", "enr", "tag"].includes(prefix)) {
166
221
  return prefix;
167
222
  }
168
223
  }
@@ -233,25 +288,24 @@ export function parseObservableKey(
233
288
  * Extract components from a check key.
234
289
  *
235
290
  * @param key - Check key to parse
236
- * @returns Object with checkId and scope, or null if invalid
291
+ * @returns Object with checkName, or null if invalid
237
292
  *
238
293
  * @example
239
294
  * ```ts
240
- * parseCheckKey("chk:sender_verification:email_headers")
241
- * // => { checkId: "sender_verification", scope: "email_headers" }
295
+ * parseCheckKey("chk:sender_verification")
296
+ * // => { checkName: "sender_verification" }
242
297
  * ```
243
298
  */
244
299
  export function parseCheckKey(
245
300
  key: string
246
- ): { checkId: string; scope: string } | null {
301
+ ): { checkName: string } | null {
247
302
  if (!validateKey(key, "chk")) {
248
303
  return null;
249
304
  }
250
305
  const parts = key.split(":");
251
- if (parts.length >= 3) {
306
+ if (parts.length >= 2) {
252
307
  return {
253
- checkId: parts[1],
254
- scope: parts.slice(2).join(":"),
308
+ checkName: parts.slice(1).join(":"),
255
309
  };
256
310
  }
257
311
  return null;
package/src/levels.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  Observable,
10
10
  Check,
11
11
  ThreatIntel,
12
- Container,
12
+ Tag,
13
13
  Level,
14
14
  } from "./types.generated";
15
15
 
@@ -254,15 +254,15 @@ export function hasLevel(obj: unknown): obj is { level: Level } {
254
254
  }
255
255
 
256
256
  /**
257
- * Extract level from an entity (Observable, Check, ThreatIntel, Container).
257
+ * Extract level from an entity (Observable, Check, ThreatIntel, Tag).
258
258
  */
259
259
  export function getEntityLevel(
260
- entity: Observable | Check | ThreatIntel | Container
260
+ entity: Observable | Check | ThreatIntel | Tag
261
261
  ): Level {
262
- if ("aggregated_level" in entity) {
263
- const aggregatedLevel = entity.aggregated_level;
264
- if (typeof aggregatedLevel === "string" && isValidLevel(aggregatedLevel)) {
265
- return aggregatedLevel;
262
+ if ("direct_level" in entity) {
263
+ const directLevel = entity.direct_level;
264
+ if (typeof directLevel === "string" && isValidLevel(directLevel)) {
265
+ return directLevel;
266
266
  }
267
267
  }
268
268
  if ("level" in entity && isValidLevel(entity.level)) {