@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.
Files changed (33) hide show
  1. package/.env.example +5 -0
  2. package/README.md +4 -0
  3. package/agent-version.json +1 -1
  4. package/dist/cli/env-catalog.js +3 -0
  5. package/dist/config/env.js +4 -5
  6. package/dist/config/features.js +1 -1
  7. package/dist/db/generated/prisma/internal/class.js +4 -4
  8. package/dist/db/generated/prisma/internal/prismaNamespace.js +12 -1
  9. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +12 -1
  10. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  11. package/dist/index.js +1 -16
  12. package/dist/openapi/spec.js +7 -2
  13. package/dist/queue/workers.js +0 -65
  14. package/dist/routes/cindertrace-response.js +183 -0
  15. package/dist/routes/heartbeat.js +1 -6
  16. package/dist/routes/security-fusion.js +15 -600
  17. package/dist/sdk/node-api-client.js +12 -0
  18. package/dist/security/cindertrace-response.js +14 -0
  19. package/dist/security/native-completion-registry.js +12 -0
  20. package/dist/server.js +3 -14
  21. package/dist/services/command-resource-leases.js +2 -4
  22. package/dist/services/security-export.js +4 -6
  23. package/package.json +3 -3
  24. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  25. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  26. package/prisma/schema.prisma +13 -0
  27. package/dist/services/security-fusion-actions.js +0 -22
  28. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  29. package/dist/services/security-fusion-ingest.js +0 -101
  30. package/dist/services/security-fusion-outbox.js +0 -185
  31. package/dist/services/security-fusion-receipt-chain.js +0 -134
  32. package/dist/services/security-fusion-service.js +0 -751
  33. package/dist/services/security-fusion-verification.js +0 -50
@@ -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 === NATIVE_COMPLETION_REGISTRY.version
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 `local` or `remote`. */
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
- return mode === 'local' || mode === 'remote' ? mode : 'off';
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": "1.1.13",
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": "1.1.13",
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": "1.1.13",
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");
@@ -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