@hivemagnus/magnus-server 1.1.13 → 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
|
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
export const NATIVE_COMPLETION_REGISTRY = {
|
|
3
3
|
version: 1,
|
|
4
4
|
descriptors: [
|
|
5
|
+
{ id: 'security-response', method: 'POST', pathMatcher: { kind: 'exact', path: '/security-response' },
|
|
6
|
+
completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 120, resourceClassifier: 'node-filesystem' },
|
|
5
7
|
{ id: 'file-write', method: 'POST', pathMatcher: { kind: 'exact', path: '/files/write' },
|
|
6
8
|
completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 3600, resourceClassifier: 'node-filesystem' },
|
|
7
9
|
{ id: 'skill-install', method: 'POST', pathMatcher: { kind: 'exact', path: '/skills/install' },
|
|
@@ -43,4 +45,14 @@ export function nativeCompletionResourceKey(descriptor, nodeId) {
|
|
|
43
45
|
throw new Error('Native completion resource cannot be classified');
|
|
44
46
|
return descriptor.resourceClassifier === 'node-ardent' ? `node:${nodeId}:ardent` : `node:${nodeId}`;
|
|
45
47
|
}
|
|
48
|
+
/** Preserve settlement of pre-extraction leases without granting the new response boundary retroactively. */
|
|
49
|
+
export function knownNativeCompletionIdentity(version, sha256, descriptorId) {
|
|
50
|
+
if (version !== 1 || !descriptorId)
|
|
51
|
+
return false;
|
|
52
|
+
const descriptor = NATIVE_COMPLETION_REGISTRY.descriptors.find(item => item.id === descriptorId);
|
|
53
|
+
if (!descriptor)
|
|
54
|
+
return false;
|
|
55
|
+
return sha256 === NATIVE_COMPLETION_REGISTRY_SHA256 || (descriptorId !== 'security-response'
|
|
56
|
+
&& sha256 === 'b0f05a1eac40dba262ec5ff0cc0de8d2b7c9f119a6a2d7ed3a00d94dcb6b1d3d');
|
|
57
|
+
}
|
|
46
58
|
//# sourceMappingURL=native-completion-registry.js.map
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { cindertraceResponseRouter } from './routes/cindertrace-response.js';
|
|
1
2
|
import { resourceLeasesRouter } from './routes/resource-leases.js';
|
|
2
3
|
import { sharedResourceLeasesRouter } from './routes/shared-resource-leases.js';
|
|
3
4
|
import { securityBackupRouter } from './routes/security-backup.js';
|
|
4
5
|
import { securityIntegrationRouter } from './routes/security-integration.js';
|
|
5
|
-
import { securityConsoleUrl, securityFusionMode } from './services/security-export.js';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
7
|
import { cors } from 'hono/cors';
|
|
8
8
|
import { logger } from 'hono/logger';
|
|
@@ -85,6 +85,7 @@ app.use('/api/v1/*', requestLogger);
|
|
|
85
85
|
app.use('/api/v1/security-fusion/ingest/events', bodyLimitMiddleware(Math.min(Math.max(Number(process.env['SECURITY_FUSION_INGEST_MAX_BYTES']) || 262_144, 1), 262_144)));
|
|
86
86
|
app.use('/api/v1/security-fusion/ingest/source-status', bodyLimitMiddleware(32_768));
|
|
87
87
|
app.route('/api/v1/security-integration/backups', securityBackupRouter);
|
|
88
|
+
app.use('/api/v1/security-integration/response/*', bodyLimitMiddleware(16_384));
|
|
88
89
|
app.use('/api/v1/*', async (c, next) => {
|
|
89
90
|
const limit = c.req.path === '/api/v1/hermes-registry/capture' || c.req.path === '/api/v1/hermes-registry/revisions'
|
|
90
91
|
? 4_500_000 : Number(process.env['API_BODY_LIMIT_BYTES'] ?? 1_048_576);
|
|
@@ -159,20 +160,8 @@ v1.route('/assistant', assistantRouter);
|
|
|
159
160
|
v1.route('/events/stream', eventsStreamRouter);
|
|
160
161
|
v1.route('/ops', opsRouter);
|
|
161
162
|
v1.route('/mcp', mcpRouter);
|
|
163
|
+
v1.route('/security-integration/response', cindertraceResponseRouter);
|
|
162
164
|
v1.route('/security-integration', securityIntegrationRouter);
|
|
163
|
-
v1.use('/security-fusion/*', async (c, next) => {
|
|
164
|
-
const mode = securityFusionMode();
|
|
165
|
-
if (mode === 'remote') {
|
|
166
|
-
const url = securityConsoleUrl();
|
|
167
|
-
return c.json({ success: false, error: { code: 'SECURITY_FUSION_MOVED',
|
|
168
|
-
message: 'Security Fusion is served by the external security console', ...(url ? { url } : {}) } }, 410);
|
|
169
|
-
}
|
|
170
|
-
if (mode === 'off') {
|
|
171
|
-
return c.json({ success: false, error: { code: 'SECURITY_FUSION_DISABLED',
|
|
172
|
-
message: 'Security Fusion is disabled; set SECURITY_FUSION_MODE=local or remote to enable it' } }, 404);
|
|
173
|
-
}
|
|
174
|
-
await next();
|
|
175
|
-
});
|
|
176
165
|
v1.route('/security-fusion', securityFusionRouter);
|
|
177
166
|
app.route('/api/v1', v1);
|
|
178
167
|
// ── API documentation (Swagger UI + OpenAPI spec) ──
|
|
@@ -17,7 +17,7 @@ import { RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET } from '../utils/
|
|
|
17
17
|
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
18
18
|
import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
|
|
19
19
|
import { MANAGED_HTTP_COMMAND } from '../security/managed-http.js';
|
|
20
|
-
import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor, nativeCompletionResourceKey } from '../security/native-completion-registry.js';
|
|
20
|
+
import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor, nativeCompletionResourceKey, knownNativeCompletionIdentity } from '../security/native-completion-registry.js';
|
|
21
21
|
import { logAuditEvent } from './audit-service.js';
|
|
22
22
|
const HELD = ['reserved', 'running', 'unknown'];
|
|
23
23
|
export class ResourceLeaseConflict extends HttpError {
|
|
@@ -382,9 +382,7 @@ export async function settleResourceResult(commandId, result) {
|
|
|
382
382
|
&& row.completionRegistrySha256 === COMMAND_COMPLETION_REGISTRY_SHA256
|
|
383
383
|
&& COMMAND_COMPLETION_REGISTRY.descriptors.some(descriptor => descriptor.id === row.completionDescriptorId);
|
|
384
384
|
const native = row.completionClass === 'native-http'
|
|
385
|
-
&& row.completionRegistryVersion
|
|
386
|
-
&& row.completionRegistrySha256 === NATIVE_COMPLETION_REGISTRY_SHA256
|
|
387
|
-
&& NATIVE_COMPLETION_REGISTRY.descriptors.some(descriptor => descriptor.id === row.completionDescriptorId);
|
|
385
|
+
&& knownNativeCompletionIdentity(row.completionRegistryVersion, row.completionRegistrySha256, row.completionDescriptorId);
|
|
388
386
|
const scheduler = row.completionClass === 'managed-scheduler'
|
|
389
387
|
&& /^[a-f0-9]{64}$/.test(row.completionOccurrenceId ?? '')
|
|
390
388
|
&& /^[a-f0-9]{64}$/.test(row.completionSpecSha256 ?? '')
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
3
|
import { incMetric, setMetricGauge } from '../routes/metrics.js';
|
|
4
|
-
/** Security Fusion is optional: `off` unless explicitly set to `
|
|
4
|
+
/** Security Fusion is optional: `off` unless explicitly set to `remote`. */
|
|
5
5
|
export function securityFusionMode() {
|
|
6
6
|
const mode = process.env['SECURITY_FUSION_MODE'];
|
|
7
|
-
|
|
7
|
+
if (mode === 'local')
|
|
8
|
+
throw new Error('Embedded Security Fusion is retired. Export its state with the Cindertrace migration tool, verify the import, then configure SECURITY_FUSION_MODE=remote. See docs/CINDERTRACE_MIGRATION.md');
|
|
9
|
+
return mode === 'remote' ? mode : 'off';
|
|
8
10
|
}
|
|
9
11
|
/** Observations are exported to an external security console. */
|
|
10
12
|
export function securityFusionRemote() {
|
|
11
13
|
return securityFusionMode() === 'remote';
|
|
12
14
|
}
|
|
13
|
-
/** Security Fusion cases, evidence and receipts are kept in this server's database. */
|
|
14
|
-
export function securityFusionLocal() {
|
|
15
|
-
return securityFusionMode() === 'local';
|
|
16
|
-
}
|
|
17
15
|
/** Optional external security console URL shown to operators in remote mode. */
|
|
18
16
|
export function securityConsoleUrl() {
|
|
19
17
|
const raw = process.env['MAGNUS_SECURITY_CONSOLE_URL'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Magnus server: the fleet control plane API (Hono, PostgreSQL, Redis) for nodes running magnus-agent, with optional Hive IAM, Hive Router, HiveMem, Hermes Agent and external security console integrations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"prepublishOnly": "npm run db:generate && npm run clean && npm run build"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@hivemagnus/magnus-contracts": "
|
|
87
|
+
"@hivemagnus/magnus-contracts": "2.0.0",
|
|
88
88
|
"@hono/node-server": "^2.1.5",
|
|
89
89
|
"@hono/swagger-ui": "^0.6.2",
|
|
90
90
|
"@prisma/adapter-pg": "7.10.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"ws": "^8.22.0",
|
|
103
103
|
"yaml": "2.9.1",
|
|
104
104
|
"zod": "^4.6.7",
|
|
105
|
-
"@hivemagnus/magnus-skills": "
|
|
105
|
+
"@hivemagnus/magnus-skills": "2.0.0",
|
|
106
106
|
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"migration": "20261103000000_cindertrace_response_bridge",
|
|
4
|
+
"classification": "schema-only",
|
|
5
|
+
"effects": [
|
|
6
|
+
{
|
|
7
|
+
"table": "cindertrace_responses",
|
|
8
|
+
"operation": "create-empty-table",
|
|
9
|
+
"expectedRows": 0
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"schemaEffects": [
|
|
13
|
+
{
|
|
14
|
+
"type": "column",
|
|
15
|
+
"identity": "public.cindertrace_responses.actionOperation",
|
|
16
|
+
"operation": "create",
|
|
17
|
+
"afterSha256": "457481149eb42973ffc9ff8d2ef366d76f86c86b67060944b7ed71e7fade4dee"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "column",
|
|
21
|
+
"identity": "public.cindertrace_responses.createdAt",
|
|
22
|
+
"operation": "create",
|
|
23
|
+
"afterSha256": "c738ae7d397b6458fb77e1a5c5d0e45c176da1f1b23dff5c8e0287ac32657cfb"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "column",
|
|
27
|
+
"identity": "public.cindertrace_responses.id",
|
|
28
|
+
"operation": "create",
|
|
29
|
+
"afterSha256": "457481149eb42973ffc9ff8d2ef366d76f86c86b67060944b7ed71e7fade4dee"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"type": "column",
|
|
33
|
+
"identity": "public.cindertrace_responses.nodeId",
|
|
34
|
+
"operation": "create",
|
|
35
|
+
"afterSha256": "457481149eb42973ffc9ff8d2ef366d76f86c86b67060944b7ed71e7fade4dee"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"type": "column",
|
|
39
|
+
"identity": "public.cindertrace_responses.requestJson",
|
|
40
|
+
"operation": "create",
|
|
41
|
+
"afterSha256": "457481149eb42973ffc9ff8d2ef366d76f86c86b67060944b7ed71e7fade4dee"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "column",
|
|
45
|
+
"identity": "public.cindertrace_responses.requestSha256",
|
|
46
|
+
"operation": "create",
|
|
47
|
+
"afterSha256": "457481149eb42973ffc9ff8d2ef366d76f86c86b67060944b7ed71e7fade4dee"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"type": "column",
|
|
51
|
+
"identity": "public.cindertrace_responses.responseJson",
|
|
52
|
+
"operation": "create",
|
|
53
|
+
"afterSha256": "2a7dfe10f0d53943c328aa5aff320e3d46ef00ea7e9a6e1185909912cc2b4b49"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"type": "column",
|
|
57
|
+
"identity": "public.cindertrace_responses.status",
|
|
58
|
+
"operation": "create",
|
|
59
|
+
"afterSha256": "222458c4b395fd8478e5c334ec5485250ada4b47e6f57591e7c030d65834bf8a"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "constraint",
|
|
63
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_actionOperation_not_null",
|
|
64
|
+
"operation": "create",
|
|
65
|
+
"afterSha256": "8183f6cdc869d43ebb2f34fe24a356b179b2c82be296b0dc164e16a28b51ed0e"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "constraint",
|
|
69
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_createdAt_not_null",
|
|
70
|
+
"operation": "create",
|
|
71
|
+
"afterSha256": "217cb343ac8189a729cd1f07d448627a4ae74f8afdb99362b2e3ce0097372798"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "constraint",
|
|
75
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_id_not_null",
|
|
76
|
+
"operation": "create",
|
|
77
|
+
"afterSha256": "e7b643243977e7692df3d6151aecafb3ee916f1612593c7c35b0272fb315a145"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"type": "constraint",
|
|
81
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_nodeId_not_null",
|
|
82
|
+
"operation": "create",
|
|
83
|
+
"afterSha256": "ff5bd491bcd090deecd11bc37c2de364b38abe9277d34f7a6acacf7cfe9c5515"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"type": "constraint",
|
|
87
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_pkey",
|
|
88
|
+
"operation": "create",
|
|
89
|
+
"afterSha256": "660693e78c67ae23c6f23d327b4747fdcae396a2df9561bf5378c3169d0e9644"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"type": "constraint",
|
|
93
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_requestJson_not_null",
|
|
94
|
+
"operation": "create",
|
|
95
|
+
"afterSha256": "75d1cc8be180d0f79c7b7524765de3939264fe158a1677ef9d876dfd7baebdef"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"type": "constraint",
|
|
99
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_requestSha256_not_null",
|
|
100
|
+
"operation": "create",
|
|
101
|
+
"afterSha256": "76fc856b52c0d2b2ba3d3a3199e82079d108fd73840a75bd8b94e3e71119d451"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"type": "constraint",
|
|
105
|
+
"identity": "public.cindertrace_responses.cindertrace_responses_status_not_null",
|
|
106
|
+
"operation": "create",
|
|
107
|
+
"afterSha256": "f7237f3a7c1ae5d7fa369cffdbd200e599219726332e0882a791a2c78de56f36"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"type": "index",
|
|
111
|
+
"identity": "public.cindertrace_responses_actionOperation_key",
|
|
112
|
+
"operation": "create",
|
|
113
|
+
"afterSha256": "9c4741fba6da61618aec19957c4dedbd66767cb7a40200b798026e58a5b27bdd"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"type": "index",
|
|
117
|
+
"identity": "public.cindertrace_responses_pkey",
|
|
118
|
+
"operation": "create",
|
|
119
|
+
"afterSha256": "3a9cc34672b45d3dcc05d7f6760ffce281eec29f433711982e6cadd460d79f27"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"type": "table",
|
|
123
|
+
"identity": "public.cindertrace_responses",
|
|
124
|
+
"operation": "create",
|
|
125
|
+
"afterSha256": "577938e54f94a986377167fe24c850e59d65f8ec8df6eb9c8059bde0c3e156d2"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
CREATE TABLE "cindertrace_responses" (
|
|
2
|
+
"id" TEXT NOT NULL PRIMARY KEY, "actionOperation" TEXT NOT NULL, "nodeId" TEXT NOT NULL,
|
|
3
|
+
"requestSha256" TEXT NOT NULL, "requestJson" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'claimed',
|
|
4
|
+
"responseJson" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
5
|
+
);
|
|
6
|
+
CREATE UNIQUE INDEX "cindertrace_responses_actionOperation_key" ON "cindertrace_responses"("actionOperation");
|
package/prisma/schema.prisma
CHANGED
|
@@ -2183,3 +2183,16 @@ model ManagedCronOccurrence {
|
|
|
2183
2183
|
@@index([nodeId, state])
|
|
2184
2184
|
@@map("managed_cron_occurrences")
|
|
2185
2185
|
}
|
|
2186
|
+
|
|
2187
|
+
/// Narrow Cindertrace bridge ledger; never replays an uncertain response.
|
|
2188
|
+
model CindertraceResponse {
|
|
2189
|
+
id String @id
|
|
2190
|
+
actionOperation String @unique
|
|
2191
|
+
nodeId String
|
|
2192
|
+
requestSha256 String
|
|
2193
|
+
requestJson String
|
|
2194
|
+
status String @default("claimed")
|
|
2195
|
+
responseJson String?
|
|
2196
|
+
createdAt DateTime @default(now())
|
|
2197
|
+
@@map("cindertrace_responses")
|
|
2198
|
+
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export const SECURITY_FUSION_ACTION_TRANSITIONS = {
|
|
2
|
-
previewed: ['pending_approval', 'cancelled'],
|
|
3
|
-
pending_approval: ['dispatch_pending', 'rejected', 'cancelled'],
|
|
4
|
-
dispatch_pending: ['executing', 'verifying', 'succeeded', 'failed'],
|
|
5
|
-
executing: ['verifying', 'succeeded', 'failed', 'release_pending'],
|
|
6
|
-
verifying: ['succeeded', 'failed'],
|
|
7
|
-
succeeded: ['release_pending'],
|
|
8
|
-
release_pending: ['releasing', 'released', 'failed'],
|
|
9
|
-
releasing: ['released', 'failed'],
|
|
10
|
-
rejected: [],
|
|
11
|
-
cancelled: [],
|
|
12
|
-
failed: [],
|
|
13
|
-
released: [],
|
|
14
|
-
};
|
|
15
|
-
export function canTransitionSecurityFusionAction(from, to) {
|
|
16
|
-
return SECURITY_FUSION_ACTION_TRANSITIONS[from].includes(to);
|
|
17
|
-
}
|
|
18
|
-
export function securityFusionActionSources(to) {
|
|
19
|
-
return Object.keys(SECURITY_FUSION_ACTION_TRANSITIONS)
|
|
20
|
-
.filter((from) => canTransitionSecurityFusionAction(from, to));
|
|
21
|
-
}
|
|
22
|
-
//# sourceMappingURL=security-fusion-actions.js.map
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { createReadStream } from 'node:fs';
|
|
3
|
-
import { lstat, mkdir, rename } from 'node:fs/promises';
|
|
4
|
-
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
-
import { getDb } from '../db/index.js';
|
|
6
|
-
import { setMetricGauge } from '../routes/metrics.js';
|
|
7
|
-
function evidenceBase() {
|
|
8
|
-
return resolve(process.env['SECURITY_FUSION_EVIDENCE_DIR'] ?? './state/security-fusion/evidence');
|
|
9
|
-
}
|
|
10
|
-
function pathInside(base, candidate) {
|
|
11
|
-
const rel = relative(base, resolve(candidate));
|
|
12
|
-
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
|
13
|
-
}
|
|
14
|
-
async function hashFile(path) {
|
|
15
|
-
const hash = createHash('sha256');
|
|
16
|
-
await new Promise((resolvePromise, reject) => {
|
|
17
|
-
const stream = createReadStream(path);
|
|
18
|
-
stream.on('data', (chunk) => hash.update(chunk));
|
|
19
|
-
stream.on('error', reject);
|
|
20
|
-
stream.on('end', resolvePromise);
|
|
21
|
-
});
|
|
22
|
-
return hash.digest('hex');
|
|
23
|
-
}
|
|
24
|
-
export async function ensurePrivateEvidenceDirectory(path) {
|
|
25
|
-
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
26
|
-
const info = await lstat(path);
|
|
27
|
-
const expectedUid = typeof process.geteuid === 'function' ? process.geteuid() : info.uid;
|
|
28
|
-
if (!info.isDirectory() || info.isSymbolicLink()
|
|
29
|
-
|| info.uid !== expectedUid || (info.mode & 0o077) !== 0) {
|
|
30
|
-
throw new Error(`Security Fusion evidence directory is unsafe: ${path}`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
async function quarantineEvidence(row, status, reason, actualBytes) {
|
|
34
|
-
const base = evidenceBase();
|
|
35
|
-
let storagePath = row.storagePath;
|
|
36
|
-
if (status === 'quarantined_hash_mismatch' && pathInside(base, row.storagePath)) {
|
|
37
|
-
const quarantineDir = join(base, 'quarantine');
|
|
38
|
-
await ensurePrivateEvidenceDirectory(quarantineDir);
|
|
39
|
-
const target = join(quarantineDir, `${row.id}.${basename(row.storagePath)}`);
|
|
40
|
-
await rename(row.storagePath, target);
|
|
41
|
-
storagePath = target;
|
|
42
|
-
}
|
|
43
|
-
await getDb().securityFusionEvidence.update({
|
|
44
|
-
where: { id: row.id },
|
|
45
|
-
data: {
|
|
46
|
-
byteSize: actualBytes,
|
|
47
|
-
integrityStatus: status,
|
|
48
|
-
quarantinedAt: new Date(),
|
|
49
|
-
quarantineReason: reason,
|
|
50
|
-
storagePath,
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Reconcile every retained artifact before byte admission and retention start.
|
|
56
|
-
* Missing, unsafe, non-regular, and hash-mismatched evidence remains recorded
|
|
57
|
-
* but is quarantined from normal evidence use.
|
|
58
|
-
*/
|
|
59
|
-
export async function reconcileSecurityFusionEvidence() {
|
|
60
|
-
const base = evidenceBase();
|
|
61
|
-
await ensurePrivateEvidenceDirectory(base);
|
|
62
|
-
const mismatchedCases = new Set();
|
|
63
|
-
let checked = 0;
|
|
64
|
-
let updated = 0;
|
|
65
|
-
let quarantined = 0;
|
|
66
|
-
let cursor;
|
|
67
|
-
while (true) {
|
|
68
|
-
const rows = await getDb().securityFusionEvidence.findMany({
|
|
69
|
-
select: {
|
|
70
|
-
id: true,
|
|
71
|
-
caseId: true,
|
|
72
|
-
sha256: true,
|
|
73
|
-
storagePath: true,
|
|
74
|
-
byteSize: true,
|
|
75
|
-
integrityStatus: true,
|
|
76
|
-
},
|
|
77
|
-
orderBy: { id: 'asc' },
|
|
78
|
-
take: 250,
|
|
79
|
-
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
|
80
|
-
});
|
|
81
|
-
for (const row of rows) {
|
|
82
|
-
checked += 1;
|
|
83
|
-
if (row.integrityStatus.startsWith('quarantined_')) {
|
|
84
|
-
mismatchedCases.add(row.caseId);
|
|
85
|
-
quarantined += 1;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (!pathInside(base, row.storagePath)) {
|
|
89
|
-
mismatchedCases.add(row.caseId);
|
|
90
|
-
quarantined += 1;
|
|
91
|
-
await quarantineEvidence(row, 'quarantined_unsafe_path', 'storage path is outside the evidence root', row.byteSize);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
let info;
|
|
95
|
-
try {
|
|
96
|
-
info = await lstat(row.storagePath);
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
if (error.code !== 'ENOENT')
|
|
100
|
-
throw error;
|
|
101
|
-
mismatchedCases.add(row.caseId);
|
|
102
|
-
quarantined += 1;
|
|
103
|
-
await quarantineEvidence(row, 'quarantined_missing', 'retained evidence file is missing', 0);
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
if (!info.isFile() || info.isSymbolicLink()) {
|
|
107
|
-
mismatchedCases.add(row.caseId);
|
|
108
|
-
quarantined += 1;
|
|
109
|
-
await quarantineEvidence(row, 'quarantined_unsafe_file', 'retained evidence is not a regular file', row.byteSize);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
const actualBytes = info.size;
|
|
113
|
-
const actualSha256 = await hashFile(row.storagePath);
|
|
114
|
-
if (actualBytes !== row.byteSize)
|
|
115
|
-
mismatchedCases.add(row.caseId);
|
|
116
|
-
if (actualSha256 !== row.sha256) {
|
|
117
|
-
mismatchedCases.add(row.caseId);
|
|
118
|
-
quarantined += 1;
|
|
119
|
-
await quarantineEvidence(row, 'quarantined_hash_mismatch', 'retained evidence hash does not match its manifest', actualBytes);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (actualBytes !== row.byteSize)
|
|
123
|
-
updated += 1;
|
|
124
|
-
await getDb().securityFusionEvidence.update({
|
|
125
|
-
where: { id: row.id },
|
|
126
|
-
data: {
|
|
127
|
-
byteSize: actualBytes,
|
|
128
|
-
integrityStatus: 'verified',
|
|
129
|
-
quarantinedAt: null,
|
|
130
|
-
quarantineReason: null,
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
if (rows.length < 250)
|
|
135
|
-
break;
|
|
136
|
-
cursor = rows.at(-1)?.id;
|
|
137
|
-
if (!cursor)
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
setMetricGauge('securityFusionEvidenceReconciliationMismatchedCases', mismatchedCases.size);
|
|
141
|
-
setMetricGauge('securityFusionEvidenceQuarantinedArtifacts', quarantined);
|
|
142
|
-
return { checked, updated, quarantined, mismatchedCases: mismatchedCases.size };
|
|
143
|
-
}
|
|
144
|
-
//# sourceMappingURL=security-fusion-evidence-integrity.js.map
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { securityFusionSourceSchema } from '@hivemagnus/magnus-contracts';
|
|
3
|
-
import { HttpError } from '../middleware/error-handler.js';
|
|
4
|
-
export const SECURITY_FUSION_MAX_BATCH_BYTES = 262_144;
|
|
5
|
-
export const SECURITY_FUSION_MAX_EVENT_BYTES = 32_768;
|
|
6
|
-
export const SECURITY_FUSION_MAX_EVENTS = 100;
|
|
7
|
-
export const SECURITY_FUSION_MAX_NESTING_DEPTH = 8;
|
|
8
|
-
function sources(value) {
|
|
9
|
-
const candidates = Array.isArray(value)
|
|
10
|
-
? value
|
|
11
|
-
: typeof value === 'string'
|
|
12
|
-
? value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
13
|
-
: [];
|
|
14
|
-
const parsed = securityFusionSourceSchema.array().min(1).safeParse(candidates);
|
|
15
|
-
if (!parsed.success)
|
|
16
|
-
throw new Error('collector allowedSources must contain recognized Security Fusion sources');
|
|
17
|
-
return [...new Set(parsed.data)];
|
|
18
|
-
}
|
|
19
|
-
export function configuredCollectorCredentials(env = process.env) {
|
|
20
|
-
const credentials = new Map();
|
|
21
|
-
const encoded = env['SECURITY_FUSION_COLLECTOR_CREDENTIALS_JSON'];
|
|
22
|
-
if (encoded) {
|
|
23
|
-
const value = JSON.parse(encoded);
|
|
24
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
25
|
-
throw new Error('SECURITY_FUSION_COLLECTOR_CREDENTIALS_JSON must be an object');
|
|
26
|
-
}
|
|
27
|
-
for (const [collectorId, raw] of Object.entries(value)) {
|
|
28
|
-
if (!/^[a-zA-Z0-9._:-]{1,160}$/u.test(collectorId)) {
|
|
29
|
-
throw new Error('collector IDs must be 1-160 safe identifier characters');
|
|
30
|
-
}
|
|
31
|
-
const config = typeof raw === 'string' ? { secret: raw } : raw;
|
|
32
|
-
if (!config || typeof config !== 'object' || typeof config['secret'] !== 'string' || config['secret'].length < 16) {
|
|
33
|
-
throw new Error(`collector ${collectorId} must have a secret of at least 16 characters`);
|
|
34
|
-
}
|
|
35
|
-
credentials.set(collectorId, {
|
|
36
|
-
collectorId,
|
|
37
|
-
secret: config['secret'],
|
|
38
|
-
allowedSources: sources(config['allowedSources'] ?? securityFusionSourceSchema.options),
|
|
39
|
-
displayName: typeof config['displayName'] === 'string' ? config['displayName'] : collectorId,
|
|
40
|
-
schemaVersion: '1.0',
|
|
41
|
-
dataClassification: typeof config['dataClassification'] === 'string'
|
|
42
|
-
? config['dataClassification']
|
|
43
|
-
: 'restricted',
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
const legacySecret = env['SECURITY_FUSION_INGEST_SECRET'];
|
|
48
|
-
if (legacySecret) {
|
|
49
|
-
const collectorId = env['SECURITY_FUSION_COLLECTOR_ID'] ?? 'magnus-security-collector';
|
|
50
|
-
if (!/^[a-zA-Z0-9._:-]{1,160}$/u.test(collectorId) || legacySecret.length < 16) {
|
|
51
|
-
throw new Error('legacy collector ID or credential is invalid');
|
|
52
|
-
}
|
|
53
|
-
if (!credentials.has(collectorId)) {
|
|
54
|
-
credentials.set(collectorId, {
|
|
55
|
-
collectorId,
|
|
56
|
-
secret: legacySecret,
|
|
57
|
-
allowedSources: sources(env['SECURITY_FUSION_COLLECTOR_ALLOWED_SOURCES'] ?? securityFusionSourceSchema.options),
|
|
58
|
-
displayName: collectorId,
|
|
59
|
-
schemaVersion: '1.0',
|
|
60
|
-
dataClassification: env['SECURITY_FUSION_DATA_CLASSIFICATION'] ?? 'restricted',
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return credentials;
|
|
65
|
-
}
|
|
66
|
-
export function collectorCredentialFingerprint(secret) {
|
|
67
|
-
return createHash('sha256').update(secret).digest('hex').slice(0, 16);
|
|
68
|
-
}
|
|
69
|
-
function assertDepth(value, depth) {
|
|
70
|
-
if (depth > SECURITY_FUSION_MAX_NESTING_DEPTH) {
|
|
71
|
-
throw new HttpError(400, 'INGEST_NESTING_TOO_DEEP', 'Event nesting exceeds the permitted depth');
|
|
72
|
-
}
|
|
73
|
-
if (Array.isArray(value)) {
|
|
74
|
-
for (const item of value)
|
|
75
|
-
assertDepth(item, depth + 1);
|
|
76
|
-
}
|
|
77
|
-
else if (value !== null && typeof value === 'object') {
|
|
78
|
-
for (const item of Object.values(value))
|
|
79
|
-
assertDepth(item, depth + 1);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
export function validateSecurityFusionBatchEnvelope(value, rawBytes) {
|
|
83
|
-
if (rawBytes > SECURITY_FUSION_MAX_BATCH_BYTES) {
|
|
84
|
-
throw new HttpError(413, 'PAYLOAD_TOO_LARGE', 'Security Fusion batch exceeds the byte limit');
|
|
85
|
-
}
|
|
86
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
87
|
-
return;
|
|
88
|
-
const events = value['events'];
|
|
89
|
-
if (!Array.isArray(events))
|
|
90
|
-
return;
|
|
91
|
-
if (events.length > SECURITY_FUSION_MAX_EVENTS) {
|
|
92
|
-
throw new HttpError(400, 'INGEST_EVENT_LIMIT', `A batch may contain at most ${SECURITY_FUSION_MAX_EVENTS} events`);
|
|
93
|
-
}
|
|
94
|
-
for (const event of events) {
|
|
95
|
-
assertDepth(event, 1);
|
|
96
|
-
if (Buffer.byteLength(JSON.stringify(event), 'utf8') > SECURITY_FUSION_MAX_EVENT_BYTES) {
|
|
97
|
-
throw new HttpError(400, 'INGEST_EVENT_TOO_LARGE', 'An individual event exceeds the byte limit');
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
//# sourceMappingURL=security-fusion-ingest.js.map
|