@hivemagnus/magnus-server 1.1.12 → 2.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.
Files changed (33) hide show
  1. package/.env.example +5 -0
  2. package/README.md +4 -0
  3. package/agent-version.json +1 -1
  4. package/dist/cli/env-catalog.js +3 -0
  5. package/dist/config/env.js +4 -5
  6. package/dist/config/features.js +1 -1
  7. package/dist/db/generated/prisma/internal/class.js +4 -4
  8. package/dist/db/generated/prisma/internal/prismaNamespace.js +12 -1
  9. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +12 -1
  10. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  11. package/dist/index.js +1 -16
  12. package/dist/openapi/spec.js +7 -2
  13. package/dist/queue/workers.js +0 -65
  14. package/dist/routes/cindertrace-response.js +183 -0
  15. package/dist/routes/heartbeat.js +1 -6
  16. package/dist/routes/security-fusion.js +15 -600
  17. package/dist/sdk/node-api-client.js +12 -0
  18. package/dist/security/cindertrace-response.js +14 -0
  19. package/dist/security/native-completion-registry.js +12 -0
  20. package/dist/server.js +3 -14
  21. package/dist/services/command-resource-leases.js +2 -4
  22. package/dist/services/security-export.js +4 -6
  23. package/package.json +3 -3
  24. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  25. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  26. package/prisma/schema.prisma +13 -0
  27. package/dist/services/security-fusion-actions.js +0 -22
  28. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  29. package/dist/services/security-fusion-ingest.js +0 -101
  30. package/dist/services/security-fusion-outbox.js +0 -185
  31. package/dist/services/security-fusion-receipt-chain.js +0 -134
  32. package/dist/services/security-fusion-service.js +0 -751
  33. package/dist/services/security-fusion-verification.js +0 -50
