mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
feat(cutter): identify the running plugin's source and refuse a modified one (#10731)
* feat(cutter): identify the running plugin's source and refuse a modified one The Cutter plugin now publishes the SHA-256 of its own file on its session descriptor (an additive field). Discovery reports each session's bridge_source as packaged, modified, or unknown (an older plugin without the field). cutter_command refuses a modified plugin before sending anything and names the packaged plugin to copy into Cutter's plugins/python directory. * fix(cutter): report an unreadable packaged plugin instead of hiding the live session * test(cutter): give the discovery fixture sessions their bridge source
This commit is contained in:
1 parent
12dd3d38ac
commit
0c38229dd3
10 files changed
+253
-1
No files matched your search
@@ -130,6 +130,18 @@ MAX_FRAME_BYTES, BRIDGE_MAX_REQUEST_BYTES_SOURCE = (
|
||||
)
|
||||
|
||||
|
||||
def _bridge_source_sha256() -> str | None:
|
||||
"""Digest this plugin file so REA can tell its packaged copy from another one."""
|
||||
try:
|
||||
with open(__file__, "rb") as source:
|
||||
return hashlib.sha256(source.read()).hexdigest()
|
||||
except (NameError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
BRIDGE_SOURCE_SHA256 = _bridge_source_sha256()
|
||||
|
||||
|
||||
def _bridge_max_request_bytes_recovery():
|
||||
return (
|
||||
"The effective REA_BRIDGE_MAX_REQUEST_BYTES value is %d bytes "
|
||||
@@ -655,6 +667,7 @@ class _BridgeServer(threading.Thread):
|
||||
"cutter_version": self.dispatcher.cutter_version,
|
||||
"identity_status": "partial",
|
||||
"max_request_bytes": MAX_FRAME_BYTES,
|
||||
"bridge_source_sha256": BRIDGE_SOURCE_SHA256,
|
||||
}
|
||||
if os.name == "nt":
|
||||
_write_windows_descriptor(self.directory, self.descriptor, json.dumps(payload).encode("utf-8"))
|
||||
|
||||
@@ -269,6 +269,13 @@ they use `%LOCALAPPDATA%\REA\CutterBridge` on Windows when `LOCALAPPDATA` is set
|
||||
and `~/.cache/rea/cutter-bridge` otherwise. The two processes must resolve the
|
||||
same directory for discovery to find the published sessions.
|
||||
|
||||
The plugin publishes the SHA-256 of its own file on its session descriptor.
|
||||
Discovery reports each session's `bridge_source`: `packaged` when it matches
|
||||
REA's packaged `bridge/cutter/rea_bridge.py`, `modified` when it differs, and
|
||||
`unknown` when the plugin predates this field. `cutter_command` refuses a
|
||||
`modified` session before sending anything and names the packaged plugin to
|
||||
copy into Cutter's `plugins/python` directory. An `unknown` session still runs.
|
||||
|
||||
The client and plugin must support the current v2 handshake; older peers are
|
||||
rejected before dispatch because they cannot preserve cross-caller recovery proof.
|
||||
The client checks the final encoded request against
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
} from "../domain/cutterDiscovery.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { cutterBridgeMac } from "./cutterBridgeMac.js";
|
||||
import {
|
||||
cutterBridgeSource,
|
||||
packagedCutterPluginPath,
|
||||
} from "./CutterBridgeSource.js";
|
||||
import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js";
|
||||
import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
@@ -60,7 +64,12 @@ import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSna
|
||||
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
|
||||
|
||||
const descriptorSchema = z.object({
|
||||
...cutterSessionSchema.shape,
|
||||
...cutterSessionSchema.omit({ bridge_source: true }).shape,
|
||||
bridge_source_sha256: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
host: z.literal("127.0.0.1"),
|
||||
port: z.int().min(1).max(65535),
|
||||
token: z.string().min(20),
|
||||
@@ -95,6 +104,17 @@ const responseSchema = z.object({
|
||||
.regex(/^[0-9a-f]{16}$/)
|
||||
.optional(),
|
||||
});
|
||||
/** A session runs a plugin whose bytes differ from the packaged copy. */
|
||||
const modifiedPluginRefusal = (): AnalysisCapabilityUnavailableError => {
|
||||
const remediation = `Copy ${packagedCutterPluginPath()} into Cutter's plugins/python directory, restart Cutter, and list sessions again. No command was sent.`;
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
"The Cutter session runs a REA plugin whose source differs from the packaged plugin, so its protocol cannot be trusted to match this REA release.",
|
||||
{ remediationAction: remediation, userMessage: remediation },
|
||||
);
|
||||
};
|
||||
|
||||
const acknowledgementSchema = z.object({
|
||||
ok: z.literal(true),
|
||||
acknowledged: z.boolean(),
|
||||
@@ -383,6 +403,12 @@ export class CutterBridgeClient {
|
||||
mismatches.push(advertised);
|
||||
return undefined;
|
||||
}
|
||||
// Classify outside the per-peer catch: an unreadable packaged plugin
|
||||
// is REA's own failure and must not read as an absent session.
|
||||
const bridgeSource = cutterBridgeSource(
|
||||
descriptor.bridge_source_sha256,
|
||||
"list_cutter_sessions",
|
||||
);
|
||||
try {
|
||||
const status = responseSchema.safeParse(
|
||||
await this.#request(
|
||||
@@ -414,6 +440,7 @@ export class CutterBridgeClient {
|
||||
cutter_version:
|
||||
status.data.cutter_version ?? descriptor.cutter_version,
|
||||
identity_status: "partial" as const,
|
||||
bridge_source: bridgeSource,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisResourceConstraintError) throw cause;
|
||||
@@ -468,6 +495,13 @@ export class CutterBridgeClient {
|
||||
this.#requestLimit();
|
||||
const descriptor = await this.#find(input.sessionId, input.signal);
|
||||
assertBridgeActive(input.signal, "cutter_command");
|
||||
if (
|
||||
cutterBridgeSource(
|
||||
descriptor.bridge_source_sha256,
|
||||
"cutter_command",
|
||||
) === "modified"
|
||||
)
|
||||
throw modifiedPluginRefusal();
|
||||
const advertised =
|
||||
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
|
||||
if (advertised !== this.#requestLimit().maximumBytes)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { cutterBridgeSource } from "./CutterBridgeSource.js";
|
||||
|
||||
const packaged = new URL("../../bridge/cutter/rea_bridge.py", import.meta.url);
|
||||
const packagedSha256 = createHash("sha256")
|
||||
.update(readFileSync(packaged))
|
||||
.digest("hex");
|
||||
|
||||
it("classifies a session's plugin against the packaged plugin bytes", () => {
|
||||
expect(cutterBridgeSource(packagedSha256, "list_cutter_sessions")).toBe(
|
||||
"packaged",
|
||||
);
|
||||
expect(cutterBridgeSource("0".repeat(64), "list_cutter_sessions")).toBe(
|
||||
"modified",
|
||||
);
|
||||
expect(cutterBridgeSource(undefined, "list_cutter_sessions")).toBe("unknown");
|
||||
expect(cutterBridgeSource(null, "list_cutter_sessions")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("reports an unreadable packaged plugin as REA's own typed failure", () => {
|
||||
const missing = new URL("./missing-rea-bridge.py", import.meta.url);
|
||||
|
||||
expect(() =>
|
||||
cutterBridgeSource("0".repeat(64), "list_cutter_sessions", missing),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
operation: "list_cutter_sessions",
|
||||
remediationAction: expect.stringContaining("Reinstall REA"),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
cutterBridgeSource(undefined, "list_cutter_sessions", missing),
|
||||
"a plugin without a digest needs no packaged comparison",
|
||||
).toBe("unknown");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { CutterBridgeSource } from "../domain/cutterDiscovery.js";
|
||||
|
||||
const PACKAGED_PLUGIN = new URL(
|
||||
"../../bridge/cutter/rea_bridge.py",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
/** Packaged plugin path a user copies into Cutter's `plugins/python` directory. */
|
||||
export const packagedCutterPluginPath = (plugin = PACKAGED_PLUGIN): string =>
|
||||
fileURLToPath(plugin);
|
||||
|
||||
const packagedDigests = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* SHA-256 of the packaged Cutter plugin bytes. The user copies the plugin into
|
||||
* Cutter, so REA compares the digest the running plugin publishes with this
|
||||
* one instead of trusting its protocol version alone. An unreadable packaged
|
||||
* plugin is a broken REA installation, reported as such rather than hiding
|
||||
* the live session that asked for the comparison.
|
||||
*/
|
||||
const packagedCutterPluginDigest = (operation: string, plugin: URL): string => {
|
||||
const cached = packagedDigests.get(plugin.href);
|
||||
if (cached !== undefined) return cached;
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = readFileSync(plugin);
|
||||
} catch (cause: unknown) {
|
||||
const remediation = `Reinstall REA so ${packagedCutterPluginPath(plugin)} is present and readable, then retry. REA could not compare the running Cutter plugin with it, and no command was sent.`;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
operation,
|
||||
"REA's packaged Cutter plugin could not be read, so the running plugin's source cannot be identified.",
|
||||
{ cause, remediationAction: remediation, userMessage: remediation },
|
||||
);
|
||||
}
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
packagedDigests.set(plugin.href, digest);
|
||||
return digest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify the plugin a session runs. A plugin that publishes no digest
|
||||
* predates source identity, so its source is unknown rather than modified.
|
||||
*/
|
||||
export const cutterBridgeSource = (
|
||||
advertisedSha256: string | null | undefined,
|
||||
operation: string,
|
||||
plugin = PACKAGED_PLUGIN,
|
||||
): CutterBridgeSource => {
|
||||
if (advertisedSha256 === undefined || advertisedSha256 === null)
|
||||
return "unknown";
|
||||
return advertisedSha256 === packagedCutterPluginDigest(operation, plugin)
|
||||
? "packaged"
|
||||
: "modified";
|
||||
};
|
||||
@@ -1,5 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Which plugin source a session runs: the packaged copy, a different one, or
|
||||
* unknown when the plugin predates source identity.
|
||||
*/
|
||||
export const cutterBridgeSourceSchema = z.enum([
|
||||
"packaged",
|
||||
"modified",
|
||||
"unknown",
|
||||
]);
|
||||
export type CutterBridgeSource = z.infer<typeof cutterBridgeSourceSchema>;
|
||||
|
||||
/** Observed Cutter session identity without bridge transport credentials. */
|
||||
export const cutterSessionSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
@@ -8,6 +19,7 @@ export const cutterSessionSchema = z.strictObject({
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
bridge_source: cutterBridgeSourceSchema,
|
||||
});
|
||||
|
||||
/** Discovery facts retained independently of descriptor cleanup. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
writeDescriptor,
|
||||
} from "../../fixtures/cutterBridgeProtocol.js";
|
||||
import { createServer } from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
@@ -305,6 +306,81 @@ describe("CutterBridgeClient: selected POSIX paths", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("CutterBridgeClient: plugin source identity", () => {
|
||||
let directory: string | undefined;
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined)
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
server = undefined;
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
const packagedDigest = createHash("sha256")
|
||||
.update(readFileSync("bridge/cutter/rea_bridge.py"))
|
||||
.digest("hex");
|
||||
|
||||
it.each([
|
||||
["packaged", packagedDigest],
|
||||
["modified", "0".repeat(64)],
|
||||
["unknown", undefined],
|
||||
] as const)(
|
||||
"reports a %s plugin source in discovery",
|
||||
async (source, digest) => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-"));
|
||||
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
|
||||
const token = "synthetic-cutter-bridge-token-for-test";
|
||||
server = createStatusHarness(token, "/tmp/sample.bin", 4).server;
|
||||
const port = await listen(server);
|
||||
await writeDescriptor(
|
||||
directory,
|
||||
sessionId,
|
||||
port,
|
||||
token,
|
||||
digest === undefined ? {} : { bridgeSourceSha256: digest },
|
||||
);
|
||||
|
||||
const result = await createClient({
|
||||
REA_CUTTER_BRIDGE_DIR: directory,
|
||||
}).listSessions();
|
||||
|
||||
expect(result.sessions).toMatchObject([{ bridge_source: source }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a command to a modified plugin before sending it", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-"));
|
||||
const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
|
||||
const token = "synthetic-cutter-bridge-token-for-test";
|
||||
const requests: unknown[] = [];
|
||||
server = authenticatedServer(token, sessionId, (request, respond) => {
|
||||
requests.push(request);
|
||||
respond({ ok: true, execution_state: "complete" });
|
||||
});
|
||||
const port = await listen(server);
|
||||
await writeDescriptor(directory, sessionId, port, token, {
|
||||
bridgeSourceSha256: "0".repeat(64),
|
||||
});
|
||||
|
||||
await expect(
|
||||
createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({
|
||||
sessionId,
|
||||
expectedGeneration: 0,
|
||||
command: "ij",
|
||||
json: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
operation: "cutter_command",
|
||||
remediationAction: expect.stringContaining("rea_bridge.py"),
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CutterBridgeClient: scenario group 1", () => {
|
||||
let directory: string | undefined;
|
||||
let server: ReturnType<typeof createServer> | undefined;
|
||||
|
||||
@@ -111,6 +111,10 @@ it("round-trips authenticated status and command bytes across the Python produce
|
||||
const { client, session } = bridge;
|
||||
expect(session.document_generation).toBe(4);
|
||||
expect(session.current_file).toBe("/tmp/cutter-interop-雪.bin");
|
||||
expect(
|
||||
session.bridge_source,
|
||||
"the real plugin publishes the digest of its packaged bytes",
|
||||
).toBe("packaged");
|
||||
|
||||
for (let index = 0; index <= 64; index += 1) {
|
||||
await expect(
|
||||
|
||||
+4
@@ -119,6 +119,7 @@ export const writeDescriptor = async (
|
||||
readonly currentFile?: string | null;
|
||||
readonly documentGeneration?: number;
|
||||
readonly maxRequestBytes?: number;
|
||||
readonly bridgeSourceSha256?: string | null;
|
||||
} = {},
|
||||
): Promise<void> =>
|
||||
writeFile(
|
||||
@@ -136,6 +137,9 @@ export const writeDescriptor = async (
|
||||
...(options.maxRequestBytes === undefined
|
||||
? {}
|
||||
: { max_request_bytes: options.maxRequestBytes }),
|
||||
...(options.bridgeSourceSha256 === undefined
|
||||
? {}
|
||||
: { bridge_source_sha256: options.bridgeSourceSha256 }),
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
@@ -73,6 +73,7 @@ const cutterDiscoveryFixture = () =>
|
||||
current_file: "C:\\Caller files\\token=literal%2F.bin",
|
||||
cutter_version: "2.3.4",
|
||||
identity_status: "partial",
|
||||
bridge_source: "unknown",
|
||||
},
|
||||
{
|
||||
session_id: "00000000-0000-4000-8000-000000000002",
|
||||
@@ -81,6 +82,7 @@ const cutterDiscoveryFixture = () =>
|
||||
current_file: null,
|
||||
cutter_version: null,
|
||||
identity_status: "partial",
|
||||
bridge_source: "unknown",
|
||||
},
|
||||
],
|
||||
bridge_directory: "/caller/Cutter bridge/token=literal%2F/证据",
|
||||
|
||||
Reference in new issue
Block a user