@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.
- package/.env.example +5 -0
- package/README.md +4 -0
- package/agent-version.json +1 -1
- package/dist/cli/env-catalog.js +3 -0
- package/dist/config/env.js +4 -5
- package/dist/config/features.js +1 -1
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +12 -1
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +12 -1
- package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
- package/dist/index.js +1 -16
- package/dist/openapi/spec.js +7 -2
- package/dist/queue/workers.js +0 -65
- package/dist/routes/cindertrace-response.js +183 -0
- package/dist/routes/heartbeat.js +1 -6
- package/dist/routes/security-fusion.js +15 -600
- package/dist/sdk/node-api-client.js +12 -0
- package/dist/security/cindertrace-response.js +14 -0
- package/dist/security/native-completion-registry.js +12 -0
- package/dist/server.js +3 -14
- package/dist/services/command-resource-leases.js +2 -4
- package/dist/services/security-export.js +4 -6
- package/package.json +3 -3
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
- package/prisma/schema.prisma +13 -0
- package/dist/services/security-fusion-actions.js +0 -22
- package/dist/services/security-fusion-evidence-integrity.js +0 -144
- package/dist/services/security-fusion-ingest.js +0 -101
- package/dist/services/security-fusion-outbox.js +0 -185
- package/dist/services/security-fusion-receipt-chain.js +0 -134
- package/dist/services/security-fusion-service.js +0 -751
- package/dist/services/security-fusion-verification.js +0 -50
|
@@ -172,7 +172,8 @@ export const ModelName = {
|
|
|
172
172
|
ResourceDrainParticipant: 'ResourceDrainParticipant',
|
|
173
173
|
ResourceParticipantWork: 'ResourceParticipantWork',
|
|
174
174
|
ManagedCronDefinition: 'ManagedCronDefinition',
|
|
175
|
-
ManagedCronOccurrence: 'ManagedCronOccurrence'
|
|
175
|
+
ManagedCronOccurrence: 'ManagedCronOccurrence',
|
|
176
|
+
CindertraceResponse: 'CindertraceResponse'
|
|
176
177
|
};
|
|
177
178
|
/**
|
|
178
179
|
* Enums
|
|
@@ -1582,6 +1583,16 @@ export const ManagedCronOccurrenceScalarFieldEnum = {
|
|
|
1582
1583
|
admissionJson: 'admissionJson',
|
|
1583
1584
|
createdAt: 'createdAt'
|
|
1584
1585
|
};
|
|
1586
|
+
export const CindertraceResponseScalarFieldEnum = {
|
|
1587
|
+
id: 'id',
|
|
1588
|
+
actionOperation: 'actionOperation',
|
|
1589
|
+
nodeId: 'nodeId',
|
|
1590
|
+
requestSha256: 'requestSha256',
|
|
1591
|
+
requestJson: 'requestJson',
|
|
1592
|
+
status: 'status',
|
|
1593
|
+
responseJson: 'responseJson',
|
|
1594
|
+
createdAt: 'createdAt'
|
|
1595
|
+
};
|
|
1585
1596
|
export const SortOrder = {
|
|
1586
1597
|
asc: 'asc',
|
|
1587
1598
|
desc: 'desc'
|
|
@@ -144,7 +144,8 @@ export const ModelName = {
|
|
|
144
144
|
ResourceDrainParticipant: 'ResourceDrainParticipant',
|
|
145
145
|
ResourceParticipantWork: 'ResourceParticipantWork',
|
|
146
146
|
ManagedCronDefinition: 'ManagedCronDefinition',
|
|
147
|
-
ManagedCronOccurrence: 'ManagedCronOccurrence'
|
|
147
|
+
ManagedCronOccurrence: 'ManagedCronOccurrence',
|
|
148
|
+
CindertraceResponse: 'CindertraceResponse'
|
|
148
149
|
};
|
|
149
150
|
/*
|
|
150
151
|
* Enums
|
|
@@ -1554,6 +1555,16 @@ export const ManagedCronOccurrenceScalarFieldEnum = {
|
|
|
1554
1555
|
admissionJson: 'admissionJson',
|
|
1555
1556
|
createdAt: 'createdAt'
|
|
1556
1557
|
};
|
|
1558
|
+
export const CindertraceResponseScalarFieldEnum = {
|
|
1559
|
+
id: 'id',
|
|
1560
|
+
actionOperation: 'actionOperation',
|
|
1561
|
+
nodeId: 'nodeId',
|
|
1562
|
+
requestSha256: 'requestSha256',
|
|
1563
|
+
requestJson: 'requestJson',
|
|
1564
|
+
status: 'status',
|
|
1565
|
+
responseJson: 'responseJson',
|
|
1566
|
+
createdAt: 'createdAt'
|
|
1567
|
+
};
|
|
1557
1568
|
export const SortOrder = {
|
|
1558
1569
|
asc: 'asc',
|
|
1559
1570
|
desc: 'desc'
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ if (existsSync(envFile))
|
|
|
14
14
|
// Validate required secrets before starting anything
|
|
15
15
|
const { validateSecrets } = await import('./utils/env.js');
|
|
16
16
|
validateSecrets();
|
|
17
|
-
const {
|
|
17
|
+
const { securityFusionMode, startSecurityExporter, stopSecurityExporter } = await import('./services/security-export.js');
|
|
18
18
|
const { featureFlags } = await import('./config/features.js');
|
|
19
19
|
const { serve } = await import('@hono/node-server');
|
|
20
20
|
const { app } = await import('./server.js');
|
|
@@ -30,9 +30,6 @@ const { stopLogRetention } = await import('./services/log-retention.js');
|
|
|
30
30
|
const { startHealthRetention } = await import('./services/health-retention.js');
|
|
31
31
|
const { stopHealthRetention } = await import('./services/health-retention.js');
|
|
32
32
|
const { startNotificationRetention, stopNotificationRetention } = await import('./services/notification-retention.js');
|
|
33
|
-
const { startSecurityFusionRetention, stopSecurityFusionRetention } = await import('./services/security-fusion-service.js');
|
|
34
|
-
const { reconcileSecurityFusionEvidence } = await import('./services/security-fusion-evidence-integrity.js');
|
|
35
|
-
const { startSecurityFusionOutboxDispatcher, stopSecurityFusionOutboxDispatcher, } = await import('./services/security-fusion-outbox.js');
|
|
36
33
|
const { startCommandWorker, closeCommandQueue } = await import('./queue/workers.js');
|
|
37
34
|
const { startUpdateCheck, stopUpdateCheck } = await import('./services/update-check-service.js');
|
|
38
35
|
const { startWebhookWorker, closeWebhookQueue } = await import('./services/webhook-service.js');
|
|
@@ -47,12 +44,6 @@ const port = Number(process.env['CONTROL_API_PORT'] ?? 4000);
|
|
|
47
44
|
const features = featureFlags();
|
|
48
45
|
console.log(`[startup] security-fusion=${securityFusionMode()} hermes-registry=${features.hermesRegistry ? 'on' : 'off'} `
|
|
49
46
|
+ `plugin-api=${features.pluginApi ? 'on' : 'off'} iam=${features.iam ? 'on' : 'off'}`);
|
|
50
|
-
if (securityFusionLocal()) {
|
|
51
|
-
const evidenceReconciliation = await reconcileSecurityFusionEvidence();
|
|
52
|
-
console.log(`[security-fusion] evidence reconciliation checked=${evidenceReconciliation.checked} `
|
|
53
|
-
+ `updated=${evidenceReconciliation.updated} quarantined=${evidenceReconciliation.quarantined} `
|
|
54
|
-
+ `mismatched_cases=${evidenceReconciliation.mismatchedCases}`);
|
|
55
|
-
}
|
|
56
47
|
const { writeRuntimeFile } = await import('./utils/runtime-file.js');
|
|
57
48
|
const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
58
49
|
/** Background services shared by the TLS and development listeners. */
|
|
@@ -64,10 +55,6 @@ function startBackgroundServices(listenPort) {
|
|
|
64
55
|
startLogRetention();
|
|
65
56
|
startHealthRetention();
|
|
66
57
|
startNotificationRetention();
|
|
67
|
-
if (securityFusionLocal()) {
|
|
68
|
-
startSecurityFusionRetention();
|
|
69
|
-
startSecurityFusionOutboxDispatcher();
|
|
70
|
-
}
|
|
71
58
|
startSecurityExporter();
|
|
72
59
|
startCommandWorker();
|
|
73
60
|
startRolloutReconciler();
|
|
@@ -137,9 +124,7 @@ async function shutdown(signal) {
|
|
|
137
124
|
stopLogRetention();
|
|
138
125
|
stopHealthRetention();
|
|
139
126
|
stopNotificationRetention();
|
|
140
|
-
stopSecurityFusionRetention();
|
|
141
127
|
stopSecurityExporter();
|
|
142
|
-
stopSecurityFusionOutboxDispatcher();
|
|
143
128
|
stopCodexFleetMonitor();
|
|
144
129
|
stopUpdateCheck();
|
|
145
130
|
stopExtensionSourceMonitor();
|
package/dist/openapi/spec.js
CHANGED
|
@@ -5,7 +5,7 @@ import { participantWorkReconciliationSchema, historicalParticipantWorkReconcili
|
|
|
5
5
|
import { sharedResourceLeaseIdentitySchema, sharedResourceLeaseViewSchema, sharedResourceReconciliationSchema, sharedResourceReserveSchema, sharedResourceTerminalEvidenceSchema, } from '../schemas/shared-resource-lease.js';
|
|
6
6
|
import { managedCronRegistrationSchema, schedulerSourceSettlementSchema } from '../schemas/managed-cron.js';
|
|
7
7
|
import { legacyAdmissionRequestSchema, resourceClaimSchema } from '../security/managed-http.js';
|
|
8
|
-
import { magnusObservationSchema } from '@hivemagnus/magnus-contracts';
|
|
8
|
+
import { magnusObservationSchema, responseRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
9
9
|
import { maintenanceSchema } from '../services/maintenance-service.js';
|
|
10
10
|
import { rolloutInputSchema } from '../services/rollout-service.js';
|
|
11
11
|
import { lessonSchema } from '../services/incident-lessons.js';
|
|
@@ -2703,6 +2703,7 @@ export function buildOpenApiSpec() {
|
|
|
2703
2703
|
tags,
|
|
2704
2704
|
components: {
|
|
2705
2705
|
securitySchemes: {
|
|
2706
|
+
cindertraceSignature: { type: 'apiKey', in: 'header', name: 'x-cindertrace-signature', description: 'Dedicated response HMAC bound to method, path, exact body, x-cindertrace-time and x-cindertrace-nonce. Responses are signed against the same request binding.' },
|
|
2706
2707
|
apiSecret: {
|
|
2707
2708
|
type: 'apiKey',
|
|
2708
2709
|
in: 'header',
|
|
@@ -2734,7 +2735,11 @@ export function buildOpenApiSpec() {
|
|
|
2734
2735
|
},
|
|
2735
2736
|
schemas: componentSchemas,
|
|
2736
2737
|
},
|
|
2737
|
-
paths,
|
|
2738
|
+
paths: { ...paths,
|
|
2739
|
+
'/api/v1/security-integration/response/execute': { post: { summary: 'Execute an exact Cindertrace-approved response through managed node admission', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], requestBody: { required: true, content: { 'application/json': { schema: zSchema.toJSONSchema(responseRequestSchema) } } }, responses: { 200: { description: 'Signed completion receipt' }, 409: { description: 'Expired, already claimed, blocked, or uncertain execution' } } } },
|
|
2740
|
+
'/api/v1/security-integration/response/{id}': { get: { summary: 'Read an existing Cindertrace execution without replay', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Signed existing execution status' } } } },
|
|
2741
|
+
'/api/v1/security-integration/response/targets/{id}/health': { get: { summary: 'Read fresh target health for Cindertrace', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Signed minimal health snapshot' } } } },
|
|
2742
|
+
},
|
|
2738
2743
|
};
|
|
2739
2744
|
}
|
|
2740
2745
|
//# sourceMappingURL=spec.js.map
|
package/dist/queue/workers.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { ResourceLeaseConflict } from '../services/command-resource-leases.js';
|
|
2
|
-
import { securityFusionLocal } from '../services/security-export.js';
|
|
3
2
|
import { recordMonitorPulse } from '../services/monitor-pulse.js';
|
|
4
3
|
import { Queue, Worker } from 'bullmq';
|
|
5
4
|
import { addDurableJob } from './requeue.js';
|
|
6
5
|
import { validateRemediationCommandForDispatch } from '../services/remediation-service.js';
|
|
7
6
|
import { reconcileCommands } from '../services/command-lifecycle.js';
|
|
8
|
-
import { reconcileRestartVerification } from '../services/security-fusion-verification.js';
|
|
9
7
|
import { getDb } from '../db/index.js';
|
|
10
8
|
import { NodeApiClient } from '../sdk/index.js';
|
|
11
9
|
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
@@ -14,7 +12,6 @@ import { RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET, getNodeManagerSe
|
|
|
14
12
|
import { safeParse } from '../utils/validate.js';
|
|
15
13
|
import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
|
|
16
14
|
import { incMetric } from '../routes/metrics.js';
|
|
17
|
-
import { securityFusionActionSources } from '../services/security-fusion-actions.js';
|
|
18
15
|
import { verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
19
16
|
// ─── Command execution queue ───
|
|
20
17
|
const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
|
|
@@ -69,59 +66,6 @@ export async function enqueueCommand(commandId) {
|
|
|
69
66
|
removeOnFail: { age: 24 * 60 * 60, count: 1_000 },
|
|
70
67
|
});
|
|
71
68
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Verify agent HMAC-SHA256 signature over command result fields.
|
|
74
|
-
* Returns true if valid, false if tampered. Skips if secret is not configured.
|
|
75
|
-
*/
|
|
76
|
-
async function recordSecurityFusionCommandResult(commandId, succeeded, detail) {
|
|
77
|
-
const db = getDb();
|
|
78
|
-
const outbox = await db.securityFusionCommandOutbox.findUnique({
|
|
79
|
-
where: { remoteCommandId: commandId },
|
|
80
|
-
});
|
|
81
|
-
const action = outbox
|
|
82
|
-
? await db.securityFusionAction.findUnique({ where: { id: outbox.actionId } })
|
|
83
|
-
: null;
|
|
84
|
-
if (action && outbox) {
|
|
85
|
-
if (succeeded && action.kind === 'service_restart') {
|
|
86
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
87
|
-
where: { id: action.id, status: { in: securityFusionActionSources('verifying') } },
|
|
88
|
-
data: { status: 'verifying', resultJson: JSON.stringify({ commandId, ...detail }) },
|
|
89
|
-
});
|
|
90
|
-
if (transitioned.count !== 1)
|
|
91
|
-
return;
|
|
92
|
-
await db.securityFusionCaseActivity.create({
|
|
93
|
-
data: {
|
|
94
|
-
caseId: action.caseId,
|
|
95
|
-
kind: 'containment.restart_verifying',
|
|
96
|
-
actorId: 'system:command-worker',
|
|
97
|
-
detailJson: JSON.stringify({ actionId: action.id, commandId }),
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
const releasing = outbox.operation === 'release';
|
|
103
|
-
const status = succeeded ? (releasing ? 'released' : 'succeeded') : 'failed';
|
|
104
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
105
|
-
where: {
|
|
106
|
-
id: action.id,
|
|
107
|
-
status: {
|
|
108
|
-
in: securityFusionActionSources(status),
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
data: { status, resultJson: JSON.stringify({ commandId, ...detail }) },
|
|
112
|
-
});
|
|
113
|
-
if (transitioned.count !== 1)
|
|
114
|
-
return;
|
|
115
|
-
await db.securityFusionCaseActivity.create({
|
|
116
|
-
data: {
|
|
117
|
-
caseId: action.caseId,
|
|
118
|
-
kind: `containment.${status}`,
|
|
119
|
-
actorId: 'system:command-worker',
|
|
120
|
-
detailJson: JSON.stringify({ actionId: action.id, commandId, ...detail }),
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
69
|
/** Start the command execution worker. */
|
|
126
70
|
export function startCommandWorker() {
|
|
127
71
|
if (_commandWorker)
|
|
@@ -241,11 +185,6 @@ export function startCommandWorker() {
|
|
|
241
185
|
});
|
|
242
186
|
if (!recorded)
|
|
243
187
|
return;
|
|
244
|
-
if (securityFusionLocal())
|
|
245
|
-
await recordSecurityFusionCommandResult(commandId, result.exitCode === 0, {
|
|
246
|
-
exitCode: result.exitCode,
|
|
247
|
-
durationMs: result.durationMs,
|
|
248
|
-
});
|
|
249
188
|
// Emit notification on command failure
|
|
250
189
|
if (result.exitCode !== 0) {
|
|
251
190
|
// Look up node name for the notification
|
|
@@ -287,8 +226,6 @@ export function startCommandWorker() {
|
|
|
287
226
|
});
|
|
288
227
|
if (!failed)
|
|
289
228
|
throw err;
|
|
290
|
-
if (securityFusionLocal())
|
|
291
|
-
await recordSecurityFusionCommandResult(commandId, false, { error: errMsg });
|
|
292
229
|
// Emit notification on worker-level failure
|
|
293
230
|
const node = nodeId ? await db.node.findUnique({
|
|
294
231
|
where: { id: nodeId },
|
|
@@ -321,8 +258,6 @@ export function startCommandWorker() {
|
|
|
321
258
|
reconciliationBusy = true;
|
|
322
259
|
try {
|
|
323
260
|
await reconcileCommands();
|
|
324
|
-
if (securityFusionLocal())
|
|
325
|
-
await reconcileRestartVerification();
|
|
326
261
|
recordMonitorPulse('commands');
|
|
327
262
|
}
|
|
328
263
|
catch {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { bridgeSignature, equalSignature } from '../security/cindertrace-response.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { responseRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
7
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
8
|
+
import { getNodeManagerSecret } from '../utils/env.js';
|
|
9
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
10
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
11
|
+
export const cindertraceResponseRouter = new Hono();
|
|
12
|
+
function secret() {
|
|
13
|
+
const value = process.env['CINDERTRACE_RESPONSE_SECRET'] ?? '';
|
|
14
|
+
if (value.length < 32 ||
|
|
15
|
+
value === process.env['API_SECRET'] ||
|
|
16
|
+
value === process.env['NODE_MANAGER_SECRET'])
|
|
17
|
+
throw new HttpError(503, 'RESPONSE_DISABLED', 'Dedicated Cindertrace response credential is required');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
cindertraceResponseRouter.use('*', async (c, next) => {
|
|
21
|
+
const timestamp = c.req.header('x-cindertrace-time') ?? '', nonce = c.req.header('x-cindertrace-nonce') ?? '';
|
|
22
|
+
if (!/^\d{13}$/.test(timestamp) ||
|
|
23
|
+
Math.abs(Date.now() - Number(timestamp)) > 60000 ||
|
|
24
|
+
!/^[a-f0-9]{32}$/.test(nonce))
|
|
25
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Invalid response request');
|
|
26
|
+
const raw = c.req.method === 'GET' ? '' : await c.req.text();
|
|
27
|
+
if (Buffer.byteLength(raw) > 16384 ||
|
|
28
|
+
!equalSignature(c.req.header('x-cindertrace-signature') ?? '', bridgeSignature(secret(), c.req.method, c.req.path, timestamp, nonce, raw)))
|
|
29
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Invalid response request');
|
|
30
|
+
await next();
|
|
31
|
+
// Sign the exact response body and bind it to this request; no source output or credentials are returned.
|
|
32
|
+
const text = await c.res.clone().text();
|
|
33
|
+
c.header('x-cindertrace-signature', bridgeSignature(secret(), 'RESPONSE', c.req.path, timestamp, nonce, text));
|
|
34
|
+
c.header('Cache-Control', 'no-store');
|
|
35
|
+
});
|
|
36
|
+
async function client(nodeId) {
|
|
37
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId } });
|
|
38
|
+
if (!node)
|
|
39
|
+
throw new HttpError(404, 'TARGET_NOT_FOUND', 'Target is unavailable');
|
|
40
|
+
return {
|
|
41
|
+
node,
|
|
42
|
+
api: new NodeApiClient({
|
|
43
|
+
baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
|
|
44
|
+
nodeToken: getNodeManagerSecret(),
|
|
45
|
+
resourceNodeId: node.id,
|
|
46
|
+
resourceActorId: 'service:cindertrace',
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
cindertraceResponseRouter.get('/targets/:id/health', async (c) => {
|
|
51
|
+
const { api } = await client(c.req.param('id'));
|
|
52
|
+
const health = await api.collectHealth();
|
|
53
|
+
return c.json({
|
|
54
|
+
data: {
|
|
55
|
+
services: health.data?.services ?? [],
|
|
56
|
+
wazuh: health.data?.wazuh,
|
|
57
|
+
responseCapable: await api.securityResponseCapable(),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
export function validateResponsePolicy(p, node) {
|
|
62
|
+
if (p.operation === 'release')
|
|
63
|
+
return;
|
|
64
|
+
if (node.status !== 'ONLINE' ||
|
|
65
|
+
!node.lastHeartbeatAt ||
|
|
66
|
+
Date.now() - node.lastHeartbeatAt.getTime() > 120000)
|
|
67
|
+
throw new HttpError(409, 'TARGET_STALE', 'A fresh online target is required');
|
|
68
|
+
if (p.kind === 'service_restart') {
|
|
69
|
+
const allowed = (process.env['CINDERTRACE_RESTART_ALLOWLIST'] ?? '')
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((x) => x.trim());
|
|
72
|
+
if (!p.serviceName || !allowed.includes(p.serviceName))
|
|
73
|
+
throw new HttpError(403, 'RESTART_BLOCKED', 'Service must be explicitly allowlisted');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const tags = JSON.parse(node.tags), metadata = JSON.parse(node.metadata);
|
|
77
|
+
const protectedIds = (process.env['CINDERTRACE_PROTECTED_TARGET_IDS'] ?? '').split(',');
|
|
78
|
+
if (protectedIds.includes(node.id) ||
|
|
79
|
+
tags.some((x) => ['control-plane', 'network-infrastructure'].includes(x)) ||
|
|
80
|
+
metadata['securityIsolationPolicy'] !== 'allow' ||
|
|
81
|
+
metadata['securityIsolationCapable'] !== true ||
|
|
82
|
+
metadata['securityAegisChannelVerified'] !== true)
|
|
83
|
+
throw new HttpError(403, 'ISOLATION_BLOCKED', 'Explicit isolation policy, capability, and verified recovery channel are required');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cindertraceResponseRouter.post('/execute', async (c) => {
|
|
87
|
+
const parsed = responseRequestSchema.safeParse(await c.req.json());
|
|
88
|
+
if (!parsed.success)
|
|
89
|
+
throw new HttpError(400, 'INVALID_RESPONSE', 'Malformed response plan');
|
|
90
|
+
const p = parsed.data, raw = JSON.stringify(p), hash = createHash('sha256').update(raw).digest('hex');
|
|
91
|
+
const db = getDb();
|
|
92
|
+
const prior = await db.cindertraceResponse.findUnique({ where: { id: p.executionId } });
|
|
93
|
+
if (prior) {
|
|
94
|
+
if (prior.requestSha256 !== hash)
|
|
95
|
+
throw new HttpError(409, 'RESPONSE_CONFLICT', 'Execution identity is bound to another plan');
|
|
96
|
+
if (prior.status !== 'completed' || !prior.responseJson)
|
|
97
|
+
throw new HttpError(409, 'RESPONSE_UNKNOWN', 'Inspect the original execution; do not replay it');
|
|
98
|
+
return c.json(JSON.parse(prior.responseJson));
|
|
99
|
+
}
|
|
100
|
+
if (p.expiresAt <= Date.now() || p.expiresAt > Date.now() + 600000)
|
|
101
|
+
throw new HttpError(409, 'APPROVAL_EXPIRED', 'Approval is expired or outside its maximum lifetime');
|
|
102
|
+
const { node, api } = await client(p.nodeId);
|
|
103
|
+
validateResponsePolicy(p, node);
|
|
104
|
+
if (p.operation === 'release') {
|
|
105
|
+
const original = await db.cindertraceResponse.findUnique({
|
|
106
|
+
where: { actionOperation: `${p.actionId}:execute` },
|
|
107
|
+
});
|
|
108
|
+
if (!original ||
|
|
109
|
+
original.nodeId !== p.nodeId ||
|
|
110
|
+
JSON.parse(original.requestJson).kind !== 'host_isolation')
|
|
111
|
+
throw new HttpError(409, 'RELEASE_UNBOUND', 'Release requires an existing isolation execution on this target');
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await db.cindertraceResponse.create({
|
|
115
|
+
data: {
|
|
116
|
+
id: p.executionId,
|
|
117
|
+
actionOperation: `${p.actionId}:${p.operation}`,
|
|
118
|
+
nodeId: p.nodeId,
|
|
119
|
+
requestSha256: hash,
|
|
120
|
+
requestJson: raw,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === 'P2002')
|
|
126
|
+
throw new HttpError(409, 'RESPONSE_ALREADY_CLAIMED', 'Action operation already has an owner');
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
await logAuditEvent({
|
|
130
|
+
action: 'cindertrace.response_claimed',
|
|
131
|
+
actorId: p.approvedBy,
|
|
132
|
+
targetType: 'node',
|
|
133
|
+
targetId: p.nodeId,
|
|
134
|
+
details: {
|
|
135
|
+
actionId: p.actionId,
|
|
136
|
+
executionId: p.executionId,
|
|
137
|
+
planSha256: p.planSha256,
|
|
138
|
+
operation: p.operation,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
const result = await api.securityResponse({
|
|
143
|
+
operation: p.operation,
|
|
144
|
+
kind: p.kind,
|
|
145
|
+
serviceName: p.serviceName,
|
|
146
|
+
isolationMinutes: p.isolationMinutes,
|
|
147
|
+
}, p.executionId);
|
|
148
|
+
if (!result.response.success || !Number.isInteger(result.response.data?.exitCode))
|
|
149
|
+
throw new Error('Response failed');
|
|
150
|
+
const reply = {
|
|
151
|
+
commandId: p.executionId,
|
|
152
|
+
actionId: p.actionId,
|
|
153
|
+
nodeId: p.nodeId,
|
|
154
|
+
operation: p.operation,
|
|
155
|
+
planSha256: p.planSha256,
|
|
156
|
+
exitCode: result.response.data.exitCode,
|
|
157
|
+
receiptSha256: result.evidence.receiptSha256,
|
|
158
|
+
};
|
|
159
|
+
await db.cindertraceResponse.update({
|
|
160
|
+
where: { id: p.executionId },
|
|
161
|
+
data: { status: 'completed', responseJson: JSON.stringify(reply) },
|
|
162
|
+
});
|
|
163
|
+
return c.json(reply);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
await db.cindertraceResponse.update({
|
|
167
|
+
where: { id: p.executionId },
|
|
168
|
+
data: { status: 'unknown' },
|
|
169
|
+
});
|
|
170
|
+
throw new HttpError(409, 'RESPONSE_UNKNOWN', 'No verified completion; inspect the original execution');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
cindertraceResponseRouter.get('/:id', async (c) => {
|
|
174
|
+
const row = await getDb().cindertraceResponse.findUnique({ where: { id: c.req.param('id') } });
|
|
175
|
+
if (!row)
|
|
176
|
+
throw new HttpError(404, 'NOT_FOUND', 'Execution is unavailable');
|
|
177
|
+
return c.json({
|
|
178
|
+
executionId: row.id,
|
|
179
|
+
status: row.status,
|
|
180
|
+
result: row.responseJson ? JSON.parse(row.responseJson) : null,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
//# sourceMappingURL=cindertrace-response.js.map
|
package/dist/routes/heartbeat.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { enqueueSecurityObservations
|
|
1
|
+
import { enqueueSecurityObservations } from '../services/security-export.js';
|
|
2
2
|
import { warnOnAgentVersionMismatch } from '../services/agent-version-warnings.js';
|
|
3
3
|
import { recordSelfUpgradeReport } from '../services/agent-upgrade-service.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
@@ -22,7 +22,6 @@ import { heartbeatHealthSchema } from '../schemas/node-responses.js';
|
|
|
22
22
|
import { normalizeIpAddress, isLoopbackOrUnroutableIp, isUsableNodeIp } from '../utils/ip.js';
|
|
23
23
|
import { AGENT_VERSION } from '../config/version.js';
|
|
24
24
|
import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
|
|
25
|
-
import { ingestMagnusServiceHealth } from '../services/security-fusion-service.js';
|
|
26
25
|
import { deriveAgentCertificateHostname, selectAgentEndpointHostname, verifyAgentCertificateHostname, } from '../utils/node-url.js';
|
|
27
26
|
import { heartbeatPeerAddress } from '../utils/heartbeat-peer.js';
|
|
28
27
|
import { authMiddleware } from '../middleware/auth.js';
|
|
@@ -490,10 +489,6 @@ heartbeatRouter.post('/', async (c) => {
|
|
|
490
489
|
await enqueueSecurityObservations(tx, nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? []), snapshot.id, snapshot.timestamp);
|
|
491
490
|
return snapshot;
|
|
492
491
|
});
|
|
493
|
-
if (securityFusionLocal())
|
|
494
|
-
void ingestMagnusServiceHealth(nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? [])).catch((err) => {
|
|
495
|
-
console.error(`[security-fusion] heartbeat conversion failed for ${nodeId}:`, err instanceof Error ? err.message : err);
|
|
496
|
-
});
|
|
497
492
|
// Evaluate health against alert rules (fire-and-forget)
|
|
498
493
|
const alertNodeName = existing?.name ?? fqdn ?? nodeId;
|
|
499
494
|
evaluateHealth(nodeId, alertNodeName, {
|