314 lines
9.5 KiB
TypeScript
314 lines
9.5 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { compareManagedMemberPaths } from "../src/application/ManagedMemberComparisonService.js";
|
|
import { MANAGED_STATIC_PROVIDER } from "../src/application/InvestigationProviders.js";
|
|
import {
|
|
compareManagedMembers,
|
|
managedMemberComparisonResultSchema,
|
|
} from "../src/domain/managedMemberComparison.js";
|
|
import { createEvidence } from "../src/domain/evidence.js";
|
|
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js";
|
|
import { buildManagedPeFixture } from "./fixtures/managedPe.js";
|
|
|
|
const memberLimits = {
|
|
typeOffset: 0,
|
|
typeLimit: 100,
|
|
methodOffset: 0,
|
|
methodLimit: 100,
|
|
fieldOffset: 0,
|
|
fieldLimit: 100,
|
|
memberRefOffset: 0,
|
|
memberRefLimit: 100,
|
|
edgeOffset: 0,
|
|
edgeLimit: 100,
|
|
instructionAnchorLimit: 100,
|
|
maxMetadataBytes: 1024 * 1024,
|
|
maxTableRows: 1_000,
|
|
maxHeapItemBytes: 1024 * 1024,
|
|
maxMethodBodyBytes: 1024 * 1024,
|
|
maxMethodInstructions: 1_000,
|
|
};
|
|
|
|
const pathMemberLimits = {
|
|
...memberLimits,
|
|
maxFileBytes: 1024 * 1024,
|
|
};
|
|
|
|
const comparisonLimits = {
|
|
max_method_matches: 100,
|
|
max_field_matches: 100,
|
|
max_candidates: 10,
|
|
};
|
|
|
|
describe("managed member comparison", () => {
|
|
it("remaps renamed methods by exact CIL/signature without using names", () => {
|
|
const leftBytes = buildManagedPeFixture();
|
|
const rightBytes = buildManagedPeFixture({
|
|
mvid: Buffer.from("00112233445566778899aabbccddeefe", "hex"),
|
|
typeName: "A",
|
|
typeNamespace: "X",
|
|
methodName: "b",
|
|
fieldName: "c",
|
|
});
|
|
const left = inspect(leftBytes, "/tmp/left.dll");
|
|
const right = inspect(rightBytes, "/tmp/right.dll");
|
|
const result = compareManagedMembers(
|
|
{ evidenceId: left.evidence.evidence_id, result: left.result },
|
|
{ evidenceId: right.evidence.evidence_id, result: right.result },
|
|
comparisonLimits,
|
|
);
|
|
|
|
expect(result.algorithm.name_matching).toBe("not-used");
|
|
expect(result.left.mvid).toBe("00112233-4455-6677-8899-aabbccddeeff");
|
|
expect(result.right.mvid).toBe("33221100-5544-7766-8899-aabbccddeefe");
|
|
expect(result.matching.exact_il_signature).toBe(1);
|
|
expect(result.methods).toEqual([
|
|
expect.objectContaining({
|
|
status: "unchanged",
|
|
left: expect.objectContaining({ token: "0x06000001", name: "Main" }),
|
|
right: expect.objectContaining({ token: "0x06000001", name: "b" }),
|
|
match: expect.objectContaining({
|
|
status: "matched",
|
|
basis: "exact-il-signature",
|
|
confidence: "exact",
|
|
}),
|
|
dimensions: [],
|
|
}),
|
|
]);
|
|
expect(result.limitations).toContain(
|
|
"Names are reported as observations but are not used as a matching basis.",
|
|
);
|
|
});
|
|
|
|
it("uses structural method shape when build-local token operands drift", () => {
|
|
const leftBytes = buildManagedPeFixture();
|
|
const rightBytes = buildManagedPeFixture({
|
|
ilBody: Buffer.from([
|
|
0x32, 0x02, 0x7b, 0x02, 0x00, 0x00, 0x04, 0x28, 0x02, 0x00, 0x00, 0x0a,
|
|
0x2a,
|
|
]),
|
|
});
|
|
const left = inspect(leftBytes, "/tmp/left.dll");
|
|
const right = inspect(rightBytes, "/tmp/right.dll");
|
|
const result = compareManagedMembers(
|
|
{ evidenceId: left.evidence.evidence_id, result: left.result },
|
|
{ evidenceId: right.evidence.evidence_id, result: right.result },
|
|
comparisonLimits,
|
|
);
|
|
|
|
expect(result.matching.exact_il_signature).toBe(0);
|
|
expect(result.matching.structural_method_shape).toBe(1);
|
|
expect(result.methods[0]).toMatchObject({
|
|
status: "changed",
|
|
match: { status: "matched", basis: "structural-method-shape" },
|
|
dimensions: ["cil"],
|
|
});
|
|
});
|
|
|
|
it("does not match identical instruction-limited method prefixes", () => {
|
|
const limited = { ...memberLimits, maxMethodInstructions: 1 };
|
|
const left = inspect(
|
|
buildManagedPeFixture(),
|
|
"/tmp/left-partial.dll",
|
|
limited,
|
|
);
|
|
const right = inspect(
|
|
buildManagedPeFixture(),
|
|
"/tmp/right-partial.dll",
|
|
limited,
|
|
);
|
|
const result = compareManagedMembers(
|
|
{ evidenceId: left.evidence.evidence_id, result: left.result },
|
|
{ evidenceId: right.evidence.evidence_id, result: right.result },
|
|
comparisonLimits,
|
|
);
|
|
|
|
expect(result.matching.exact_il_signature).toBe(0);
|
|
expect(result.matching.structural_method_shape).toBe(0);
|
|
expect(result.coverage).toMatchObject({
|
|
status: "partial",
|
|
left_status: "partial",
|
|
right_status: "partial",
|
|
});
|
|
});
|
|
|
|
it("keeps unmatched members unknown when the opposite page is incomplete", () => {
|
|
const left = inspect(buildManagedPeFixture(), "/tmp/left-paged.dll");
|
|
const right = inspect(buildManagedPeFixture(), "/tmp/right-paged.dll");
|
|
const leftPartial = {
|
|
...left.result,
|
|
fields: {
|
|
...left.result.fields,
|
|
items: [],
|
|
returned: 0,
|
|
dropped: 1,
|
|
complete: false,
|
|
},
|
|
};
|
|
const rightPartial = {
|
|
...right.result,
|
|
methods: {
|
|
...right.result.methods,
|
|
items: [],
|
|
returned: 0,
|
|
dropped: 1,
|
|
complete: false,
|
|
},
|
|
};
|
|
const result = compareManagedMembers(
|
|
{ evidenceId: left.evidence.evidence_id, result: leftPartial },
|
|
{ evidenceId: right.evidence.evidence_id, result: rightPartial },
|
|
comparisonLimits,
|
|
);
|
|
|
|
expect(result.summary).toMatchObject({
|
|
added: 0,
|
|
removed: 0,
|
|
unknown: 2,
|
|
});
|
|
expect(result.methods[0]).toMatchObject({
|
|
status: "unknown",
|
|
left: { token: "0x06000001" },
|
|
right: null,
|
|
limitations: [expect.stringContaining("unknown-within-unobserved-page")],
|
|
});
|
|
expect(result.fields[0]).toMatchObject({
|
|
status: "unknown",
|
|
left: null,
|
|
right: { token: "0x04000001" },
|
|
limitations: [expect.stringContaining("unknown-within-unobserved-page")],
|
|
});
|
|
expect(result.coverage).toEqual({
|
|
status: "truncated",
|
|
left_status: "partial",
|
|
right_status: "partial",
|
|
omitted_methods: 1,
|
|
omitted_fields: 1,
|
|
omitted_candidates: 0,
|
|
});
|
|
});
|
|
|
|
it("does not guess ambiguous field signature matches", () => {
|
|
const left = inspect(buildManagedPeFixture(), "/tmp/left.dll");
|
|
const leftField = left.result.fields.items[0];
|
|
expect(leftField).toBeDefined();
|
|
if (leftField === undefined) return;
|
|
const duplicatedLeft = {
|
|
...left.result,
|
|
fields: {
|
|
...left.result.fields,
|
|
items: [
|
|
leftField,
|
|
{
|
|
...leftField,
|
|
token: "0x04000002",
|
|
name: "other",
|
|
},
|
|
],
|
|
total: 2,
|
|
returned: 2,
|
|
},
|
|
};
|
|
const right = inspect(buildManagedPeFixture(), "/tmp/right.dll");
|
|
const rightField = right.result.fields.items[0];
|
|
expect(rightField).toBeDefined();
|
|
if (rightField === undefined) return;
|
|
const duplicatedRight = {
|
|
...right.result,
|
|
fields: {
|
|
...right.result.fields,
|
|
items: [
|
|
rightField,
|
|
{
|
|
...rightField,
|
|
token: "0x04000002",
|
|
name: "renamed",
|
|
},
|
|
],
|
|
total: 2,
|
|
returned: 2,
|
|
},
|
|
};
|
|
const result = compareManagedMembers(
|
|
{ evidenceId: left.evidence.evidence_id, result: duplicatedLeft },
|
|
{ evidenceId: right.evidence.evidence_id, result: duplicatedRight },
|
|
comparisonLimits,
|
|
);
|
|
|
|
expect(result.matching.ambiguous).toBe(1);
|
|
expect(result.fields).toEqual([
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
match: expect.objectContaining({
|
|
status: "ambiguous",
|
|
basis: "field-signature",
|
|
candidate_left_tokens: ["0x04000001", "0x04000002"],
|
|
candidate_right_tokens: ["0x04000001", "0x04000002"],
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("compares two local paths and returns derived Evidence", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "rea-managed-compare-"));
|
|
const leftPath = join(directory, "left.dll");
|
|
const rightPath = join(directory, "right.dll");
|
|
await writeFile(leftPath, buildManagedPeFixture());
|
|
await writeFile(
|
|
rightPath,
|
|
buildManagedPeFixture({ methodName: "Renamed" }),
|
|
);
|
|
|
|
const result = await compareManagedMemberPaths({
|
|
leftPath,
|
|
rightPath,
|
|
memberLimits: pathMemberLimits,
|
|
comparisonLimits,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.operation).toBe("compare_managed_members");
|
|
expect(result.value.confidence).toBe("inferred");
|
|
expect(result.value.authority).toBe("analyst-inference");
|
|
expect(
|
|
managedMemberComparisonResultSchema.parse(result.value.normalized_result)
|
|
.matching.exact_il_signature,
|
|
).toBe(1);
|
|
});
|
|
});
|
|
|
|
const inspect = (
|
|
bytes: Buffer,
|
|
path: string,
|
|
limits: typeof memberLimits = memberLimits,
|
|
) => {
|
|
const target: BinaryTarget = {
|
|
path,
|
|
sha256: hash(bytes),
|
|
kind: "executable",
|
|
format: "pe",
|
|
architecture: "x86",
|
|
};
|
|
const result = inspectManagedMembersBytes(bytes, target, limits);
|
|
return {
|
|
result,
|
|
evidence: createEvidence(target, MANAGED_STATIC_PROVIDER, {
|
|
operation: "inspect_managed_members",
|
|
parameters: {},
|
|
result,
|
|
rawResult: null,
|
|
limitations: result.limitations,
|
|
}),
|
|
};
|
|
};
|
|
|
|
const hash = (bytes: Buffer): string =>
|
|
createHash("sha256").update(bytes).digest("hex");
|