@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
|
@@ -1,605 +1,20 @@
|
|
|
1
|
-
import { createHmac, randomBytes } from 'node:crypto';
|
|
2
1
|
import { Hono } from 'hono';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import { HttpError } from '../middleware/error-handler.js';
|
|
7
|
-
import { logAuditEvent } from '../services/audit-service.js';
|
|
8
|
-
import { ingestSecurityFusionBatch, preserveEvidence, saveInvestigation, SECURITY_FUSION_RETENTION_POLICY, sha256, } from '../services/security-fusion-service.js';
|
|
9
|
-
import { collectorCredentialFingerprint, configuredCollectorCredentials, validateSecurityFusionBatchEnvelope, } from '../services/security-fusion-ingest.js';
|
|
10
|
-
import { dispatchSecurityFusionOutbox } from '../services/security-fusion-outbox.js';
|
|
11
|
-
import { securityFusionActionSources } from '../services/security-fusion-actions.js';
|
|
12
|
-
import { incMetric } from './metrics.js';
|
|
2
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
3
|
+
import { securityConsoleUrl, securityFusionMode } from '../services/security-export.js';
|
|
4
|
+
/** Compatibility only. Security state and effects belong to Cindertrace. */
|
|
13
5
|
export const securityFusionRouter = new Hono();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
events: 250,
|
|
19
|
-
entities: 250,
|
|
20
|
-
activity: 250,
|
|
21
|
-
evidence: 100,
|
|
22
|
-
investigations: 25,
|
|
23
|
-
actions: 100,
|
|
24
|
-
relationships: SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships,
|
|
25
|
-
};
|
|
26
|
-
export function securityFusionIngestSignature(secret, collectorId, timestamp, nonce, raw) {
|
|
27
|
-
return createHmac('sha256', secret).update(`${collectorId}.${timestamp}.${nonce}.${raw}`).digest('hex');
|
|
28
|
-
}
|
|
29
|
-
function json(value) {
|
|
30
|
-
try {
|
|
31
|
-
return JSON.parse(value);
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return {};
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function serializeCase(row) {
|
|
38
|
-
return {
|
|
39
|
-
...row,
|
|
40
|
-
recommendedInvestigation: json(String(row['recommendedInvestigationJson'] ?? '[]')),
|
|
41
|
-
evidenceSteps: json(String(row['evidenceStepsJson'] ?? '[]')),
|
|
42
|
-
recommendedInvestigationJson: undefined,
|
|
43
|
-
evidenceStepsJson: undefined,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
function serializeSourceState(row) {
|
|
47
|
-
if (row.status === 'healthy'
|
|
48
|
-
&& row.lastSuccessAt
|
|
49
|
-
&& Date.now() - row.lastSuccessAt.getTime() > SOURCE_STALE_MS) {
|
|
50
|
-
return { ...row, status: 'stale' };
|
|
51
|
-
}
|
|
52
|
-
return row;
|
|
53
|
-
}
|
|
54
|
-
export function serializeSecurityFusionEvent(row, includeEvidenceAttributes) {
|
|
55
|
-
const attributes = json(String(row['attributesJson'] ?? '{}'));
|
|
56
|
-
return {
|
|
57
|
-
...row,
|
|
58
|
-
attributes: includeEvidenceAttributes
|
|
59
|
-
? attributes
|
|
60
|
-
: { redacted: true },
|
|
61
|
-
attributesJson: undefined,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
async function verifyIngest(c, raw) {
|
|
65
|
-
let credentials;
|
|
66
|
-
try {
|
|
67
|
-
credentials = configuredCollectorCredentials();
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
throw new HttpError(503, 'SECURITY_FUSION_DISABLED', 'Security Fusion collector credentials are invalid');
|
|
71
|
-
}
|
|
72
|
-
if (credentials.size === 0) {
|
|
73
|
-
throw new HttpError(503, 'SECURITY_FUSION_DISABLED', 'Security Fusion ingest is not configured');
|
|
74
|
-
}
|
|
75
|
-
const collectorId = c.req.header('x-security-collector-id') ?? '';
|
|
76
|
-
const credential = credentials.get(collectorId);
|
|
77
|
-
if (!credential)
|
|
78
|
-
throw new HttpError(401, 'INVALID_INGEST_SIGNATURE', 'Invalid signed ingest request');
|
|
79
|
-
const timestamp = c.req.header('x-security-timestamp') ?? '';
|
|
80
|
-
const nonce = c.req.header('x-security-nonce') ?? '';
|
|
81
|
-
const signature = c.req.header('x-security-signature') ?? '';
|
|
82
|
-
const timestampMs = Date.parse(timestamp);
|
|
83
|
-
if (!nonce || nonce.length > 160 || !Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > INGEST_SKEW_MS) {
|
|
84
|
-
throw new HttpError(401, 'INVALID_INGEST_SIGNATURE', 'Stale or malformed signed ingest request');
|
|
85
|
-
}
|
|
86
|
-
const expected = securityFusionIngestSignature(credential.secret, collectorId, timestamp, nonce, raw);
|
|
87
|
-
if (!safeEqual(expected, signature))
|
|
88
|
-
throw new HttpError(401, 'INVALID_INGEST_SIGNATURE', 'Invalid signed ingest request');
|
|
89
|
-
try {
|
|
90
|
-
await getDb().$transaction(async (db) => {
|
|
91
|
-
const existing = await db.securityFusionCollector.findUnique({ where: { id: collectorId } });
|
|
92
|
-
if (existing?.revokedAt || existing?.status === 'revoked') {
|
|
93
|
-
throw new HttpError(401, 'COLLECTOR_REVOKED', 'Collector identity has been revoked');
|
|
94
|
-
}
|
|
95
|
-
await db.securityFusionCollector.upsert({
|
|
96
|
-
where: { id: collectorId },
|
|
97
|
-
create: {
|
|
98
|
-
id: collectorId,
|
|
99
|
-
displayName: credential.displayName,
|
|
100
|
-
credentialFingerprint: collectorCredentialFingerprint(credential.secret),
|
|
101
|
-
allowedSourcesJson: JSON.stringify(credential.allowedSources),
|
|
102
|
-
schemaVersion: credential.schemaVersion,
|
|
103
|
-
dataClassification: credential.dataClassification,
|
|
104
|
-
status: 'active',
|
|
105
|
-
lastReceiptAt: new Date(),
|
|
106
|
-
},
|
|
107
|
-
update: {
|
|
108
|
-
displayName: credential.displayName,
|
|
109
|
-
credentialFingerprint: collectorCredentialFingerprint(credential.secret),
|
|
110
|
-
allowedSourcesJson: JSON.stringify(credential.allowedSources),
|
|
111
|
-
schemaVersion: credential.schemaVersion,
|
|
112
|
-
dataClassification: credential.dataClassification,
|
|
113
|
-
lastReceiptAt: new Date(),
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
await db.securityFusionNonce.create({
|
|
117
|
-
data: { nonce, collectorId, expiresAt: new Date(Date.now() + INGEST_SKEW_MS) },
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
if (error instanceof HttpError)
|
|
123
|
-
throw error;
|
|
124
|
-
throw new HttpError(409, 'INGEST_REPLAY', 'Ingest nonce has already been used');
|
|
125
|
-
}
|
|
126
|
-
return credential;
|
|
127
|
-
}
|
|
128
|
-
securityFusionRouter.post('/ingest/events', async (c) => {
|
|
129
|
-
const raw = await c.req.text();
|
|
130
|
-
const credential = await verifyIngest(c, raw);
|
|
131
|
-
try {
|
|
132
|
-
let parsedJson;
|
|
133
|
-
try {
|
|
134
|
-
parsedJson = JSON.parse(raw);
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
throw new HttpError(400, 'VALIDATION_ERROR', 'Invalid JSON body');
|
|
138
|
-
}
|
|
139
|
-
validateSecurityFusionBatchEnvelope(parsedJson, Buffer.byteLength(raw, 'utf8'));
|
|
140
|
-
const parsed = securityFusionEventBatchSchema.safeParse(parsedJson);
|
|
141
|
-
if (!parsed.success)
|
|
142
|
-
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map((issue) => issue.message).join('; '));
|
|
143
|
-
if (parsed.data.collectorId !== credential.collectorId) {
|
|
144
|
-
throw new HttpError(403, 'COLLECTOR_IDENTITY_MISMATCH', 'Signed collector identity does not match the batch');
|
|
145
|
-
}
|
|
146
|
-
const disallowed = parsed.data.events.find((event) => !credential.allowedSources.includes(event.source));
|
|
147
|
-
const checkpointSources = Object.keys(parsed.data.checkpoints ?? {});
|
|
148
|
-
if (disallowed || checkpointSources.some((source) => !credential.allowedSources.includes(source))) {
|
|
149
|
-
throw new HttpError(403, 'COLLECTOR_SOURCE_FORBIDDEN', 'Collector is not authorized for one or more source types');
|
|
150
|
-
}
|
|
151
|
-
const result = await ingestSecurityFusionBatch(parsed.data);
|
|
152
|
-
await getDb().securityFusionCollector.update({
|
|
153
|
-
where: { id: credential.collectorId },
|
|
154
|
-
data: { lastSuccessAt: new Date(), lastErrorAt: null, lastError: null },
|
|
155
|
-
});
|
|
156
|
-
return c.json({ success: true, data: result }, 202);
|
|
157
|
-
}
|
|
158
|
-
catch (error) {
|
|
159
|
-
const message = error instanceof HttpError ? error.code : 'INGEST_FAILED';
|
|
160
|
-
await getDb().securityFusionCollector.update({
|
|
161
|
-
where: { id: credential.collectorId },
|
|
162
|
-
data: { lastErrorAt: new Date(), lastError: message },
|
|
163
|
-
}).catch(() => undefined);
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
securityFusionRouter.get('/ingest/checkpoints', async (c) => {
|
|
168
|
-
const credential = await verifyIngest(c, '');
|
|
169
|
-
const states = await getDb().securityFusionSourceState.findMany({
|
|
170
|
-
where: { checkpoint: { not: null }, source: { in: credential.allowedSources } },
|
|
171
|
-
select: { source: true, checkpoint: true },
|
|
172
|
-
});
|
|
173
|
-
return c.json({ success: true, data: Object.fromEntries(states.map((state) => [state.source, state.checkpoint])) });
|
|
174
|
-
});
|
|
175
|
-
securityFusionRouter.post('/ingest/source-status', async (c) => {
|
|
176
|
-
const raw = await c.req.text();
|
|
177
|
-
const credential = await verifyIngest(c, raw);
|
|
178
|
-
let parsedJson;
|
|
179
|
-
try {
|
|
180
|
-
parsedJson = JSON.parse(raw);
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
throw new HttpError(400, 'VALIDATION_ERROR', 'Invalid JSON body');
|
|
184
|
-
}
|
|
185
|
-
const parsed = securityFusionSourceReportSchema.safeParse(parsedJson);
|
|
186
|
-
if (!parsed.success) {
|
|
187
|
-
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map((issue) => issue.message).join('; '));
|
|
188
|
-
}
|
|
189
|
-
if (parsed.data.collectorId !== credential.collectorId) {
|
|
190
|
-
throw new HttpError(403, 'COLLECTOR_IDENTITY_MISMATCH', 'Signed collector identity does not match the report');
|
|
191
|
-
}
|
|
192
|
-
if (parsed.data.sources.some((item) => !credential.allowedSources.includes(item.source))) {
|
|
193
|
-
throw new HttpError(403, 'COLLECTOR_SOURCE_FORBIDDEN', 'Collector is not authorized for one or more source types');
|
|
194
|
-
}
|
|
195
|
-
const now = new Date();
|
|
196
|
-
await getDb().$transaction(parsed.data.sources.map((item) => getDb().securityFusionSourceState.upsert({
|
|
197
|
-
where: { source: item.source },
|
|
198
|
-
create: {
|
|
199
|
-
id: item.source,
|
|
200
|
-
source: item.source,
|
|
201
|
-
status: item.status,
|
|
202
|
-
checkpoint: item.checkpoint,
|
|
203
|
-
lastEventAt: item.lastEventAt ? new Date(item.lastEventAt) : null,
|
|
204
|
-
lastSuccessAt: item.status === 'healthy' ? now : null,
|
|
205
|
-
lastErrorAt: item.status === 'error' || item.status === 'degraded' ? now : null,
|
|
206
|
-
lastError: item.error ?? null,
|
|
207
|
-
},
|
|
208
|
-
update: {
|
|
209
|
-
status: item.status,
|
|
210
|
-
...(item.checkpoint === undefined ? {} : { checkpoint: item.checkpoint }),
|
|
211
|
-
...(item.lastEventAt === undefined ? {} : { lastEventAt: new Date(item.lastEventAt) }),
|
|
212
|
-
...(item.status === 'healthy' ? { lastSuccessAt: now, lastErrorAt: null, lastError: null } : {}),
|
|
213
|
-
...(item.status === 'error' || item.status === 'degraded'
|
|
214
|
-
? { lastErrorAt: now, lastError: item.error ?? item.status }
|
|
215
|
-
: {}),
|
|
216
|
-
...(item.status === 'configured' || item.status === 'unconfigured'
|
|
217
|
-
? { lastErrorAt: null, lastError: null }
|
|
218
|
-
: {}),
|
|
219
|
-
},
|
|
220
|
-
})));
|
|
221
|
-
const failed = parsed.data.sources.find((item) => item.status === 'error' || item.status === 'degraded');
|
|
222
|
-
await getDb().securityFusionCollector.update({
|
|
223
|
-
where: { id: credential.collectorId },
|
|
224
|
-
data: failed
|
|
225
|
-
? { lastErrorAt: now, lastError: `${failed.source}: ${failed.error ?? failed.status}` }
|
|
226
|
-
: { lastSuccessAt: now, lastErrorAt: null, lastError: null },
|
|
227
|
-
});
|
|
228
|
-
return c.json({ success: true, data: { updated: parsed.data.sources.length } }, 202);
|
|
6
|
+
securityFusionRouter.use('*', async (c, next) => {
|
|
7
|
+
if (securityFusionMode() === 'off')
|
|
8
|
+
return c.notFound();
|
|
9
|
+
await next();
|
|
229
10
|
});
|
|
230
11
|
securityFusionRouter.use('*', authMiddleware);
|
|
231
|
-
securityFusionRouter.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return c.json({ success: true, data: { generatedAt: new Date().toISOString(), openCases: openCases.map((row) => serializeCase(row)), sources: sources.map(serializeSourceState), bySeverity } });
|
|
240
|
-
});
|
|
241
|
-
securityFusionRouter.get('/sources', async (c) => {
|
|
242
|
-
const rows = await getDb().securityFusionSourceState.findMany({ orderBy: { source: 'asc' } });
|
|
243
|
-
const known = new Map(rows.map((row) => [row.source, row]));
|
|
244
|
-
return c.json({
|
|
245
|
-
success: true,
|
|
246
|
-
data: securityFusionSourceSchema.options.map((source) => {
|
|
247
|
-
const state = known.get(source);
|
|
248
|
-
return state ? serializeSourceState(state) : { id: source, source, status: 'unconfigured' };
|
|
249
|
-
}),
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
securityFusionRouter.get('/source-registry', async (c) => {
|
|
253
|
-
const collectors = await getDb().securityFusionCollector.findMany({
|
|
254
|
-
orderBy: { displayName: 'asc' },
|
|
255
|
-
});
|
|
256
|
-
return c.json({
|
|
257
|
-
success: true,
|
|
258
|
-
data: collectors.map((collector) => ({
|
|
259
|
-
...collector,
|
|
260
|
-
allowedSources: json(collector.allowedSourcesJson),
|
|
261
|
-
allowedSourcesJson: undefined,
|
|
262
|
-
credentialFingerprint: undefined,
|
|
263
|
-
cursorHealth: collector.lastErrorAt ? 'error' : collector.lastSuccessAt ? 'healthy' : 'pending',
|
|
264
|
-
})),
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
securityFusionRouter.post('/source-registry/:id/revoke', requireRole('ADMIN'), async (c) => {
|
|
268
|
-
const collectorId = c.req.param('id');
|
|
269
|
-
const revokedAt = new Date();
|
|
270
|
-
const result = await getDb().securityFusionCollector.updateMany({
|
|
271
|
-
where: { id: collectorId, status: { not: 'revoked' }, revokedAt: null },
|
|
272
|
-
data: { status: 'revoked', revokedAt },
|
|
273
|
-
});
|
|
274
|
-
if (result.count !== 1) {
|
|
275
|
-
throw new HttpError(409, 'INVALID_COLLECTOR_STATE', 'Collector is missing or already revoked');
|
|
276
|
-
}
|
|
277
|
-
await logAuditEvent({
|
|
278
|
-
action: 'security_fusion.collector.revoke',
|
|
279
|
-
actorId: getUser(c).userId,
|
|
280
|
-
targetType: 'security-fusion-collector',
|
|
281
|
-
targetId: collectorId,
|
|
282
|
-
details: { revokedAt: revokedAt.toISOString() },
|
|
283
|
-
});
|
|
284
|
-
return c.json({ success: true, data: { id: collectorId, status: 'revoked', revokedAt } });
|
|
285
|
-
});
|
|
286
|
-
securityFusionRouter.get('/events', async (c) => {
|
|
287
|
-
const source = c.req.query('source');
|
|
288
|
-
const kind = c.req.query('kind');
|
|
289
|
-
const limit = Math.min(Math.max(Number(c.req.query('limit') ?? 100), 1), 250);
|
|
290
|
-
const rows = await getDb().securityFusionEvent.findMany({
|
|
291
|
-
where: { ...(source ? { source } : {}), ...(kind ? { kind: { contains: kind } } : {}) },
|
|
292
|
-
orderBy: { observedAt: 'desc' }, take: limit,
|
|
293
|
-
});
|
|
294
|
-
const includeEvidenceAttributes = hasPermission(getUser(c), 'security-fusion:evidence:read');
|
|
295
|
-
return c.json({ success: true, data: rows.map((row) => serializeSecurityFusionEvent(row, includeEvidenceAttributes)) });
|
|
296
|
-
});
|
|
297
|
-
securityFusionRouter.get('/entities', async (c) => {
|
|
298
|
-
const type = c.req.query('type');
|
|
299
|
-
const rows = await getDb().securityFusionEntity.findMany({ where: type ? { type } : {}, orderBy: { lastSeenAt: 'desc' }, take: 100 });
|
|
300
|
-
return c.json({ success: true, data: rows.map((row) => ({ ...row, aliases: json(row.aliasesJson), aliasesJson: undefined })) });
|
|
301
|
-
});
|
|
302
|
-
securityFusionRouter.get('/entities/:id', async (c) => {
|
|
303
|
-
const entity = await getDb().securityFusionEntity.findUnique({ where: { id: c.req.param('id') } });
|
|
304
|
-
if (!entity)
|
|
305
|
-
throw new HttpError(404, 'NOT_FOUND', 'Security entity not found');
|
|
306
|
-
const links = await getDb().securityFusionCaseEntity.findMany({
|
|
307
|
-
where: { entityId: entity.id },
|
|
308
|
-
orderBy: { id: 'desc' },
|
|
309
|
-
take: SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships + 1,
|
|
310
|
-
});
|
|
311
|
-
const relationshipsTruncated = links.length > SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships;
|
|
312
|
-
if (relationshipsTruncated)
|
|
313
|
-
incMetric('securityFusionRelationshipTruncated');
|
|
314
|
-
const cases = await getDb().securityFusionCase.findMany({
|
|
315
|
-
where: {
|
|
316
|
-
archivedAt: null,
|
|
317
|
-
id: {
|
|
318
|
-
in: links
|
|
319
|
-
.slice(0, SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships)
|
|
320
|
-
.map((link) => link.caseId),
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
orderBy: { lastSeenAt: 'desc' },
|
|
324
|
-
});
|
|
325
|
-
return c.json({ success: true, data: {
|
|
326
|
-
...entity,
|
|
327
|
-
aliases: json(entity.aliasesJson),
|
|
328
|
-
cases: cases.map((row) => serializeCase(row)),
|
|
329
|
-
relationshipsTruncated,
|
|
330
|
-
relationshipLimit: SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships,
|
|
331
|
-
} });
|
|
332
|
-
});
|
|
333
|
-
securityFusionRouter.get('/cases', async (c) => {
|
|
334
|
-
const status = c.req.query('status');
|
|
335
|
-
const archived = c.req.query('archived') === 'true';
|
|
336
|
-
const rows = await getDb().securityFusionCase.findMany({
|
|
337
|
-
where: {
|
|
338
|
-
...(status ? { status } : {}),
|
|
339
|
-
archivedAt: archived ? { not: null } : null,
|
|
340
|
-
},
|
|
341
|
-
orderBy: { lastSeenAt: 'desc' },
|
|
342
|
-
take: 100,
|
|
343
|
-
});
|
|
344
|
-
return c.json({ success: true, data: rows.map((row) => serializeCase(row)) });
|
|
345
|
-
});
|
|
346
|
-
export async function loadSecurityFusionCaseDetail(caseId, includeEvidence) {
|
|
347
|
-
const caseRow = await getDb().securityFusionCase.findUnique({ where: { id: caseId } });
|
|
348
|
-
if (!caseRow)
|
|
349
|
-
throw new HttpError(404, 'NOT_FOUND', 'Security case not found');
|
|
350
|
-
const [caseEvents, caseEntities, activity, evidence, investigations, actions, relationshipRows] = await Promise.all([
|
|
351
|
-
getDb().securityFusionCaseEvent.findMany({
|
|
352
|
-
where: { caseId },
|
|
353
|
-
orderBy: { id: 'desc' },
|
|
354
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.events,
|
|
355
|
-
}),
|
|
356
|
-
getDb().securityFusionCaseEntity.findMany({
|
|
357
|
-
where: { caseId },
|
|
358
|
-
orderBy: { id: 'desc' },
|
|
359
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.entities,
|
|
360
|
-
}),
|
|
361
|
-
getDb().securityFusionCaseActivity.findMany({
|
|
362
|
-
where: { caseId },
|
|
363
|
-
orderBy: { createdAt: 'desc' },
|
|
364
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.activity,
|
|
365
|
-
}),
|
|
366
|
-
getDb().securityFusionEvidence.findMany({
|
|
367
|
-
where: { caseId },
|
|
368
|
-
orderBy: { preservedAt: 'desc' },
|
|
369
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.evidence,
|
|
370
|
-
}),
|
|
371
|
-
getDb().securityFusionInvestigation.findMany({
|
|
372
|
-
where: { caseId },
|
|
373
|
-
orderBy: { revision: 'desc' },
|
|
374
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.investigations,
|
|
375
|
-
}),
|
|
376
|
-
getDb().securityFusionAction.findMany({
|
|
377
|
-
where: { caseId },
|
|
378
|
-
orderBy: { createdAt: 'desc' },
|
|
379
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.actions,
|
|
380
|
-
}),
|
|
381
|
-
getDb().securityFusionCaseRelationship.findMany({
|
|
382
|
-
where: { OR: [{ fromCaseId: caseId }, { toCaseId: caseId }] },
|
|
383
|
-
orderBy: { createdAt: 'desc' },
|
|
384
|
-
take: SECURITY_FUSION_CASE_DETAIL_LIMITS.relationships + 1,
|
|
385
|
-
}),
|
|
386
|
-
]);
|
|
387
|
-
const relationshipsTruncated = relationshipRows.length > SECURITY_FUSION_CASE_DETAIL_LIMITS.relationships;
|
|
388
|
-
if (relationshipsTruncated)
|
|
389
|
-
incMetric('securityFusionRelationshipTruncated');
|
|
390
|
-
const relationships = relationshipRows.slice(0, SECURITY_FUSION_CASE_DETAIL_LIMITS.relationships);
|
|
391
|
-
const relatedCaseIds = [...new Set(relationships.map((row) => row.fromCaseId === caseId ? row.toCaseId : row.fromCaseId))];
|
|
392
|
-
const [events, entities, relatedCases] = await Promise.all([
|
|
393
|
-
getDb().securityFusionEvent.findMany({ where: { id: { in: caseEvents.map((row) => row.eventId) } }, orderBy: { observedAt: 'asc' } }),
|
|
394
|
-
getDb().securityFusionEntity.findMany({ where: { id: { in: caseEntities.map((row) => row.entityId) } }, orderBy: { type: 'asc' } }),
|
|
395
|
-
getDb().securityFusionCase.findMany({
|
|
396
|
-
where: { id: { in: relatedCaseIds } },
|
|
397
|
-
orderBy: { lastSeenAt: 'desc' },
|
|
398
|
-
}),
|
|
399
|
-
]);
|
|
400
|
-
const contributions = new Map(caseEvents.map((row) => [row.eventId, { ruleId: row.ruleId, confidenceContribution: row.confidenceContribution }]));
|
|
401
|
-
return {
|
|
402
|
-
case: serializeCase(caseRow),
|
|
403
|
-
events: events.map((row) => ({ ...serializeSecurityFusionEvent(row, includeEvidence), ...contributions.get(row.id) })),
|
|
404
|
-
entities,
|
|
405
|
-
activity: activity.reverse().map((row) => ({ ...row, detail: json(row.detailJson), detailJson: undefined })),
|
|
406
|
-
evidence: includeEvidence ? evidence : evidence.map((row) => ({ id: row.id, caseId: row.caseId, eventId: row.eventId, mediaType: row.mediaType, sha256: row.sha256, preservedAt: row.preservedAt, expiresAt: row.expiresAt, restricted: true })),
|
|
407
|
-
investigations: investigations.map((row) => ({ ...row, draft: json(row.draftJson), draftJson: undefined })),
|
|
408
|
-
actions: actions.map((row) => ({ ...row, preview: json(row.previewJson), result: row.resultJson ? json(row.resultJson) : null, approvalTokenHash: undefined })),
|
|
409
|
-
relationships: relationships.map((row) => ({
|
|
410
|
-
...row,
|
|
411
|
-
rationale: json(row.rationaleJson),
|
|
412
|
-
rationaleJson: undefined,
|
|
413
|
-
})),
|
|
414
|
-
relatedCases: relatedCases.map((row) => serializeCase(row)),
|
|
415
|
-
relationshipsTruncated,
|
|
416
|
-
limits: SECURITY_FUSION_CASE_DETAIL_LIMITS,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
securityFusionRouter.get('/cases/:id', async (c) => {
|
|
420
|
-
const detail = await loadSecurityFusionCaseDetail(c.req.param('id'), hasPermission(getUser(c), 'security-fusion:evidence:read'));
|
|
421
|
-
return c.json({ success: true, data: detail });
|
|
422
|
-
});
|
|
423
|
-
securityFusionRouter.patch('/cases/:id', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
424
|
-
const body = await c.req.json();
|
|
425
|
-
const allowed = ['new', 'triage', 'investigating', 'contained', 'monitoring', 'resolved', 'false_positive'];
|
|
426
|
-
if (body.status && !allowed.includes(body.status))
|
|
427
|
-
throw new HttpError(400, 'VALIDATION_ERROR', 'Invalid case status');
|
|
428
|
-
const user = getUser(c);
|
|
429
|
-
const row = await getDb().securityFusionCase.update({
|
|
430
|
-
where: { id: c.req.param('id') },
|
|
431
|
-
data: { ...(body.status ? { status: body.status, resolvedAt: ['resolved', 'false_positive'].includes(body.status) ? new Date() : null } : {}), ...(body.assignedTo !== undefined ? { assignedTo: body.assignedTo } : {}) },
|
|
432
|
-
});
|
|
433
|
-
await getDb().securityFusionCaseActivity.create({ data: { caseId: row.id, kind: 'case.updated', actorId: user.userId, detailJson: JSON.stringify(body) } });
|
|
434
|
-
return c.json({ success: true, data: serializeCase(row) });
|
|
435
|
-
});
|
|
436
|
-
securityFusionRouter.post('/cases/:id/investigations', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
437
|
-
const parsed = securityFusionInvestigationDraftSchema.safeParse(await c.req.json());
|
|
438
|
-
if (!parsed.success)
|
|
439
|
-
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map((issue) => issue.message).join('; '));
|
|
440
|
-
const row = await saveInvestigation(c.req.param('id'), getUser(c).userId, parsed.data);
|
|
441
|
-
return c.json({ success: true, data: { ...row, draft: parsed.data, draftJson: undefined } }, 201);
|
|
442
|
-
});
|
|
443
|
-
securityFusionRouter.post('/cases/:id/evidence/:eventId/preserve', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
444
|
-
const caseId = c.req.param('id');
|
|
445
|
-
const row = await preserveEvidence(caseId, c.req.param('eventId'), getUser(c).userId);
|
|
446
|
-
await logAuditEvent({ action: 'security_fusion.evidence.preserve', actorId: getUser(c).userId, targetType: 'security-case', targetId: caseId, details: { evidenceId: row.id, sha256: row.sha256, byteSize: row.byteSize } });
|
|
447
|
-
return c.json({ success: true, data: row }, 201);
|
|
448
|
-
});
|
|
449
|
-
securityFusionRouter.get('/evidence', requirePermission('security-fusion:evidence:read'), async (c) => {
|
|
450
|
-
return c.json({ success: true, data: await getDb().securityFusionEvidence.findMany({ orderBy: { preservedAt: 'desc' }, take: 250 }) });
|
|
451
|
-
});
|
|
452
|
-
securityFusionRouter.get('/deletion-receipts', requirePermission('security-fusion:evidence:read'), async (c) => {
|
|
453
|
-
const [receipts, chainHead] = await Promise.all([
|
|
454
|
-
getDb().securityFusionDeletionReceipt.findMany({
|
|
455
|
-
orderBy: { chainSequence: 'desc' },
|
|
456
|
-
take: 250,
|
|
457
|
-
}),
|
|
458
|
-
getDb().securityFusionReceiptChainHead.findUnique({
|
|
459
|
-
where: { id: 'security-fusion' },
|
|
460
|
-
select: { headSha256: true, chainSequence: true, updatedAt: true },
|
|
461
|
-
}),
|
|
462
|
-
]);
|
|
463
|
-
return c.json({
|
|
464
|
-
success: true,
|
|
465
|
-
data: { receipts, chainHead },
|
|
466
|
-
});
|
|
467
|
-
});
|
|
468
|
-
securityFusionRouter.get('/policies', async (c) => c.json({ success: true, data: {
|
|
469
|
-
eventRetentionDays: SECURITY_FUSION_RETENTION_POLICY.eventDays,
|
|
470
|
-
preservedEvidenceDays: SECURITY_FUSION_RETENTION_POLICY.evidenceDays,
|
|
471
|
-
caseArchiveDays: SECURITY_FUSION_RETENTION_POLICY.caseArchiveDays,
|
|
472
|
-
archiveGraceDays: SECURITY_FUSION_RETENTION_POLICY.archiveGraceDays,
|
|
473
|
-
deletionReceiptDays: SECURITY_FUSION_RETENTION_POLICY.deletionReceiptDays,
|
|
474
|
-
perCaseCeilings: {
|
|
475
|
-
events: SECURITY_FUSION_RETENTION_POLICY.maxCaseEvents,
|
|
476
|
-
entities: SECURITY_FUSION_RETENTION_POLICY.maxCaseEntities,
|
|
477
|
-
relationships: SECURITY_FUSION_RETENTION_POLICY.maxCaseRelationships,
|
|
478
|
-
preservedEvidence: SECURITY_FUSION_RETENTION_POLICY.maxPreservedEvidence,
|
|
479
|
-
preservedEvidenceBytes: SECURITY_FUSION_RETENTION_POLICY.maxPreservedEvidenceBytes,
|
|
480
|
-
},
|
|
481
|
-
approvalTtlMinutes: 10,
|
|
482
|
-
isolation: { defaultMinutes: 15, maximumMinutes: 30, defaultPolicy: 'blocked' },
|
|
483
|
-
allowedActions: ['service_restart', 'host_isolation'],
|
|
484
|
-
deniedActions: ['credential_rotation', 'network_block', 'arbitrary_command'],
|
|
485
|
-
} }));
|
|
486
|
-
securityFusionRouter.post('/cases/:id/actions/preview', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
487
|
-
const parsed = securityFusionContainmentPreviewSchema.safeParse({ ...(await c.req.json()), caseId: c.req.param('id') });
|
|
488
|
-
if (!parsed.success)
|
|
489
|
-
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map((issue) => issue.message).join('; '));
|
|
490
|
-
const node = await getDb().node.findUnique({ where: { id: parsed.data.nodeId } });
|
|
491
|
-
if (!node || node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120_000) {
|
|
492
|
-
throw new HttpError(409, 'TARGET_NOT_READY', 'Containment target must be online with a fresh heartbeat');
|
|
493
|
-
}
|
|
494
|
-
const metadata = json(node.metadata);
|
|
495
|
-
const tags = json(node.tags);
|
|
496
|
-
const tagList = Array.isArray(tags) ? tags.map(String) : [];
|
|
497
|
-
if (parsed.data.kind === 'service_restart') {
|
|
498
|
-
const allowlist = (process.env['SECURITY_FUSION_RESTART_ALLOWLIST'] ?? '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
499
|
-
const snapshot = await getDb().healthSnapshot.findFirst({ where: { nodeId: node.id, timestamp: { gte: new Date(Date.now() - 120_000), lte: new Date() } }, orderBy: { timestamp: 'desc' } });
|
|
500
|
-
const reportedServices = snapshot ? json(snapshot.services) : [];
|
|
501
|
-
const active = Array.isArray(reportedServices)
|
|
502
|
-
? reportedServices.some((service) => {
|
|
503
|
-
const row = service;
|
|
504
|
-
return typeof row['name'] === 'string' && row['name'].replace(/\.service$/, '') === parsed.data.serviceName?.replace(/\.service$/, '') && row['status'] === 'active';
|
|
505
|
-
})
|
|
506
|
-
: false;
|
|
507
|
-
if (!parsed.data.serviceName || !allowlist.includes(parsed.data.serviceName) || !active) {
|
|
508
|
-
throw new HttpError(403, 'SERVICE_RESTART_BLOCKED', 'Service must be globally allowlisted and currently reported active');
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
if (parsed.data.kind === 'host_isolation') {
|
|
512
|
-
const protectedNode = tagList.some((tag) => ['control-plane', 'network-infrastructure'].includes(tag));
|
|
513
|
-
if (metadata['securityIsolationPolicy'] !== 'allow' || metadata['securityIsolationCapable'] !== true || protectedNode) {
|
|
514
|
-
throw new HttpError(403, 'ISOLATION_BLOCKED', 'Isolation requires explicit policy and capability; control-plane and network nodes are protected');
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
const preview = {
|
|
518
|
-
...parsed.data, node: { id: node.id, name: node.name, hostname: node.hostname },
|
|
519
|
-
rollbackPlan: parsed.data.kind === 'host_isolation'
|
|
520
|
-
? `Automatic release after ${parsed.data.isolationMinutes ?? 15} minutes; ADMIN may release immediately.`
|
|
521
|
-
: 'Verify the service returns active and the node heartbeat remains fresh.',
|
|
522
|
-
expiresAt: new Date(Date.now() + APPROVAL_TTL_MS).toISOString(),
|
|
523
|
-
};
|
|
524
|
-
const previewJson = JSON.stringify(preview);
|
|
525
|
-
const row = await getDb().securityFusionAction.create({
|
|
526
|
-
data: {
|
|
527
|
-
caseId: parsed.data.caseId, nodeId: node.id, kind: parsed.data.kind,
|
|
528
|
-
serviceName: parsed.data.serviceName, isolationMinutes: parsed.data.isolationMinutes ?? (parsed.data.kind === 'host_isolation' ? 15 : null),
|
|
529
|
-
rationale: parsed.data.rationale, rollbackPlan: preview.rollbackPlan, previewJson,
|
|
530
|
-
previewSha256: sha256(previewJson), requestedBy: getUser(c).userId,
|
|
531
|
-
},
|
|
532
|
-
});
|
|
533
|
-
return c.json({ success: true, data: { ...row, preview, previewJson: undefined } }, 201);
|
|
534
|
-
});
|
|
535
|
-
securityFusionRouter.post('/actions/:id/request', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
536
|
-
// Legacy local-mode rows retain their non-null token columns, but no token is
|
|
537
|
-
// ever disclosed or accepted. Containment execution belongs to the isolated
|
|
538
|
-
// external approval boundary even during rollback to local read mode.
|
|
539
|
-
const tokenHash = sha256(randomBytes(32).toString('hex'));
|
|
540
|
-
const expiresAt = new Date(Date.now() + APPROVAL_TTL_MS);
|
|
541
|
-
const row = await getDb().$transaction(async (db) => {
|
|
542
|
-
const action = await db.securityFusionAction.findUnique({ where: { id: c.req.param('id') } });
|
|
543
|
-
if (!action)
|
|
544
|
-
throw new HttpError(409, 'INVALID_ACTION_STATE', 'Action is not previewable');
|
|
545
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
546
|
-
where: { id: action.id, status: { in: securityFusionActionSources('pending_approval') } },
|
|
547
|
-
data: { status: 'pending_approval', approvalTokenHash: tokenHash, approvalExpiresAt: expiresAt },
|
|
548
|
-
});
|
|
549
|
-
if (transitioned.count !== 1) {
|
|
550
|
-
throw new HttpError(409, 'INVALID_ACTION_STATE', 'Action is not previewable');
|
|
551
|
-
}
|
|
552
|
-
await db.securityFusionActionApproval.create({
|
|
553
|
-
data: {
|
|
554
|
-
actionId: action.id,
|
|
555
|
-
previewSha256: action.previewSha256,
|
|
556
|
-
tokenHash,
|
|
557
|
-
requestedBy: getUser(c).userId,
|
|
558
|
-
expiresAt,
|
|
559
|
-
},
|
|
560
|
-
});
|
|
561
|
-
return { ...action, status: 'pending_approval', approvalExpiresAt: expiresAt };
|
|
562
|
-
});
|
|
563
|
-
return c.json({ success: true, data: { id: row.id, status: row.status, approvalExpiresAt: row.approvalExpiresAt } });
|
|
564
|
-
});
|
|
565
|
-
securityFusionRouter.post('/actions/:id/approve', requireRole('ADMIN'), async () => {
|
|
566
|
-
throw new HttpError(403, 'TELEGRAM_APPROVAL_REQUIRED', 'Containment execution requires explicit permission through the external approval channel');
|
|
567
|
-
});
|
|
568
|
-
securityFusionRouter.post('/actions/:id/reject', requireRole('ADMIN'), async () => {
|
|
569
|
-
throw new HttpError(403, 'TELEGRAM_DECISION_REQUIRED', 'Decide the exact containment plan through the external approval channel');
|
|
570
|
-
});
|
|
571
|
-
securityFusionRouter.post('/actions/:id/cancel', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
572
|
-
const actionId = c.req.param('id');
|
|
573
|
-
const row = await getDb().securityFusionAction.updateMany({ where: { id: actionId, status: { in: securityFusionActionSources('cancelled') } }, data: { status: 'cancelled', approvalTokenHash: null } });
|
|
574
|
-
if (row.count === 0)
|
|
575
|
-
throw new HttpError(409, 'INVALID_ACTION_STATE', 'Only unexecuted actions may be cancelled');
|
|
576
|
-
await getDb().securityFusionActionApproval.updateMany({ where: { actionId, status: 'pending' }, data: { status: 'cancelled', decidedAt: new Date() } });
|
|
577
|
-
return c.json({ success: true, data: { id: actionId, status: 'cancelled' } });
|
|
578
|
-
});
|
|
579
|
-
securityFusionRouter.post('/actions/:id/release', requireRole('ADMIN'), async (c) => {
|
|
580
|
-
const user = getUser(c);
|
|
581
|
-
const claimed = await getDb().$transaction(async (db) => {
|
|
582
|
-
const action = await db.securityFusionAction.findUnique({ where: { id: c.req.param('id') } });
|
|
583
|
-
if (!action || action.kind !== 'host_isolation') {
|
|
584
|
-
throw new HttpError(409, 'INVALID_ACTION_STATE', 'Only an executed isolation may be released');
|
|
585
|
-
}
|
|
586
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
587
|
-
where: { id: action.id, status: { in: securityFusionActionSources('release_pending') } },
|
|
588
|
-
data: { status: 'release_pending' },
|
|
589
|
-
});
|
|
590
|
-
if (transitioned.count !== 1) {
|
|
591
|
-
throw new HttpError(409, 'INVALID_ACTION_STATE', 'Only an executed isolation may be released');
|
|
592
|
-
}
|
|
593
|
-
const outbox = await db.securityFusionCommandOutbox.create({
|
|
594
|
-
data: { actionId: action.id, operation: 'release', actorId: user.userId },
|
|
595
|
-
});
|
|
596
|
-
return { action, outbox };
|
|
597
|
-
});
|
|
598
|
-
await dispatchSecurityFusionOutbox();
|
|
599
|
-
const dispatched = await getDb().securityFusionCommandOutbox.findUnique({ where: { id: claimed.outbox.id } });
|
|
600
|
-
await getDb().securityFusionCaseActivity.create({
|
|
601
|
-
data: { caseId: claimed.action.caseId, kind: 'isolation.release_requested', actorId: user.userId, detailJson: JSON.stringify({ actionId: claimed.action.id, outboxId: claimed.outbox.id, commandId: dispatched?.remoteCommandId ?? null }) },
|
|
602
|
-
});
|
|
603
|
-
return c.json({ success: true, data: { id: claimed.action.id, status: dispatched?.status === 'dispatched' ? 'releasing' : 'release_pending', commandId: dispatched?.remoteCommandId ?? null } }, 202);
|
|
604
|
-
});
|
|
12
|
+
securityFusionRouter.all('*', (c) => c.json({
|
|
13
|
+
success: false,
|
|
14
|
+
error: {
|
|
15
|
+
code: 'SECURITY_FUSION_MOVED',
|
|
16
|
+
message: 'Security Fusion is now Cindertrace',
|
|
17
|
+
...(securityConsoleUrl() ? { url: securityConsoleUrl() } : {}),
|
|
18
|
+
},
|
|
19
|
+
}, 410));
|
|
605
20
|
//# sourceMappingURL=security-fusion.js.map
|
|
@@ -302,6 +302,18 @@ export class NodeApiClient {
|
|
|
302
302
|
return { response: result.response, evidence: result.evidence };
|
|
303
303
|
}
|
|
304
304
|
// ── Health ──
|
|
305
|
+
async securityResponseCapable() {
|
|
306
|
+
const reply = await this.api.get('ping', { timeout: 5000, retry: 0 }).json();
|
|
307
|
+
return reply.success === true && reply.data?.features?.securityResponse === 1;
|
|
308
|
+
}
|
|
309
|
+
async securityResponse(body, commandId) {
|
|
310
|
+
if (!await this.securityResponseCapable())
|
|
311
|
+
throw new HttpError(409, 'RESPONSE_DISABLED', 'Target has not enabled its managed response adapter');
|
|
312
|
+
const result = await this.mutateWithEvidence('POST', 'security-response', body, 120000, commandId, undefined, true);
|
|
313
|
+
if (!result.evidence)
|
|
314
|
+
throw new Error('Response requires a signed managed receipt');
|
|
315
|
+
return { response: result.response, evidence: result.evidence };
|
|
316
|
+
}
|
|
305
317
|
async collectHealth() {
|
|
306
318
|
return this.api.get('health/collect').json();
|
|
307
319
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export function bridgeSignature(secret, method, path, timestamp, nonce, body) {
|
|
3
|
+
if (secret.length < 32)
|
|
4
|
+
throw new Error('Dedicated bridge secret is required');
|
|
5
|
+
return createHmac('sha256', secret)
|
|
6
|
+
.update(`cindertrace.magnus-response.v1\n${method}\n${path}\n${timestamp}\n${nonce}\n${body}`)
|
|
7
|
+
.digest('hex');
|
|
8
|
+
}
|
|
9
|
+
export function equalSignature(a, b) {
|
|
10
|
+
return (/^[a-f0-9]{64}$/.test(a) &&
|
|
11
|
+
/^[a-f0-9]{64}$/.test(b) &&
|
|
12
|
+
timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')));
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=cindertrace-response.js.map
|