@hivemagnus/magnus-server 1.1.13 → 2.1.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.
Files changed (89) hide show
  1. package/.env.example +37 -0
  2. package/README.md +36 -0
  3. package/agent-version.json +1 -1
  4. package/dist/ai/TOOLS.md +1 -1
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/env-catalog.js +16 -0
  7. package/dist/cli/migrations.js +22 -0
  8. package/dist/cli/verify-initialization.js +12 -0
  9. package/dist/cli.js +28 -0
  10. package/dist/config/console-path.js +17 -0
  11. package/dist/config/env.js +32 -5
  12. package/dist/config/features.js +4 -1
  13. package/dist/config/oathweave.js +42 -0
  14. package/dist/config/rehearsal.js +22 -0
  15. package/dist/db/generated/prisma/internal/class.js +4 -4
  16. package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
  18. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  19. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  20. package/dist/db/read-retry.js +5 -1
  21. package/dist/db/seed.js +8 -0
  22. package/dist/index.js +28 -37
  23. package/dist/middleware/auth.js +21 -0
  24. package/dist/middleware/error-handler.js +10 -10
  25. package/dist/middleware/oathweave-verifier.js +23 -0
  26. package/dist/middleware/oathweave.js +115 -0
  27. package/dist/middleware/rate-limit.js +12 -3
  28. package/dist/openapi/spec.js +45 -4
  29. package/dist/queue/names.js +9 -0
  30. package/dist/queue/workers.js +9 -68
  31. package/dist/routes/assistant.js +30 -12
  32. package/dist/routes/cindertrace-response.js +183 -0
  33. package/dist/routes/console.js +33 -0
  34. package/dist/routes/cron.js +8 -5
  35. package/dist/routes/events-stream.js +10 -0
  36. package/dist/routes/files.js +2 -1
  37. package/dist/routes/fleet-gateway.js +69 -0
  38. package/dist/routes/heartbeat.js +1 -6
  39. package/dist/routes/hermes.js +26 -6
  40. package/dist/routes/hiveskills.js +116 -0
  41. package/dist/routes/library.js +3 -2
  42. package/dist/routes/logs.js +5 -0
  43. package/dist/routes/metrics.js +4 -3
  44. package/dist/routes/nodes.js +45 -8
  45. package/dist/routes/ops.js +24 -6
  46. package/dist/routes/profiling.js +2 -1
  47. package/dist/routes/roles.js +17 -9
  48. package/dist/routes/security-fusion.js +15 -600
  49. package/dist/routes/stats.js +8 -5
  50. package/dist/routes/terminal-ticket.js +3 -3
  51. package/dist/routes/terminal.js +2 -2
  52. package/dist/routes/webhooks.js +15 -15
  53. package/dist/sdk/node-api-client.js +23 -1
  54. package/dist/security/cindertrace-response.js +14 -0
  55. package/dist/security/native-completion-registry.js +14 -0
  56. package/dist/server.js +11 -14
  57. package/dist/services/ai-assistant-service.js +32 -6
  58. package/dist/services/ai-tools.js +15 -8
  59. package/dist/services/ardent-scope-enrollment.js +4 -2
  60. package/dist/services/command-resource-leases.js +2 -4
  61. package/dist/services/forecast-service.js +23 -8
  62. package/dist/services/ops-service.js +20 -25
  63. package/dist/services/prediction-service.js +2 -1
  64. package/dist/services/resource-enrollment.js +57 -4
  65. package/dist/services/secret-views.js +72 -0
  66. package/dist/services/security-export.js +4 -6
  67. package/dist/services/topology-service.js +2 -1
  68. package/dist/services/webhook-security.js +10 -1
  69. package/dist/services/webhook-service.js +14 -6
  70. package/dist/utils/node-url.js +2 -1
  71. package/dist/utils/safe-errors.js +130 -0
  72. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  73. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  74. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  75. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  76. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  77. package/package.json +7 -3
  78. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  79. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  80. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  81. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  82. package/prisma/schema.prisma +22 -0
  83. package/dist/services/security-fusion-actions.js +0 -22
  84. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  85. package/dist/services/security-fusion-ingest.js +0 -101
  86. package/dist/services/security-fusion-outbox.js +0 -185
  87. package/dist/services/security-fusion-receipt-chain.js +0 -134
  88. package/dist/services/security-fusion-service.js +0 -751
  89. package/dist/services/security-fusion-verification.js +0 -50
