fix(mcp): ограничить очередь NetFlow и защитить чтение истории
This commit is contained in:
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 assert from "node:assert/strict"
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
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"
|
import { fingerprint, redact } from "./security.js"
|
||||||
|
|
||||||
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
||||||
@@ -25,6 +28,7 @@ function fixture() {
|
|||||||
return { rows }
|
return { rows }
|
||||||
} }
|
} }
|
||||||
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
|
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 }
|
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.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
||||||
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
|
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 type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
|
||||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||||
import { openServerProbeMcpBinding } from "./server-probe-actions.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 MAX_BYTES = 2097152
|
||||||
const uuid = z.string().uuid()
|
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. */
|
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
||||||
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
||||||
/** Historical authentication only; confers no execution/cancellation grant. */
|
/** 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
|
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
|
||||||
/** Exact previously locked source projection for private history conversion. */
|
/** Exact previously locked source projection for private history conversion. */
|
||||||
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
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 type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
||||||
import { fingerprint } from "../modules/mcp/security.js"
|
import { fingerprint } from "../modules/mcp/security.js"
|
||||||
import type { ServerProbeJournalContext } from "./server-probe-journal.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 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++
|
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 { isDeepStrictEqual } from "node:util"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
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 { parseServerProbeJournalContext, validateServerProbeJournalReceipt, type ServerProbeJournalContext } from "./server-probe-journal.js"
|
||||||
import type { ServerProbeReceipt } from "./server-probe-execution.js"
|
import type { ServerProbeReceipt } from "./server-probe-execution.js"
|
||||||
import type { ProbeStorageScope } from "./server-probe-journal-maintenance.js"
|
import type { ProbeStorageScope } from "./server-probe-journal-maintenance.js"
|
||||||
@@ -89,8 +89,9 @@ function stages(rows: Record<string, unknown>[], ctx: ServerProbeJournalContext,
|
|||||||
}
|
}
|
||||||
return received
|
return received
|
||||||
}
|
}
|
||||||
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecret,
|
// Inspection must never recreate missing application key state.
|
||||||
proof: (domain, value) => privateSecretFingerprint(fingerprint(value), "server-probe-journal-v1:" + domain) }
|
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecretExisting,
|
||||||
|
proof: async (domain, value) => (await privateFingerprinterExisting("server-probe-journal-v1:" + domain))(fingerprint(value)) }
|
||||||
|
|
||||||
function verifier(dependencies: LegacyServerProbeVerifierDependencies, crypto: LegacyServerProbeVerifierCrypto) {
|
function verifier(dependencies: LegacyServerProbeVerifierDependencies, crypto: LegacyServerProbeVerifierCrypto) {
|
||||||
return async (raw: LegacyServerProbeBundle): Promise<LegacyServerProbeVerification> => {
|
return async (raw: LegacyServerProbeBundle): Promise<LegacyServerProbeVerification> => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as crypto from "node:crypto"
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { createFlowIngestQuality, type FlowIngestQuality } from "./traffic-flow-ingest-quality.js"
|
import { createFlowIngestQuality, type FlowIngestQuality } from "./traffic-flow-ingest-quality.js"
|
||||||
import ts from "typescript"
|
import ts from "typescript"
|
||||||
|
import { DatabaseBackgroundWork } from "./database-background-work.js"
|
||||||
import * as owned from "./traffic-flow-owned-batch.js"
|
import * as owned from "./traffic-flow-owned-batch.js"
|
||||||
import * as proof from "./traffic-flow-batch-proof.js"
|
import * as proof from "./traffic-flow-batch-proof.js"
|
||||||
import * as accumulator from "./traffic-flow-fact-accumulator.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.equal(commits,pruneAttempts+1)
|
||||||
assert.ok(dispatches>pruneDispatches,"explicit retention/prune still dispatches on a fresh engine")
|
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")
|
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)
|
||||||
@@ -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 {
|
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||||
if (flows.length) bumpDataEpoch()
|
if (flows.length) bumpDataEpoch()
|
||||||
const at=Date.now(),bucketAt=minuteBucketIso(at),hourAt=hourBucketIso(at),observedAt=new Date(at).toISOString(),ripeMisses:string[]=[]
|
const at=Date.now(),bucketAt=minuteBucketIso(at),hourAt=hourBucketIso(at),observedAt=new Date(at).toISOString(),ripeMisses:string[]=[]
|
||||||
const topo = peekFlowTopology(), catalog = peekServerCatalog()
|
const topo = peekFlowTopology(), catalog = peekServerCatalog()
|
||||||
if (!topo) void databaseBackgroundWork.track(loadFlowTopology()).catch(() => {})
|
if (!topo) refreshColdTopology()
|
||||||
if (!catalog) void databaseBackgroundWork.track(getServerCatalog()).catch(() => {})
|
if (!catalog) refreshColdCatalog()
|
||||||
const serverType = catalog?.byId.get(serverId)?.type
|
const serverType = catalog?.byId.get(serverId)?.type
|
||||||
for (const raw of flows) {
|
for (const raw of flows) {
|
||||||
const flow = normalizeParsedFlow(raw)
|
const flow = normalizeParsedFlow(raw)
|
||||||
|
|||||||
Reference in new issue
Block a user