@@ -1,185 +0,0 @@
1
- import { getDb } from '../db/index.js';
2
- import { enqueueCommand } from '../queue/workers.js';
3
- import { securityFusionActionSources } from './security-fusion-actions.js';
4
- const OUTBOX_LEASE_MS = 30_000;
5
- const OUTBOX_INTERVAL_MS = 2_000;
6
- /**
7
- * Absolute path of an operator-installed containment helper on managed nodes
8
- * (`SECURITY_FUSION_CONTAINMENT_COMMAND`). Magnus does not ship one; without it
9
- * release records fail closed and stay visible for manual handling.
10
- */
11
- function containmentCommand() {
12
- const command = process.env['SECURITY_FUSION_CONTAINMENT_COMMAND']?.trim() ?? '';
13
- if (!/^\/[A-Za-z0-9._/-]{1,255}$/.test(command) || command.includes('..'))
14
- throw new Error('SECURITY_FUSION_CONTAINMENT_COMMAND is not configured');
15
- return command;
16
- }
17
- function containmentArgs(operation) {
18
- if (operation !== 'release')
19
- throw new Error('Embedded containment execute authority is retired');
20
- return ['release'];
21
- }
22
- async function dispatchOutboxRecord(outboxId) {
23
- const db = getDb();
24
- const now = new Date();
25
- const claimed = await db.securityFusionCommandOutbox.updateMany({
26
- where: {
27
- id: outboxId,
28
- operation: 'release',
29
- OR: [
30
- { status: { in: ['pending', 'failed'] }, nextAttemptAt: { lte: now } },
31
- { status: 'dispatching', leaseExpiresAt: { lt: now } },
32
- ],
33
- },
34
- data: {
35
- status: 'dispatching',
36
- leaseExpiresAt: new Date(now.getTime() + OUTBOX_LEASE_MS),
37
- attempts: { increment: 1 },
38
- lastError: null,
39
- },
40
- });
41
- if (claimed.count !== 1)
42
- return false;
43
- const outbox = await db.securityFusionCommandOutbox.findUnique({ where: { id: outboxId } });
44
- const action = outbox
45
- ? await db.securityFusionAction.findUnique({ where: { id: outbox.actionId } })
46
- : null;
47
- if (!outbox || !action)
48
- throw new Error('Security Fusion outbox references a missing action');
49
- const node = await db.node.findUnique({ where: { id: action.nodeId } });
50
- if (!node)
51
- throw new Error('Security Fusion containment target no longer exists');
52
- const command = containmentCommand();
53
- const args = containmentArgs(outbox.operation);
54
- const idempotencyKey = `security-fusion:${action.id}:${node.id}:${outbox.operation}`;
55
- const remote = await db.remoteCommand.upsert({
56
- where: { idempotencyKey },
57
- create: {
58
- nodeId: node.id,
59
- command,
60
- args: JSON.stringify(args),
61
- environment: '{}',
62
- timeoutSeconds: 120,
63
- status: 'QUEUED',
64
- riskLevel: 'high',
65
- idempotencyKey,
66
- correlationId: outbox.id,
67
- createdBy: outbox.actorId,
68
- },
69
- update: {},
70
- });
71
- await db.securityFusionCommandOutbox.update({
72
- where: { id: outbox.id },
73
- data: { remoteCommandId: remote.id },
74
- });
75
- await enqueueCommand(remote.id);
76
- await db.$transaction([
77
- db.securityFusionCommandOutbox.update({
78
- where: { id: outbox.id },
79
- data: {
80
- status: 'dispatched',
81
- leaseExpiresAt: null,
82
- lastError: null,
83
- dispatchedAt: new Date(),
84
- },
85
- }),
86
- db.securityFusionAction.updateMany({
87
- where: {
88
- id: action.id,
89
- status: {
90
- in: securityFusionActionSources('releasing'),
91
- },
92
- },
93
- data: {
94
- status: 'releasing',
95
- resultJson: JSON.stringify({
96
- commandId: remote.id,
97
- outboxId: outbox.id,
98
- operation: outbox.operation,
99
- }),
100
- },
101
- }),
102
- ]);
103
- return true;
104
- }
105
- export async function dispatchSecurityFusionOutbox() {
106
- const db = getDb();
107
- const now = new Date();
108
- // Initial containment belongs exclusively to the standalone Security Fusion
109
- // service. Keep only the independent emergency release path in this embedded
110
- // compatibility service, and quarantine any row old code could retry.
111
- await db.securityFusionCommandOutbox.updateMany({
112
- where: {
113
- operation: 'execute',
114
- status: { in: ['pending', 'failed'] },
115
- },
116
- data: {
117
- status: 'blocked',
118
- leaseExpiresAt: null,
119
- lastError: 'Embedded execute quarantined; standalone Security Fusion approval is required',
120
- },
121
- });
122
- await db.securityFusionCommandOutbox.updateMany({
123
- where: {
124
- operation: 'execute',
125
- status: { in: ['dispatching', 'unknown'] },
126
- },
127
- data: {
128
- status: 'blocked',
129
- leaseExpiresAt: null,
130
- lastError: 'Embedded execute quarantined; execution may have occurred; do not retry',
131
- },
132
- });
133
- const records = await db.securityFusionCommandOutbox.findMany({
134
- where: {
135
- operation: 'release',
136
- OR: [
137
- { status: { in: ['pending', 'failed'] }, nextAttemptAt: { lte: now } },
138
- { status: 'dispatching', leaseExpiresAt: { lt: now } },
139
- ],
140
- },
141
- orderBy: { createdAt: 'asc' },
142
- take: 20,
143
- });
144
- let dispatched = 0;
145
- for (const record of records) {
146
- try {
147
- if (await dispatchOutboxRecord(record.id))
148
- dispatched += 1;
149
- }
150
- catch (error) {
151
- const message = error instanceof Error ? error.message : String(error);
152
- const retryDelay = Math.min(60_000, 1_000 * (2 ** Math.min(record.attempts, 6)));
153
- await db.securityFusionCommandOutbox.updateMany({
154
- where: { id: record.id, status: 'dispatching' },
155
- data: {
156
- status: 'failed',
157
- leaseExpiresAt: null,
158
- lastError: message.slice(0, 1000),
159
- nextAttemptAt: new Date(Date.now() + retryDelay),
160
- },
161
- });
162
- console.error(`[security-fusion] outbox dispatch failed for ${record.id}: ${message}`);
163
- }
164
- }
165
- return dispatched;
166
- }
167
- let dispatcherTimer = null;
168
- export function startSecurityFusionOutboxDispatcher() {
169
- if (dispatcherTimer)
170
- return;
171
- const dispatch = () => {
172
- void dispatchSecurityFusionOutbox().catch((error) => {
173
- console.error('[security-fusion] outbox scan failed', error instanceof Error ? error.message : String(error));
174
- });
175
- };
176
- dispatch();
177
- dispatcherTimer = setInterval(dispatch, OUTBOX_INTERVAL_MS);
178
- dispatcherTimer.unref();
179
- }
180
- export function stopSecurityFusionOutboxDispatcher() {
181
- if (dispatcherTimer)
182
- clearInterval(dispatcherTimer);
183
- dispatcherTimer = null;
184
- }
185
- //# sourceMappingURL=security-fusion-outbox.js.map
@@ -1,134 +0,0 @@
1
- import { createHmac, createHash } from 'node:crypto';
2
- import { appendFile, chmod, lstat, mkdir, open } from 'node:fs/promises';
3
- import { dirname, resolve } from 'node:path';
4
- import { getDb } from '../db/index.js';
5
- function canonicalJson(value) {
6
- if (Array.isArray(value))
7
- return `[${value.map(canonicalJson).join(',')}]`;
8
- if (value && typeof value === 'object') {
9
- return `{${Object.entries(value)
10
- .sort(([a], [b]) => a.localeCompare(b))
11
- .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
12
- }
13
- return JSON.stringify(value);
14
- }
15
- export function deletionReceiptHash(input, previousReceiptSha256, chainSequence) {
16
- return createHash('sha256').update(canonicalJson({
17
- resourceType: input.resourceType,
18
- resourceIdHash: input.resourceIdHash,
19
- reason: input.reason,
20
- outcome: input.outcome,
21
- bytesDeleted: input.bytesDeleted,
22
- detail: JSON.parse(input.detailJson),
23
- deletedAt: input.deletedAt.toISOString(),
24
- previousReceiptSha256,
25
- chainSequence,
26
- chainVersion: 2,
27
- })).digest('hex');
28
- }
29
- /**
30
- * Append receipts while holding the singleton PostgreSQL chain-head row lock.
31
- * This serializes all writers across Control API processes.
32
- */
33
- export async function appendDeletionReceipts(tx, inputs) {
34
- const [head] = await tx.$queryRaw `
35
- SELECT "headSha256", "chainSequence"
36
- FROM "security_fusion_receipt_chain_heads"
37
- WHERE "id" = 'security-fusion'
38
- FOR UPDATE
39
- `;
40
- if (!head)
41
- throw new Error('Security Fusion receipt chain head is missing');
42
- let previousReceiptSha256 = head.headSha256;
43
- let chainSequence = head.chainSequence;
44
- for (const input of inputs) {
45
- chainSequence += 1;
46
- const receiptSha256 = deletionReceiptHash(input, previousReceiptSha256, chainSequence);
47
- await tx.securityFusionDeletionReceipt.create({
48
- data: {
49
- ...input,
50
- receiptSha256,
51
- previousReceiptSha256,
52
- chainSequence,
53
- chainVersion: 2,
54
- },
55
- });
56
- previousReceiptSha256 = receiptSha256;
57
- }
58
- if (inputs.length > 0) {
59
- await tx.securityFusionReceiptChainHead.update({
60
- where: { id: 'security-fusion' },
61
- data: { headSha256: previousReceiptSha256, chainSequence },
62
- });
63
- }
64
- return { headSha256: previousReceiptSha256, chainSequence };
65
- }
66
- function chainHeadExportPath() {
67
- return resolve(process.env['SECURITY_FUSION_RECEIPT_CHAIN_EXPORT']
68
- ?? './state/security-fusion/receipt-chain-heads.jsonl');
69
- }
70
- /** Durably export the current chain head outside the mutable database. */
71
- export async function exportReceiptChainHead(head) {
72
- const current = head ?? await getDb().securityFusionReceiptChainHead.findUnique({
73
- where: { id: 'security-fusion' },
74
- select: { headSha256: true, chainSequence: true },
75
- });
76
- if (!current)
77
- throw new Error('Security Fusion receipt chain head is missing');
78
- const path = chainHeadExportPath();
79
- const directory = dirname(path);
80
- await mkdir(directory, { recursive: true, mode: 0o700 });
81
- const expectedUid = typeof process.geteuid === 'function' ? process.geteuid() : null;
82
- const directoryInfo = await lstat(directory);
83
- if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()
84
- || (expectedUid !== null && directoryInfo.uid !== expectedUid)) {
85
- throw new Error('Security Fusion receipt chain export directory is unsafe');
86
- }
87
- await chmod(directory, 0o700);
88
- try {
89
- const info = await lstat(path);
90
- if (!info.isFile() || info.isSymbolicLink()
91
- || (expectedUid !== null && info.uid !== expectedUid)
92
- || (info.mode & 0o777) !== 0o600) {
93
- throw new Error('Security Fusion receipt chain export must be a mode-0600 regular file');
94
- }
95
- }
96
- catch (error) {
97
- if (error.code !== 'ENOENT')
98
- throw error;
99
- }
100
- const exportedAt = new Date().toISOString();
101
- const signingKey = process.env['SECURITY_FUSION_RECEIPT_SIGNING_KEY'];
102
- if (!signingKey || signingKey.length < 32) {
103
- throw new Error('SECURITY_FUSION_RECEIPT_SIGNING_KEY must contain at least 32 characters');
104
- }
105
- const signedPayload = canonicalJson({
106
- chain: 'security-fusion',
107
- chainSequence: current.chainSequence,
108
- headSha256: current.headSha256,
109
- exportedAt,
110
- });
111
- const record = {
112
- chain: 'security-fusion',
113
- chainSequence: current.chainSequence,
114
- headSha256: current.headSha256,
115
- exportedAt,
116
- signatureHmacSha256: createHmac('sha256', signingKey).update(signedPayload).digest('hex'),
117
- };
118
- const handle = await open(path, 'a', 0o600);
119
- try {
120
- await appendFile(handle, `${JSON.stringify(record)}\n`, 'utf8');
121
- await handle.sync();
122
- }
123
- finally {
124
- await handle.close();
125
- }
126
- const directoryHandle = await open(directory, 'r');
127
- try {
128
- await directoryHandle.sync();
129
- }
130
- finally {
131
- await directoryHandle.close();
132
- }
133
- }
134
- //# sourceMappingURL=security-fusion-receipt-chain.js.map