fix(mcp): ограничить очередь NetFlow и защитить чтение истории

This commit is contained in:
Denozordec committed 2026-11-04 21:58:22 +07:00
1 parent c90a6ca3b8
commit b4d2d4bc20
6 files changed
+134 -10

No files matched your search

@@ -1,6 +1,9 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests } from "./diagnostic-cancellation-source.js"
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests, readServerProbeExecutionSource } from "./diagnostic-cancellation-source.js"
import { fingerprint, redact } from "./security.js"
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
@@ -25,6 +28,7 @@ function fixture() {
return { rows }
} }
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
readWithExistingKey: () => readServerProbeExecutionSource(snapshot, operationId, owner),
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
}
{
@@ -100,4 +104,22 @@ for (const mutate of mutations) {
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
}
console.log(`diagnostic-cancellation-source: locked/read-only source, expired original contexts and ${mutations.length} shared tamper checks passed`)
{
const state = await mkdtemp(join(tmpdir(), "mm-legacy-source-test-"))
const previousState = process.env.MCP_STATE_DIR
process.env.MCP_STATE_DIR = state
try {
const f = fixture()
await f.refresh()
f.row.evidence = { sealed: Buffer.alloc(32).toString("base64") }
await assert.rejects(f.readWithExistingKey, { code: "STATE_CONFLICT" })
assert.equal(f.readSql.length, 1)
assert.deepEqual(await readdir(state), [], "Historical source reads must leave missing encryption state untouched")
} finally {
if (previousState === undefined) delete process.env.MCP_STATE_DIR
else process.env.MCP_STATE_DIR = previousState
await rm(join(state, "encryption-key"), { force: true })
await rmdir(state)
}
}
console.log(`diagnostic-cancellation-source: locked/read-only source, missing key, expired original contexts and ${mutations.length} shared tamper checks passed`)
@@ -3,7 +3,7 @@ import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-p
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
import { decryptSecret, decryptSecretExisting, fingerprint, privateFingerprinterExisting, privateSecretFingerprint, redact, McpError } from "./security.js"
const MAX_BYTES = 2097152
const uuid = z.string().uuid()
@@ -113,7 +113,10 @@ export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
export const createDiagnosticCancellationSourceForTests = sourceLoader
/** Historical authentication only; confers no execution/cancellation grant. */
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
export const readServerProbeExecutionSource = readSourceLoader({
open: decryptSecretExisting,
proof: async value => (await privateFingerprinterExisting("server-probe-mcp-binding-v1"))(fingerprint(value)),
})
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
/** Exact previously locked source projection for private history conversion. */
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
@@ -1,5 +1,8 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
import { fingerprint } from "../modules/mcp/security.js"
import type { ServerProbeJournalContext } from "./server-probe-journal.js"
@@ -207,4 +210,25 @@ for (const field of ["operation_id", "connection_id", "actor_id"]) {
const f = await fixture(), verify = createLegacyServerProbeVerifierForTests(f.deps, { ...f.crypto, proof: async () => { throw Error(secret) } })
const result = await verify(f.bundle); assert.equal(result.status, "unresolved"); assert.equal(JSON.stringify(result).includes(secret), false); checks++
}
console.log(`server-probe-legacy-verifier: ${checks} inert historical authentication checks passed; migration readiness always false`)
{
const state = await mkdtemp(join(tmpdir(), "mm-legacy-verifier-test-"))
const previousState = process.env.MCP_STATE_DIR
process.env.MCP_STATE_DIR = state
try {
const receipt = await fixture()
const quarantine = await fixture("rest", false)
await quarantine.quarantine()
const verify = createLegacyServerProbeVerifier(receipt.deps)
for (const bundle of [receipt.bundle, quarantine.bundle]) {
assert.equal((await verify(bundle)).status, "unresolved")
assert.deepEqual(await readdir(state), [], "Historical reads must leave missing encryption state untouched")
checks++
}
} finally {
if (previousState === undefined) delete process.env.MCP_STATE_DIR
else process.env.MCP_STATE_DIR = previousState
await rm(join(state, "encryption-key"), { force: true })
await rmdir(state)
}
}
console.log(`server-probe-legacy-verifier: ${checks} historical authentication checks passed; migration readiness always false`)
@@ -3,7 +3,7 @@
import { isDeepStrictEqual } from "node:util"
import { z } from "zod"
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
import { decryptSecret, fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js"
import { decryptSecretExisting, fingerprint, privateFingerprinterExisting } from "../modules/mcp/security.js"
import { parseServerProbeJournalContext, validateServerProbeJournalReceipt, type ServerProbeJournalContext } from "./server-probe-journal.js"
import type { ServerProbeReceipt } from "./server-probe-execution.js"
import type { ProbeStorageScope } from "./server-probe-journal-maintenance.js"
@@ -89,8 +89,9 @@ function stages(rows: Record<string, unknown>[], ctx: ServerProbeJournalContext,
}
return received
}
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecret,
proof: (domain, value) => privateSecretFingerprint(fingerprint(value), "server-probe-journal-v1:" + domain) }
// Inspection must never recreate missing application key state.
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecretExisting,
proof: async (domain, value) => (await privateFingerprinterExisting("server-probe-journal-v1:" + domain))(fingerprint(value)) }
function verifier(dependencies: LegacyServerProbeVerifierDependencies, crypto: LegacyServerProbeVerifierCrypto) {
return async (raw: LegacyServerProbeBundle): Promise<LegacyServerProbeVerification> => {
@@ -5,6 +5,7 @@ import * as crypto from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { createFlowIngestQuality, type FlowIngestQuality } from "./traffic-flow-ingest-quality.js"
import ts from "typescript"
import { DatabaseBackgroundWork } from "./database-background-work.js"
import * as owned from "./traffic-flow-owned-batch.js"
import * as proof from "./traffic-flow-batch-proof.js"
import * as accumulator from "./traffic-flow-fact-accumulator.js"
@@ -264,3 +265,60 @@ await explicitPrune.flushPending({force:true,prune:true})
assert.equal(commits,pruneAttempts+1)
assert.ok(dispatches>pruneDispatches,"explicit retention/prune still dispatches on a fresh engine")
console.log("maintenance no-op: pristine/reset skip only; error-only unknown batch remains owned without replay; explicit prune preserved")
// A paused cold cache must not enqueue one pair of SQL loaders per datagram.
const coldWork = new DatabaseBackgroundWork()
let coldClock = 0
let topologyLoads = 0
let catalogLoads = 0
let catalogReady = false
let topologyReady = false
let rejectTopology!: (error: Error) => void
let releaseCatalog!: () => void
const firstTopology = new Promise<void>((_resolve, reject) => { rejectTopology = reject })
const firstCatalog = new Promise<void>(resolve => { releaseCatalog = resolve })
const coldDependencies = {
...dependencies,
"./database-background-work.js": { databaseBackgroundWork: coldWork },
"./traffic-flow-topology.js": {
peekFlowTopology: () => topologyReady ? {} : null,
peekServerCatalog: () => catalogReady ? { byId: new Map() } : null,
loadFlowTopology: async () => {
topologyLoads++
if (topologyLoads === 1) await firstTopology
topologyReady = true
},
getServerCatalog: async () => {
catalogLoads++
await firstCatalog
catalogReady = true
},
},
}
const coldExports: Record<string, unknown> = {}
runInNewContext(output, {
exports: coldExports,
require: (name: keyof typeof coldDependencies) => coldDependencies[name],
performance: { now: () => coldClock },
Buffer, console, setTimeout, clearTimeout,
})
const coldEngine = coldExports as unknown as Engine
for (let i = 0; i < 128; i++) coldEngine.queueParsedFlows(1, [])
assert.deepEqual([topologyLoads, catalogLoads], [1, 1])
let drained = false
const drain = coldWork.drain().then(() => { drained = true })
await Promise.resolve()
assert.equal(drained, false)
rejectTopology(new Error("Owned topology load refused"))
releaseCatalog()
await drain
assert.equal(drained, true)
coldClock = 999
for (let i = 0; i < 128; i++) coldEngine.queueParsedFlows(1, [])
assert.deepEqual([topologyLoads, catalogLoads], [1, 1])
coldClock = 1000
coldEngine.queueParsedFlows(1, [])
await coldWork.drain()
assert.deepEqual([topologyLoads, catalogLoads], [2, 1])
assert.equal(topologyReady, true)
+18 -2
View File
@@ -404,12 +404,28 @@ export function getEngineStats(): EngineStats {
}
}
function coldSourceRefresh(load: () => Promise<unknown>): () => void {
let loading = false
let retryAfter = 0
return () => {
if (loading || performance.now() < retryAfter) return
loading = true
void databaseBackgroundWork.track(load()).then(
() => { loading = false },
() => { retryAfter = performance.now() + 1000; loading = false },
)
}
}
const refreshColdTopology = coldSourceRefresh(loadFlowTopology)
const refreshColdCatalog = coldSourceRefresh(getServerCatalog)
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
if (flows.length) bumpDataEpoch()
const at=Date.now(),bucketAt=minuteBucketIso(at),hourAt=hourBucketIso(at),observedAt=new Date(at).toISOString(),ripeMisses:string[]=[]
const topo = peekFlowTopology(), catalog = peekServerCatalog()
if (!topo) void databaseBackgroundWork.track(loadFlowTopology()).catch(() => {})
if (!catalog) void databaseBackgroundWork.track(getServerCatalog()).catch(() => {})
if (!topo) refreshColdTopology()
if (!catalog) refreshColdCatalog()
const serverType = catalog?.byId.get(serverId)?.type
for (const raw of flows) {
const flow = normalizeParsedFlow(raw)