@@ -1,751 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
- import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
- import { join, resolve } from 'node:path';
4
- import { getDb } from '../db/index.js';
5
- import { HttpError } from '../middleware/error-handler.js';
6
- import { incMetric } from '../routes/metrics.js';
7
- import { sanitizeDetails } from './audit-service.js';
8
- import { appendDeletionReceipts, exportReceiptChainHead, } from './security-fusion-receipt-chain.js';
9
- import { ensurePrivateEvidenceDirectory } from './security-fusion-evidence-integrity.js';
10
- const severityRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
11
- const sourceTrust = {
12
- unifi_dns: 0.72, unifi_ids: 0.9, netflow: 0.75, wazuh: 0.94,
13
- tailscale: 0.9, magnus: 0.88, hive: 0.9, magnus_ui: 0.86,
14
- };
15
- /** Identity-bearing sources whose overlapping sessions form a cross-service authentication case. */
16
- const IDENTITY_SOURCES = new Set(['hive', 'magnus_ui']);
17
- const MAGNUS_SOURCE_STATE_TOUCH_INTERVAL_MS = 60_000;
18
- let lastMagnusSourceStateTouchAt = 0;
19
- export const SECURITY_FUSION_RETENTION_POLICY = {
20
- eventDays: 90,
21
- caseArchiveDays: 365,
22
- archiveGraceDays: 30,
23
- evidenceDays: 365,
24
- deletionReceiptDays: 2555,
25
- maxCaseEvents: 1_000,
26
- maxCaseEntities: 250,
27
- maxCaseRelationships: 50,
28
- maxPreservedEvidence: 100,
29
- maxPreservedEvidenceBytes: 8 * 1024 * 1024,
30
- maxSingleEvidenceBytes: 64 * 1024,
31
- };
32
- export function evaluateSecurityFusionEvidenceAdmission(currentCount, currentBytes, nextBytes) {
33
- if (currentCount >= SECURITY_FUSION_RETENTION_POLICY.maxPreservedEvidence) {
34
- return 'count_limit';
35
- }
36
- if (currentBytes + nextBytes
37
- > SECURITY_FUSION_RETENTION_POLICY.maxPreservedEvidenceBytes) {
38
- return 'byte_limit';
39
- }
40
- return 'accepted';
41
- }
42
- export function boundSecurityFusionRelationships(rows, limit = SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships) {
43
- return { rows: rows.slice(0, limit), truncated: rows.length > limit };
44
- }
45
- function rank(severity) {
46
- return severityRank[severity] ?? 0;
47
- }
48
- function canonicalJson(value) {
49
- if (Array.isArray(value))
50
- return `[${value.map(canonicalJson).join(',')}]`;
51
- if (value && typeof value === 'object') {
52
- return `{${Object.entries(value)
53
- .sort(([a], [b]) => a.localeCompare(b))
54
- .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
55
- }
56
- return JSON.stringify(value);
57
- }
58
- export function sha256(value) {
59
- return createHash('sha256').update(value).digest('hex');
60
- }
61
- function canonicalEntity(type, value) {
62
- const normalized = value.trim().toLowerCase();
63
- return type === 'domain' ? normalized.replace(/\.$/, '') : normalized;
64
- }
65
- export function correlationFamily(event, relatedSources) {
66
- const kind = event.kind.toLowerCase();
67
- if (IDENTITY_SOURCES.has(event.source) && [...relatedSources].some(source => source !== event.source && IDENTITY_SOURCES.has(source)))
68
- return 'cross-service-authentication';
69
- if ((event.source === 'unifi_ids' && relatedSources.has('wazuh')) || (event.source === 'wazuh' && relatedSources.has('unifi_ids')))
70
- return 'ids-endpoint-intrusion';
71
- if (kind.includes('auth') || kind.includes('login') || kind.includes('session'))
72
- return 'credential-abuse';
73
- if (kind.includes('dns') || kind.includes('flow')) {
74
- return rank(event.severity) >= rank('medium')
75
- || relatedSources.has('wazuh')
76
- ? 'suspicious-dns-egress'
77
- : null;
78
- }
79
- if (kind.includes('ids') || kind.includes('ips'))
80
- return 'ids-endpoint-intrusion';
81
- if (kind.includes('wazuh') || kind.includes('tamper') || kind.includes('drift'))
82
- return 'endpoint-integrity';
83
- if (kind.includes('heartbeat') && rank(event.severity) >= rank('medium'))
84
- return 'endpoint-integrity';
85
- if (kind.includes('tailscale'))
86
- return 'tailnet-access';
87
- return rank(event.severity) >= rank('high') ? 'security-observation' : null;
88
- }
89
- function investigationFor(family) {
90
- const commonEvidence = [
91
- 'Preserve the normalized event set and source locators before source retention expires.',
92
- 'Record timestamps, entity aliases, and integrity hashes in the case evidence manifest.',
93
- ];
94
- if (family === 'credential-abuse')
95
- return {
96
- checks: ['Verify the affected account and source identity.', 'Compare identity-provider and application sessions with Tailscale device ownership.', 'Review nearby Wazuh authentication findings.'],
97
- evidence: commonEvidence,
98
- };
99
- if (family === 'suspicious-dns-egress')
100
- return {
101
- checks: ['Confirm the client-to-IP mapping at the observation time.', 'Compare DNS, IPFIX, and UniFi IDS signatures.', 'Check the endpoint in Wazuh before proposing isolation.'],
102
- evidence: commonEvidence,
103
- };
104
- return {
105
- checks: ['Verify host liveness and configuration drift.', 'Review Wazuh findings and recent approved fleet operations.', 'Establish whether the observation is expected maintenance.'],
106
- evidence: commonEvidence,
107
- };
108
- }
109
- function caseDomain(family) {
110
- return ['credential-abuse', 'cross-service-authentication', 'tailnet-access'].includes(family)
111
- ? 'identity-access'
112
- : 'network-endpoint';
113
- }
114
- async function notifyChatChannel(caseId, revisionKey, message) {
115
- const baseUrl = process.env['SECURITY_FUSION_MATTERMOST_URL']?.replace(/\/$/u, '');
116
- const token = process.env['SECURITY_FUSION_MATTERMOST_BOT_TOKEN'];
117
- const channelId = process.env['SECURITY_FUSION_MATTERMOST_CHANNEL_ID'];
118
- if (!baseUrl || !token || !channelId)
119
- return;
120
- let delivery;
121
- try {
122
- delivery = await getDb().securityFusionDelivery.create({
123
- data: { caseId, revisionKey, channel: 'mattermost:security-fusion', status: 'sending' },
124
- });
125
- }
126
- catch (error) {
127
- if (typeof error === 'object' && error && 'code' in error && error.code === 'P2002')
128
- return;
129
- throw error;
130
- }
131
- try {
132
- const response = await fetch(`${baseUrl}/api/v4/posts`, {
133
- method: 'POST',
134
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
135
- body: JSON.stringify({ channel_id: channelId, message }),
136
- signal: AbortSignal.timeout(10_000),
137
- });
138
- if (!response.ok)
139
- throw new Error(`Chat channel returned ${response.status}`);
140
- const body = await response.json();
141
- await getDb().securityFusionDelivery.update({
142
- where: { id: delivery.id },
143
- data: { status: 'delivered', externalId: body.id ?? null },
144
- });
145
- }
146
- catch (error) {
147
- await getDb().securityFusionDelivery.update({
148
- where: { id: delivery.id },
149
- data: { status: 'failed', error: error instanceof Error ? error.message : String(error) },
150
- });
151
- }
152
- }
153
- async function upsertEntity(eventId, observedAt, ref) {
154
- const db = getDb();
155
- const canonical = canonicalEntity(ref.type, ref.value);
156
- const row = await db.securityFusionEntity.upsert({
157
- where: { type_canonical: { type: ref.type, canonical } },
158
- create: { type: ref.type, canonical, label: ref.label ?? ref.value, aliasesJson: JSON.stringify([ref.value]), firstSeenAt: observedAt, lastSeenAt: observedAt },
159
- update: { label: ref.label ?? ref.value, lastSeenAt: observedAt },
160
- });
161
- await db.securityFusionEventEntity.upsert({
162
- where: { eventId_entityId: { eventId, entityId: row.id } },
163
- create: { eventId, entityId: row.id },
164
- update: {},
165
- });
166
- await db.securityFusionEntityAlias.upsert({
167
- where: { source_canonical: { source: 'normalized', canonical: `${ref.type}:${canonical}` } },
168
- create: { entityId: row.id, source: 'normalized', value: ref.value, canonical: `${ref.type}:${canonical}`, firstSeenAt: observedAt, lastSeenAt: observedAt },
169
- update: { entityId: row.id, value: ref.value, lastSeenAt: observedAt },
170
- });
171
- return row.id;
172
- }
173
- async function correlate(event, storedId, entityIds) {
174
- const windowStart = new Date(Date.parse(event.observedAt) - 15 * 60_000);
175
- const relatedLinks = entityIds.length === 0 ? [] : await getDb().securityFusionEventEntity.findMany({
176
- where: { entityId: { in: entityIds } },
177
- orderBy: { id: 'desc' },
178
- take: SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships + 1,
179
- select: { eventId: true },
180
- });
181
- const boundedExpansion = boundSecurityFusionRelationships(relatedLinks);
182
- const relationshipExpansionTruncated = boundedExpansion.truncated;
183
- if (relationshipExpansionTruncated)
184
- incMetric('securityFusionRelationshipTruncated');
185
- const boundedRelatedLinks = boundedExpansion.rows;
186
- const related = boundedRelatedLinks.length === 0 ? [] : await getDb().securityFusionEvent.findMany({
187
- where: {
188
- id: { in: boundedRelatedLinks.map((link) => link.eventId) },
189
- observedAt: { gte: windowStart, lte: new Date(event.observedAt) },
190
- },
191
- select: { source: true },
192
- });
193
- const family = correlationFamily(event, new Set(related.map((item) => item.source)));
194
- if (!family)
195
- return null;
196
- const anchor = [...event.entities]
197
- .sort((a, b) => `${a.type}:${a.value}`.localeCompare(`${b.type}:${b.value}`))[0];
198
- const fingerprint = sha256(`${caseDomain(family)}|${anchor ? `${anchor.type}:${canonicalEntity(anchor.type, anchor.value)}` : event.source}`);
199
- const guidance = investigationFor(family);
200
- const trustAdjusted = Math.min(1, event.confidence * (sourceTrust[event.source] ?? 0.7));
201
- const existing = await getDb().securityFusionCase.findUnique({ where: { fingerprint } });
202
- const caseRow = await getDb().securityFusionCase.upsert({
203
- where: { fingerprint },
204
- create: {
205
- fingerprint,
206
- title: `${family.replaceAll('-', ' ')} · ${anchor?.label ?? anchor?.value ?? event.source}`,
207
- severity: event.severity,
208
- confidence: trustAdjusted,
209
- summary: event.summary,
210
- recommendedInvestigationJson: JSON.stringify(guidance.checks),
211
- evidenceStepsJson: JSON.stringify(guidance.evidence),
212
- firstSeenAt: new Date(event.observedAt),
213
- lastSeenAt: new Date(event.observedAt),
214
- },
215
- update: {
216
- status: existing && ['resolved', 'false_positive'].includes(existing.status) ? 'new' : existing?.status,
217
- severity: existing && rank(existing.severity) > rank(event.severity) ? existing.severity : event.severity,
218
- confidence: Math.min(1, Math.max(existing?.confidence ?? 0, trustAdjusted) + (existing ? 0.05 : 0)),
219
- lastSeenAt: new Date(event.observedAt),
220
- archivedAt: null,
221
- },
222
- });
223
- const reopened = existing && ['resolved', 'false_positive'].includes(existing.status);
224
- const escalated = existing && rank(event.severity) > rank(existing.severity);
225
- const accepted = await getDb().$transaction(async (tx) => {
226
- await tx.$queryRaw `SELECT "id" FROM "security_fusion_cases" WHERE "id" = ${caseRow.id} FOR UPDATE`;
227
- const lockedCase = await tx.securityFusionCase.findUnique({
228
- where: { id: caseRow.id },
229
- select: { evidenceAcceptedCount: true },
230
- });
231
- if (!lockedCase
232
- || lockedCase.evidenceAcceptedCount >= SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents) {
233
- if (lockedCase) {
234
- await tx.securityFusionCase.update({
235
- where: { id: caseRow.id },
236
- data: { evidenceTruncatedCount: { increment: 1 } },
237
- });
238
- }
239
- await tx.securityFusionSourceState.upsert({
240
- where: { source: event.source },
241
- create: {
242
- id: event.source,
243
- source: event.source,
244
- status: 'configured',
245
- truncatedCount: 1,
246
- },
247
- update: { truncatedCount: { increment: 1 } },
248
- });
249
- return false;
250
- }
251
- await tx.securityFusionCaseEvent.create({
252
- data: {
253
- caseId: caseRow.id,
254
- eventId: storedId,
255
- confidenceContribution: trustAdjusted,
256
- },
257
- });
258
- const entityCount = await tx.securityFusionCaseEntity.count({ where: { caseId: caseRow.id } });
259
- const remainingEntitySlots = Math.max(0, SECURITY_FUSION_RETENTION_POLICY.maxCaseEntities - entityCount);
260
- for (const entityId of entityIds.slice(0, remainingEntitySlots)) {
261
- await tx.securityFusionCaseEntity.upsert({
262
- where: { caseId_entityId: { caseId: caseRow.id, entityId } },
263
- create: { caseId: caseRow.id, entityId },
264
- update: {},
265
- });
266
- }
267
- await tx.securityFusionCase.update({
268
- where: { id: caseRow.id },
269
- data: { evidenceAcceptedCount: { increment: 1 } },
270
- });
271
- await tx.securityFusionCaseActivity.create({
272
- data: {
273
- caseId: caseRow.id,
274
- kind: !existing ? 'case.opened' : reopened ? 'case.reopened' : escalated ? 'case.escalated' : 'evidence.merged',
275
- actorId: 'system:security-fusion',
276
- detailJson: JSON.stringify({
277
- eventId: storedId,
278
- family,
279
- confidenceContribution: trustAdjusted,
280
- previousSeverity: existing?.severity ?? null,
281
- relationshipExpansionTruncated,
282
- }),
283
- },
284
- });
285
- return true;
286
- });
287
- if (accepted)
288
- incMetric('securityFusionEvidenceAccepted');
289
- else
290
- incMetric('securityFusionEvidenceTruncated');
291
- if (rank(caseRow.severity) >= rank('high') && (!existing || reopened || escalated)) {
292
- const transition = !existing ? 'opened' : reopened ? 'reopened' : 'escalated';
293
- await notifyChatChannel(caseRow.id, `${caseRow.id}:${transition}:${caseRow.severity}:${caseRow.lastSeenAt.toISOString()}`, `**Security Fusion ${caseRow.severity.toUpperCase()} case ${transition}**\n${caseRow.title}\n${caseRow.summary}\nCase: ${caseRow.id}`);
294
- }
295
- return caseRow.id;
296
- }
297
- export async function ingestSecurityFusionBatch(batch) {
298
- const db = getDb();
299
- const definition = canonicalJson({
300
- families: ['credential-abuse', 'suspicious-dns-egress', 'ids-endpoint-intrusion', 'endpoint-integrity', 'tailnet-access', 'cross-service-authentication'],
301
- windowMinutes: 15,
302
- fingerprint: 'family+canonical-anchor',
303
- });
304
- await db.securityFusionRuleVersion.upsert({
305
- where: { ruleId_version: { ruleId: 'builtin-security-fusion', version: 1 } },
306
- create: { ruleId: 'builtin-security-fusion', version: 1, definitionJson: definition, sha256: sha256(definition) },
307
- update: {},
308
- });
309
- let stored = 0;
310
- let duplicates = 0;
311
- const caseIds = new Set();
312
- for (const event of batch.events) {
313
- const sanitized = { ...event, attributes: sanitizeDetails(event.attributes) };
314
- const integritySha256 = sha256(canonicalJson(sanitized));
315
- let row;
316
- try {
317
- row = await db.securityFusionEvent.create({
318
- data: {
319
- eventId: event.eventId, source: event.source, sourceEventId: event.sourceEventId,
320
- kind: event.kind, observedAt: new Date(event.observedAt), severity: event.severity,
321
- confidence: event.confidence, summary: event.summary,
322
- attributesJson: JSON.stringify(sanitized.attributes), integritySha256, collectorId: batch.collectorId,
323
- },
324
- });
325
- }
326
- catch (error) {
327
- if (typeof error === 'object' && error && 'code' in error && error.code === 'P2002') {
328
- duplicates += 1;
329
- continue;
330
- }
331
- throw error;
332
- }
333
- stored += 1;
334
- const entityIds = [];
335
- for (const ref of event.entities)
336
- entityIds.push(await upsertEntity(row.id, row.observedAt, ref));
337
- const caseId = await correlate(event, row.id, entityIds);
338
- if (caseId)
339
- caseIds.add(caseId);
340
- await db.securityFusionSourceState.upsert({
341
- where: { source: event.source },
342
- create: { id: event.source, source: event.source, status: 'healthy', lastEventAt: row.observedAt, lastSuccessAt: new Date() },
343
- update: { status: 'healthy', lastEventAt: row.observedAt, lastSuccessAt: new Date(), lastError: null },
344
- });
345
- }
346
- for (const [source, checkpoint] of Object.entries(batch.checkpoints ?? {})) {
347
- await db.securityFusionSourceState.upsert({
348
- where: { source },
349
- create: { id: source, source, status: 'configured', checkpoint },
350
- update: { checkpoint },
351
- });
352
- }
353
- return { stored, duplicates, caseIds: [...caseIds] };
354
- }
355
- export function isSecurityRelevantServiceStatus(status) {
356
- return status === 'failed' || status === 'inactive';
357
- }
358
- export async function ingestMagnusServiceHealth(nodeId, nodeName, services) {
359
- const observedAt = new Date();
360
- const db = getDb();
361
- if (observedAt.getTime() - lastMagnusSourceStateTouchAt >= MAGNUS_SOURCE_STATE_TOUCH_INTERVAL_MS) {
362
- // Heartbeats arrive from the whole fleet every 30 seconds. Source state is
363
- // fleet-wide, so touching the same row for every node only creates row-lock
364
- // and vacuum churn without improving its freshness signal.
365
- lastMagnusSourceStateTouchAt = observedAt.getTime();
366
- try {
367
- await db.securityFusionSourceState.upsert({
368
- where: { source: 'magnus' },
369
- create: {
370
- id: 'magnus',
371
- source: 'magnus',
372
- status: 'healthy',
373
- lastSuccessAt: observedAt,
374
- },
375
- update: {
376
- status: 'healthy',
377
- lastSuccessAt: observedAt,
378
- lastErrorAt: null,
379
- lastError: null,
380
- },
381
- });
382
- }
383
- catch (error) {
384
- lastMagnusSourceStateTouchAt = 0;
385
- throw error;
386
- }
387
- }
388
- const degraded = services.filter((service) => service.name && isSecurityRelevantServiceStatus(service.status));
389
- if (degraded.length === 0)
390
- return;
391
- const day = observedAt.toISOString().slice(0, 10);
392
- const candidates = degraded.slice(0, 50).map((service) => {
393
- const sourceEventId = `${nodeId}:${service.name}:${service.status}:${day}`;
394
- return { service, sourceEventId };
395
- });
396
- const existing = await db.securityFusionEvent.findMany({
397
- where: {
398
- source: 'magnus',
399
- sourceEventId: { in: candidates.map(({ sourceEventId }) => sourceEventId) },
400
- },
401
- select: { sourceEventId: true },
402
- });
403
- const existingIds = new Set(existing.map((event) => event.sourceEventId));
404
- const unseen = candidates.filter(({ sourceEventId }) => !existingIds.has(sourceEventId));
405
- if (unseen.length === 0)
406
- return;
407
- await ingestSecurityFusionBatch({
408
- schemaVersion: '1.0',
409
- collectorId: 'magnus-control-api',
410
- sentAt: observedAt.toISOString(),
411
- events: unseen.map(({ service, sourceEventId }) => {
412
- return {
413
- schemaVersion: '1.0',
414
- eventId: sha256(`magnus:${sourceEventId}`),
415
- source: 'magnus',
416
- sourceEventId,
417
- kind: 'magnus.service.health',
418
- observedAt: observedAt.toISOString(),
419
- severity: service.status === 'failed' ? 'high' : 'medium',
420
- confidence: 0.88,
421
- summary: `${service.name} reported ${service.status} on ${nodeName}`,
422
- entities: [
423
- { type: 'host', value: nodeName },
424
- { type: 'service', value: service.name, label: service.name },
425
- ],
426
- attributes: { nodeId, serviceStatus: service.status },
427
- evidence: [{ sourceLocator: `magnus-heartbeat:${nodeId}:${day}`, mediaType: 'application/json' }],
428
- };
429
- }),
430
- });
431
- }
432
- export async function saveInvestigation(caseId, authorId, draft) {
433
- const latest = await getDb().securityFusionInvestigation.findFirst({ where: { caseId }, orderBy: { revision: 'desc' } });
434
- return getDb().securityFusionInvestigation.create({
435
- data: { caseId, authorId, revision: (latest?.revision ?? 0) + 1, draftJson: JSON.stringify(draft) },
436
- });
437
- }
438
- export async function preserveEvidence(caseId, eventId, actorId) {
439
- const [event, caseLink] = await Promise.all([
440
- getDb().securityFusionEvent.findUnique({ where: { id: eventId } }),
441
- getDb().securityFusionCaseEvent.findUnique({
442
- where: { caseId_eventId: { caseId, eventId } },
443
- select: { id: true },
444
- }),
445
- ]);
446
- if (!event || !caseLink) {
447
- incMetric('securityFusionEvidenceRejected');
448
- throw new HttpError(404, 'NOT_FOUND', 'Security case evidence not found');
449
- }
450
- const payload = canonicalJson({
451
- eventId: event.eventId, source: event.source, sourceEventId: event.sourceEventId,
452
- kind: event.kind, observedAt: event.observedAt.toISOString(), severity: event.severity,
453
- confidence: event.confidence, summary: event.summary, attributes: JSON.parse(event.attributesJson),
454
- integritySha256: event.integritySha256,
455
- });
456
- const digest = sha256(payload);
457
- const byteSize = Buffer.byteLength(payload, 'utf8');
458
- if (byteSize > SECURITY_FUSION_RETENTION_POLICY.maxSingleEvidenceBytes) {
459
- incMetric('securityFusionEvidenceRejected');
460
- throw new HttpError(413, 'EVIDENCE_TOO_LARGE', 'Preserved evidence exceeds the per-artifact byte limit');
461
- }
462
- const base = resolve(process.env['SECURITY_FUSION_EVIDENCE_DIR'] ?? './state/security-fusion/evidence');
463
- const dir = join(base, digest.slice(0, 2));
464
- await ensurePrivateEvidenceDirectory(dir);
465
- const evidenceId = randomUUID();
466
- const target = join(dir, `${digest}.${evidenceId}.json`);
467
- const temporary = `${target}.${randomBytes(6).toString('hex')}.partial`;
468
- await writeFile(temporary, payload, { encoding: 'utf8', mode: 0o600 });
469
- const staged = await readFile(temporary, 'utf8');
470
- if (sha256(staged) !== digest) {
471
- await unlink(temporary).catch(() => undefined);
472
- throw new Error('EVIDENCE_HASH_MISMATCH');
473
- }
474
- let promoted = false;
475
- try {
476
- const row = await getDb().$transaction(async (tx) => {
477
- await tx.$queryRaw `SELECT "id" FROM "security_fusion_cases" WHERE "id" = ${caseId} FOR UPDATE`;
478
- const [existing, count, bytes] = await Promise.all([
479
- tx.securityFusionEvidence.findFirst({
480
- where: { caseId, eventId },
481
- select: { id: true },
482
- }),
483
- tx.securityFusionEvidence.count({ where: { caseId } }),
484
- tx.securityFusionEvidence.aggregate({
485
- where: { caseId },
486
- _sum: { byteSize: true },
487
- }),
488
- ]);
489
- if (existing) {
490
- throw new HttpError(409, 'EVIDENCE_ALREADY_PRESERVED', 'This event is already preserved for the case');
491
- }
492
- const admission = evaluateSecurityFusionEvidenceAdmission(count, bytes._sum.byteSize ?? 0, byteSize);
493
- if (admission !== 'accepted') {
494
- throw new HttpError(409, 'EVIDENCE_CASE_LIMIT', 'The case has reached its preserved-evidence ceiling');
495
- }
496
- await rename(temporary, target);
497
- promoted = true;
498
- const created = await tx.securityFusionEvidence.create({
499
- data: {
500
- id: evidenceId,
501
- caseId,
502
- eventId,
503
- sourceLocator: `${event.source}:${event.sourceEventId}`,
504
- mediaType: 'application/json',
505
- sha256: digest,
506
- storagePath: target,
507
- preservedBy: actorId,
508
- expiresAt: new Date(Date.now() + SECURITY_FUSION_RETENTION_POLICY.evidenceDays * 24 * 60 * 60_000),
509
- byteSize,
510
- },
511
- });
512
- await tx.securityFusionCaseActivity.create({
513
- data: {
514
- caseId,
515
- kind: 'evidence.preserved',
516
- actorId,
517
- detailJson: JSON.stringify({
518
- evidenceId: created.id,
519
- eventId,
520
- sha256: digest,
521
- sourceLocator: created.sourceLocator,
522
- byteSize,
523
- }),
524
- },
525
- });
526
- return created;
527
- });
528
- incMetric('securityFusionEvidenceAccepted');
529
- return row;
530
- }
531
- catch (error) {
532
- await unlink(promoted ? target : temporary).catch(() => undefined);
533
- incMetric('securityFusionEvidenceRejected');
534
- throw error;
535
- }
536
- }
537
- function deletionReceiptData(resourceType, resourceId, reason, outcome, bytesDeleted, detail, deletedAt) {
538
- const resourceIdHash = sha256(resourceId);
539
- const detailJson = canonicalJson(detail);
540
- return {
541
- resourceType,
542
- resourceIdHash,
543
- reason,
544
- outcome,
545
- bytesDeleted,
546
- detailJson,
547
- deletedAt,
548
- };
549
- }
550
- async function deleteEvidenceWithReceipts(evidenceRows, reason, deletedAt) {
551
- if (evidenceRows.length === 0)
552
- return 0;
553
- const receipts = [];
554
- for (const evidence of evidenceRows) {
555
- let outcome = 'deleted';
556
- try {
557
- await unlink(evidence.storagePath);
558
- }
559
- catch (error) {
560
- if (error.code === 'ENOENT')
561
- outcome = 'already_missing';
562
- else
563
- throw error;
564
- }
565
- receipts.push(deletionReceiptData('evidence', evidence.id, reason, outcome, evidence.byteSize, {
566
- caseIdHash: sha256(evidence.caseId),
567
- contentSha256: evidence.sha256,
568
- }, deletedAt));
569
- }
570
- const head = await getDb().$transaction(async (tx) => {
571
- await tx.securityFusionEvidence.deleteMany({
572
- where: { id: { in: evidenceRows.map((row) => row.id) } },
573
- });
574
- return appendDeletionReceipts(tx, receipts);
575
- });
576
- await exportReceiptChainHead(head);
577
- incMetric('securityFusionEvidenceDeleted', evidenceRows.length);
578
- return evidenceRows.length;
579
- }
580
- async function deleteArchivedCase(caseId, archivedAt, deletedAt) {
581
- const db = getDb();
582
- const [evidence, oldActions, counts] = await Promise.all([
583
- db.securityFusionEvidence.findMany({
584
- where: { caseId },
585
- select: { id: true, caseId: true, sha256: true, storagePath: true, byteSize: true },
586
- }),
587
- db.securityFusionAction.findMany({ where: { caseId }, select: { id: true } }),
588
- Promise.all([
589
- db.securityFusionCaseEvent.count({ where: { caseId } }),
590
- db.securityFusionCaseEntity.count({ where: { caseId } }),
591
- db.securityFusionCaseActivity.count({ where: { caseId } }),
592
- db.securityFusionInvestigation.count({ where: { caseId } }),
593
- db.securityFusionAction.count({ where: { caseId } }),
594
- db.securityFusionCaseRelationship.count({
595
- where: { OR: [{ fromCaseId: caseId }, { toCaseId: caseId }] },
596
- }),
597
- ]),
598
- ]);
599
- await deleteEvidenceWithReceipts(evidence, 'case_retention', deletedAt);
600
- const actionIds = oldActions.map((row) => row.id);
601
- const receipt = deletionReceiptData('case', caseId, 'archive_grace_expired', 'deleted', evidence.reduce((sum, row) => sum + row.byteSize, 0), {
602
- archivedAt: archivedAt.toISOString(),
603
- childCounts: {
604
- events: counts[0],
605
- entities: counts[1],
606
- activity: counts[2],
607
- investigations: counts[3],
608
- actions: counts[4],
609
- relationships: counts[5],
610
- evidence: evidence.length,
611
- },
612
- }, deletedAt);
613
- const head = await db.$transaction(async (tx) => {
614
- await tx.securityFusionCaseRelationship.deleteMany({
615
- where: { OR: [{ fromCaseId: caseId }, { toCaseId: caseId }] },
616
- });
617
- await tx.securityFusionCaseActivity.deleteMany({ where: { caseId } });
618
- await tx.securityFusionInvestigation.deleteMany({ where: { caseId } });
619
- await tx.securityFusionCaseEntity.deleteMany({ where: { caseId } });
620
- await tx.securityFusionCaseEvent.deleteMany({ where: { caseId } });
621
- await tx.securityFusionDelivery.deleteMany({ where: { caseId } });
622
- await tx.securityFusionCommandOutbox.deleteMany({ where: { actionId: { in: actionIds } } });
623
- await tx.securityFusionActionApproval.deleteMany({ where: { actionId: { in: actionIds } } });
624
- await tx.securityFusionAction.deleteMany({ where: { caseId } });
625
- await tx.securityFusionCase.delete({ where: { id: caseId } });
626
- return appendDeletionReceipts(tx, [receipt]);
627
- });
628
- await exportReceiptChainHead(head);
629
- incMetric('securityFusionCasesDeleted');
630
- }
631
- async function compactOverfullSecurityFusionCases(compactedAt) {
632
- const db = getDb();
633
- const overfullCases = await db.$queryRaw `
634
- SELECT "caseId", COUNT(*)::int AS count
635
- FROM "security_fusion_case_events"
636
- GROUP BY "caseId"
637
- HAVING COUNT(*) > ${SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents}
638
- `;
639
- let truncated = 0;
640
- for (const caseRow of overfullCases) {
641
- const excessLinks = await db.$queryRaw `
642
- SELECT ce."id", ce."eventId"
643
- FROM "security_fusion_case_events" ce
644
- JOIN "security_fusion_events" e ON e."id" = ce."eventId"
645
- WHERE ce."caseId" = ${caseRow.caseId}
646
- ORDER BY e."observedAt" DESC, ce."id" DESC
647
- OFFSET ${SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents}
648
- `;
649
- if (excessLinks.length === 0)
650
- continue;
651
- const excessActivity = await db.$queryRaw `
652
- SELECT "id"
653
- FROM "security_fusion_case_activity"
654
- WHERE "caseId" = ${caseRow.caseId}
655
- AND "kind" = 'evidence.merged'
656
- ORDER BY "createdAt" ASC, "id" ASC
657
- LIMIT ${excessLinks.length}
658
- `;
659
- const linkIds = excessLinks.map((row) => row.id);
660
- const activityIds = excessActivity.map((row) => row.id);
661
- const receipt = deletionReceiptData('case_evidence_links', `${caseRow.caseId}:${linkIds.sort().join(':')}`, 'per_case_ceiling_compaction', 'truncated', 0, {
662
- caseIdHash: sha256(caseRow.caseId),
663
- linksDeleted: linkIds.length,
664
- activitiesDeleted: activityIds.length,
665
- retainedCeiling: SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents,
666
- }, compactedAt);
667
- const head = await db.$transaction(async (tx) => {
668
- await tx.securityFusionCaseActivity.deleteMany({ where: { id: { in: activityIds } } });
669
- await tx.securityFusionCaseEvent.deleteMany({ where: { id: { in: linkIds } } });
670
- await tx.securityFusionCase.update({
671
- where: { id: caseRow.caseId },
672
- data: {
673
- evidenceAcceptedCount: SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents,
674
- evidenceTruncatedCount: { increment: linkIds.length },
675
- },
676
- });
677
- return appendDeletionReceipts(tx, [receipt]);
678
- });
679
- await exportReceiptChainHead(head);
680
- truncated += linkIds.length;
681
- }
682
- incMetric('securityFusionEvidenceTruncated', truncated);
683
- return truncated;
684
- }
685
- export async function purgeSecurityFusion(now = new Date()) {
686
- const eventCutoff = new Date(now.getTime() - SECURITY_FUSION_RETENTION_POLICY.eventDays * 24 * 60 * 60_000);
687
- const caseArchiveCutoff = new Date(now.getTime() - SECURITY_FUSION_RETENTION_POLICY.caseArchiveDays * 24 * 60 * 60_000);
688
- const caseDeleteCutoff = new Date(now.getTime() - SECURITY_FUSION_RETENTION_POLICY.archiveGraceDays * 24 * 60 * 60_000);
689
- const receiptCutoff = new Date(now.getTime()
690
- - SECURITY_FUSION_RETENTION_POLICY.deletionReceiptDays * 24 * 60 * 60_000);
691
- const [oldEvents, expiredEvidence, casesToDelete] = await Promise.all([
692
- getDb().securityFusionEvent.findMany({ where: { ingestedAt: { lt: eventCutoff } }, select: { id: true } }),
693
- getDb().securityFusionEvidence.findMany({
694
- where: { expiresAt: { lt: now } },
695
- select: { id: true, caseId: true, sha256: true, storagePath: true, byteSize: true },
696
- }),
697
- getDb().securityFusionCase.findMany({
698
- where: { archivedAt: { lt: caseDeleteCutoff } },
699
- select: { id: true, archivedAt: true },
700
- }),
701
- ]);
702
- const truncatedEvidence = await compactOverfullSecurityFusionCases(now);
703
- const evidenceDeleted = await deleteEvidenceWithReceipts(expiredEvidence, 'evidence_retention', now);
704
- for (const caseRow of casesToDelete) {
705
- if (caseRow.archivedAt)
706
- await deleteArchivedCase(caseRow.id, caseRow.archivedAt, now);
707
- }
708
- const eventIds = oldEvents.map((row) => row.id);
709
- const eventReceipt = eventIds.length === 0 ? null : deletionReceiptData('event_batch', eventIds.sort().join(':'), 'event_retention', 'deleted', 0, { count: eventIds.length, cutoff: eventCutoff.toISOString() }, now);
710
- const archiveResult = await getDb().securityFusionCase.updateMany({
711
- where: { archivedAt: null, lastSeenAt: { lt: caseArchiveCutoff } },
712
- data: { archivedAt: now },
713
- });
714
- incMetric('securityFusionCasesArchived', archiveResult.count);
715
- const transactionResult = await getDb().$transaction(async (tx) => {
716
- await tx.securityFusionCaseEvent.deleteMany({ where: { eventId: { in: eventIds } } });
717
- await tx.securityFusionEventEntity.deleteMany({ where: { eventId: { in: eventIds } } });
718
- await tx.securityFusionEvent.deleteMany({ where: { id: { in: eventIds } } });
719
- await tx.securityFusionNonce.deleteMany({ where: { expiresAt: { lt: now } } });
720
- const receiptDeletionResult = await tx.securityFusionDeletionReceipt.deleteMany({
721
- where: { deletedAt: { lt: receiptCutoff } },
722
- });
723
- const head = await appendDeletionReceipts(tx, eventReceipt ? [eventReceipt] : []);
724
- return { receiptDeletionResult, head };
725
- });
726
- await exportReceiptChainHead(transactionResult.head);
727
- return {
728
- events: eventIds.length,
729
- evidence: evidenceDeleted,
730
- truncatedEvidence,
731
- archivedCases: archiveResult.count,
732
- deletedCases: casesToDelete.length,
733
- receipts: transactionResult.receiptDeletionResult.count,
734
- };
735
- }
736
- let retentionTimer = null;
737
- export function startSecurityFusionRetention() {
738
- if (retentionTimer)
739
- return;
740
- void purgeSecurityFusion().catch((error) => console.error('[security-fusion] retention failed', error));
741
- retentionTimer = setInterval(() => {
742
- void purgeSecurityFusion().catch((error) => console.error('[security-fusion] retention failed', error));
743
- }, 60 * 60_000);
744
- retentionTimer.unref();
745
- }
746
- export function stopSecurityFusionRetention() {
747
- if (retentionTimer)
748
- clearInterval(retentionTimer);
749
- retentionTimer = null;
750
- }
751
- //# sourceMappingURL=security-fusion-service.js.map