From 263a98c6ce2d19fcda87569165695fc4d20c74ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:23 +0800 Subject: [PATCH] fix(managed): preserve malformed signature uncertainty in comparisons Consume the signature producer status before inferring operand-resolution limits. Preserve exact byte-match provenance while malformed paired signatures remain unknown through current and historical Evidence, CLI and MCP. --- docs/managed-code-analysis.md | 6 +- .../managed/managedMemberComparisonItems.ts | 14 ++- .../cli/managedMemberComparisonCli.test.ts | 99 +++++++++++++++++++ ...anagedMemberComparisonFromMetadata.test.ts | 47 +++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index b603bc4967..c21c25f529 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -544,7 +544,11 @@ establish which library the runtime loaded. Signatures retain their decoded fields, but a Field or MethodDef row whose signature has the other member kind is malformed and cannot establish an -unchanged signature. Runtime-provided methods are counted separately in +unchanged signature. Comparison retains exact byte-based pairs for malformed +signatures, including empty, truncated, or invalid headers, but reports them as +unknown with a `signature-malformed` limitation when no change is observed. +This applies to historical Evidence without signature-resolution attestation as +well. Runtime-provided methods are counted separately in `native_implementations.runtime_provided`; they have no file-backed native body for this workflow to map and do not increase `native_body_unresolved`. Historical verification Evidence without this separate count leaves it unknown (`null`). diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index e280b41b94..5705ee8f55 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -549,17 +549,25 @@ const unresolved = (member: Method | Field, resolved: boolean): boolean => member.signature.kind !== "unknown" && member.signature.resolved_sha256 === null; -/** Equal build-local bytes cannot establish equal unresolved type operands. */ +/** Exact bytes retain a pair without establishing a valid or resolved signature. */ const pairedSignatureLimitations = ( left: Method | Field, right: Method | Field, examined: boolean, -): string[] => - unresolved(left, examined) || unresolved(right, examined) +): string[] => { + if ( + left.signature.parse_status === "malformed" || + right.signature.parse_status === "malformed" + ) + return [ + "signature-malformed: At least one signature is malformed; equal signature bytes do not establish an unchanged member signature.", + ]; + return unresolved(left, examined) || unresolved(right, examined) ? [ "signature-not-resolved: At least one signature names a type REA could not resolve; equal build-local row bytes do not establish equivalent signature types.", ] : []; +}; /** * Declared names of unpaired members whose signature was not decoded: one-sided diff --git a/tests/boundary/cli/managedMemberComparisonCli.test.ts b/tests/boundary/cli/managedMemberComparisonCli.test.ts index 3f131a9025..b02f6f2554 100644 --- a/tests/boundary/cli/managedMemberComparisonCli.test.ts +++ b/tests/boundary/cli/managedMemberComparisonCli.test.ts @@ -1,8 +1,12 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js"; +import { managedMemberComparisonResultSchema } from "../../../src/domain/managed/managedMemberComparison.js"; import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -85,3 +89,98 @@ cliTest( }); }, ); + +cliTest( + "keeps malformed signatures unknown through CLI and stdio MCP comparison while a valid control stays unchanged", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-managed-signature-cli-", + ); + const call = await cli.sharedMcpCall!(); + for (const malformed of [true, false]) { + const name = malformed ? "truncated" : "control"; + const path = join(directory, `${name}.dll`); + const peerPath = join(directory, `${name}-peer.dll`); + const bytes = buildManagedPeFixture({ + methodSignature: Buffer.from( + malformed ? [0x00, 0x80] : [0x00, 0x00, 0x01], + ), + ilBody: Buffer.from([0x06, 0x2a]), + }); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + await writeFile(peerPath, bytes); + const inspected = await cli.sharedCliRun!({ + arguments: ["inspect-managed-members", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const cliEvidence = parseEvidence(inspected.json); + const mcpInspected = await call("inspect_managed_members", { + path: peerPath, + }); + expect(mcpInspected.isError, JSON.stringify(mcpInspected)).not.toBe(true); + const mcpEvidence = parseEvidence(mcpInspected.structuredContent); + for (const [evidence, sourcePath] of [ + [cliEvidence, path], + [mcpEvidence, peerPath], + ] as const) { + const report = managedMemberInspectionSchema.parse( + evidence.normalized_result, + ); + expect(report.artifact).toMatchObject({ path: sourcePath, sha256 }); + expect(report.coverage.state).toBe(malformed ? "partial" : "complete"); + expect(report.methods[0]?.signature).toMatchObject({ + parse_status: malformed ? "malformed" : "decoded", + ...(malformed + ? { issue: "truncated compressed integer", resolved_sha256: null } + : {}), + }); + } + const compared = await cli.sharedCliRun!({ + arguments: ["compare-managed-members", path, path, "--json"], + }); + expect(compared.exitCode, compared.stderr).toBe(0); + // Identical bytes under separate paths provide distinct authenticated + // observations for MCP, whose Evidence inputs must have different IDs. + const mcpCompared = await call("compare_managed_members", { + left: cliEvidence, + right: mcpEvidence, + }); + expect(mcpCompared.isError, JSON.stringify(mcpCompared)).not.toBe(true); + for (const evidence of [ + parseEvidence(compared.json), + parseEvidence(mcpCompared.structuredContent), + ]) { + const report = managedMemberComparisonResultSchema.parse( + evidence.normalized_result, + ); + expect(report.left.artifact_sha256).toBe(sha256); + expect(report.right.artifact_sha256).toBe(sha256); + expect(report.coverage.status).toBe( + malformed ? "partial" : "complete-within-inputs", + ); + expect(report.summary).toEqual({ + unchanged: malformed ? 1 : 2, + changed: 0, + added: 0, + removed: 0, + unknown: malformed ? 1 : 0, + }); + expect(report.methods).toEqual([ + expect.objectContaining({ + status: malformed ? "unknown" : "unchanged", + match: expect.objectContaining({ + status: "matched", + basis: "exact-il-signature", + confidence: "exact", + }), + limitations: malformed + ? [expect.stringMatching(/^signature-malformed:/)] + : [], + }), + ]); + expect(report.fields[0]?.status).toBe("unchanged"); + } + } + }, +); diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 04fa5ec038..5944d4fcd2 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -1462,6 +1462,53 @@ describe("managed member comparison beside user strings", () => { }); }); +it.each([ + ["wrong member kinds", [0x06, 0x08], [0x00, 0x00, 0x01]], + ["empty blobs", [], []], + ["truncated blobs", [0x00, 0x80], [0x06]], + ["invalid headers", [0x08, 0x00, 0x08], [0x26, 0x08]], +] as const)( + "keeps equal malformed %s unknown regardless of resolution attestation", + (_label, methodBytes, fieldBytes) => { + const side = inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from(methodBytes), + fieldSignature: Buffer.from(fieldBytes), + ilBody: Buffer.from([0x06, 0x2a]), + }), + "/tmp/malformed-signatures.dll", + ); + expect(side.result.methods[0]?.signature.parse_status).toBe("malformed"); + expect(side.result.fields[0]?.signature.parse_status).toBe("malformed"); + expect(side.result.coverage.state).toBe("partial"); + + for (const signature_resolution of [ + side.result.signature_resolution, + null, + ]) { + const observation = { + ...side, + result: { ...side.result, signature_resolution }, + }; + const comparison = compareManagedMembers(observation, observation); + expect(comparison.coverage.status).toBe("partial"); + expect(comparison.summary).toMatchObject({ + unchanged: 0, + changed: 0, + added: 0, + removed: 0, + unknown: 2, + }); + for (const item of [...comparison.methods, ...comparison.fields]) + expect(item).toMatchObject({ + status: "unknown", + match: { status: "matched", confidence: "exact" }, + limitations: [expect.stringMatching(/^signature-malformed:/)], + }); + } + }, +); + describe("managed member comparison of undecoded signatures", () => { it("remaps renamed methods only when raw signatures and complete normalized IL match", () => { const signature = Buffer.from([0x00, 0x00, 0x7f]);