mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* feat(evidence)!: give Evidence bundles a format revision owned by one module `src/domain/evidenceFormat.ts` owns Evidence format evolution. Bundles now carry `evidence_format: 1`; every bundle reader admits only the current revision and refuses an unmarked or other-revision bundle once, with the single recreate-observations guidance, instead of admitting earlier shapes one optional field at a time. The 6.7-era artifact inventory summary identities are no longer rebuilt: compare_artifacts checks the current projection only and reports a mismatch with the owner's guidance. The pre-6.6 identity diagnosis uses the same guidance. SQLite `worker_timeout_ms` and Dart AOT `string_pool.policy`, optional only for historical Evidence, are now required. BREAKING CHANGE: Evidence bundles written by REA 6.x (no `evidence_format`) are refused by import, comparison, reconstruction and analysis-snapshot readers as an integrity mismatch telling the caller to recreate the observations; re-exporting an earlier bundle does not update its format or identities. Bundles constructed by hand must include `"evidence_format": 1`. 6.7-era artifact summaries are refused by compare_artifacts, and SQLite and Dart AOT Evidence without the newly required fields fails validation. * fix(evidence): let the ledger application admit its bundle's format build_reconstruction_obligation_ledger registered the full bundle schema with the SDK, so an unmarked or other-revision bundle was refused as invalid_request before the format owner ran. Register it like coverage: the application parses the request envelope and admits the bundle. * feat(pcap)!: read capture Evidence through the current result schema only (#10879) Delete the pcap read-compatibility schema that defaulted executable identity for historical capture Evidence, and require query_labels_hex (null when unknown) now that every producer writes it. Evidence from an earlier format is refused at the bundle format owner; a record that omits these fields is reported as malformed instead of defaulted. BREAKING CHANGE: inspect_pcap_capture results always carry query_labels_hex, and analysis views no longer default missing tshark/capinfos identity fields. * feat(desktop)!: require the effective view policy in desktop state Evidence (#10886) inspect_desktop_application_state always records the complete policy it used, including sqlite_view_bytes. Drop the optional historical shape and the task draft's absent-policy branch. BREAKING CHANGE: desktop application state results without effective_view_policy, or with a policy missing sqlite_view_bytes, are refused as malformed. * feat(android)!: require component declaration facets and reference aggregates in Android Evidence (#10904) * feat(android)!: require component declaration facets and reference aggregates in Android Evidence The JADX producers always write the manifest component projection with every declaration facet, target_definition and direct_invokes_coverage. Require them, delete the duplicate observed component schema, drop the unknown declaration coverage that only described historical omission, and give manifest view results their explicit facet. BREAKING CHANGE: Android package, component and reference results missing these fields are refused as malformed; component declaration_coverage and its summary counts no longer include unknown; manifest view results always carry facet manifest. * refactor(android): parse package results with the domain schema directly * feat(browser)!: require the DOM length unit and redirect-scoped prior-hop facts (#10897) * feat(browser)!: require the DOM length unit and redirect-scoped prior-hop facts Both CDP producers always declare node_value_length_unit, and the observation session writes redirect_response on every redirect event and no other. Require the unit, drop the diff's absent-unit branch, and admit redirect_response exactly on redirect rows. BREAKING CHANGE: DOM snapshots without node_value_length_unit and redirect timeline rows without redirect_response are refused as malformed, including inline inspections passed to compare_web_captures. * docs(browser): drop the historical redirect_response omission note * feat(native)!: require native Evidence fields that were optional only for historical Evidence (#10946) Mach-O encryption, slice malformed cause, symbol runtime names and import facts; ELF export encoding facts and visibility; value-flow limits; load-image permissions and relocation symbols; Swift type-reference kind and GPU AMD target metadata are required. Legacy enum values (unrecorded, unknown visibility, undecodable, modinfo_field_exceeds_limit) and the retained schemas, view defaults and reinterpretations that served historical Evidence are deleted. BREAKING CHANGE: native records missing these fields are refused as malformed; recreate the observation.
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
|
|
|
import { projectAnalysisView } from "./analysisView.js";
|
|
import type { AnalysisViewParent } from "./analysisViewParent.js";
|
|
import { analysisViewResultSchema } from "./analysisViewResult.js";
|
|
|
|
it("binds every Mach-O facet to its rows, including empty pages", () => {
|
|
for (const facet of [
|
|
"entrypoints",
|
|
"architectures",
|
|
"build_metadata",
|
|
"load_commands",
|
|
"dependencies",
|
|
"imports",
|
|
"exports",
|
|
"segments",
|
|
"provenance",
|
|
] as const) {
|
|
for (const offset of [0, 999]) {
|
|
const projected = projectAnalysisView(parent(), {
|
|
kind: "macho",
|
|
facet,
|
|
offset,
|
|
limit: 64,
|
|
});
|
|
if (!projected.ok) throw projected.error;
|
|
expect(analysisViewResultSchema.safeParse(projected.value).success).toBe(
|
|
true,
|
|
);
|
|
if (offset === 0)
|
|
expect(
|
|
analysisViewResultSchema.safeParse({
|
|
...projected.value,
|
|
view: {
|
|
kind: "macho",
|
|
facet: facet === "entrypoints" ? "exports" : "entrypoints",
|
|
offset,
|
|
limit: 64,
|
|
},
|
|
}).success,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("refuses symbols and encryption rows missing fields every producer writes", () => {
|
|
const { runtime_name: _runtimeName, ...withoutRuntimeName } =
|
|
symbol("_malloc");
|
|
const current = inspectMachoSchema.parse(report);
|
|
const { malformed_kind: _malformedKind, ...withoutCause } =
|
|
current.encryption.items[0]!;
|
|
for (const [source, path] of [
|
|
[
|
|
{ ...report, exports: coverage([withoutRuntimeName]) },
|
|
["exports", "items", 0, "runtime_name"],
|
|
],
|
|
[
|
|
{ ...report, encryption: coverage([withoutCause]) },
|
|
["encryption", "items", 0, "malformed_kind"],
|
|
],
|
|
[{ ...report, encryption: undefined }, ["encryption"]],
|
|
] as const) {
|
|
const parsed = inspectMachoSchema.safeParse(source);
|
|
expect(parsed.error?.issues.map((issue) => issue.path)).toContainEqual(
|
|
path,
|
|
);
|
|
expect(
|
|
projectAnalysisView(parent(source), {
|
|
kind: "macho",
|
|
facet: "encryption",
|
|
offset: 0,
|
|
limit: 10,
|
|
}).ok,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
const symbol = (name: string) => ({
|
|
name,
|
|
runtime_name: name.slice(1),
|
|
address: null,
|
|
weak: null,
|
|
reexport: null,
|
|
source: "libSystem",
|
|
});
|
|
const importSymbol = (name: string) => ({
|
|
...symbol(name),
|
|
raw_name: null,
|
|
name_status: "exact" as const,
|
|
addend: null,
|
|
});
|
|
const coverage = <T>(items: T[], exhaustive = true) => ({
|
|
items,
|
|
total: exhaustive ? items.length : null,
|
|
exhaustive,
|
|
limitations: exhaustive ? [] : ["nm output may omit stripped symbols."],
|
|
});
|
|
const report = {
|
|
format: "mach-o",
|
|
selected_architecture: { name: "arm64", container_file_offset: 0 },
|
|
endian: null,
|
|
word_size: 64,
|
|
file_type: "2",
|
|
flags: ["0x00200085"],
|
|
uuid: "4C4C44E9-5555-3144-A1E9-D858E24A1CCE",
|
|
entrypoints: coverage([{ file_offset: 16384 }]),
|
|
architectures: coverage([
|
|
{
|
|
name: "arm64",
|
|
cpu_type: "16777228",
|
|
cpu_subtype: "0",
|
|
file_offset: 0,
|
|
size: 4096,
|
|
alignment: 14,
|
|
},
|
|
]),
|
|
build_metadata: coverage([
|
|
{ platform: "1", minimum_os: "14.0", sdk: "15.0", tools: [] },
|
|
]),
|
|
load_commands: coverage([{ index: 0, kind: "LC_MAIN", fields: {} }]),
|
|
dependencies: coverage([
|
|
{
|
|
path: "/usr/lib/libSystem.B.dylib",
|
|
kind: "LC_LOAD_DYLIB",
|
|
current_version: "1351.0.0",
|
|
compatibility_version: "1.0.0",
|
|
},
|
|
]),
|
|
imports: coverage(
|
|
["_malloc", "_free", "_open"].map((name) => importSymbol(name)),
|
|
false,
|
|
),
|
|
exports: coverage([symbol("__mh_execute_header")]),
|
|
segments: coverage([
|
|
{
|
|
name: "__TEXT",
|
|
vm_address: "0x100000000",
|
|
vm_size: 16384,
|
|
file_offset: 0,
|
|
file_size: 16384,
|
|
maximum_permissions: {
|
|
read: true,
|
|
write: false,
|
|
execute: true,
|
|
raw: "5",
|
|
},
|
|
initial_permissions: {
|
|
read: true,
|
|
write: false,
|
|
execute: true,
|
|
raw: "5",
|
|
},
|
|
sections: coverage([]),
|
|
},
|
|
]),
|
|
encryption: coverage([
|
|
{
|
|
architecture: "arm64",
|
|
cpu_type: 16777228,
|
|
cpu_subtype: 0,
|
|
container_file_offset: 0,
|
|
size: 4096,
|
|
status: "not-encrypted",
|
|
malformed_kind: null,
|
|
reason: null,
|
|
commands: [
|
|
{
|
|
kind: "LC_ENCRYPTION_INFO_64",
|
|
index: 1,
|
|
cmdsize: 24,
|
|
cryptoff: 16384,
|
|
cryptsize: 0,
|
|
cryptid: 0,
|
|
pad: 0,
|
|
},
|
|
],
|
|
range: {
|
|
content: "plaintext",
|
|
slice_start: 16384,
|
|
slice_end: 16384,
|
|
container_start: 16384,
|
|
container_end: 16384,
|
|
mapping: { status: "mapped", fragments: [], sections: [] },
|
|
},
|
|
},
|
|
]),
|
|
provenance: [
|
|
{
|
|
tool: "nm",
|
|
command: ["/usr/bin/nm", "$ARTIFACT"],
|
|
tool_version: null,
|
|
version_reason: "fixture",
|
|
executable_sha256: "a".repeat(64),
|
|
exit: { code: 0, signal: null },
|
|
stdout_bytes: 10,
|
|
stderr_bytes: 0,
|
|
},
|
|
],
|
|
limitations: ["Imports and exports combine dyld_info and nm."],
|
|
};
|
|
const parent = (
|
|
normalizedResult: unknown = report,
|
|
subject: AnalysisViewParent["subject"] = {
|
|
name: "tool",
|
|
digest: { sha256: "b".repeat(64) },
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
local_path: "/bin/tool",
|
|
},
|
|
): AnalysisViewParent => ({
|
|
predicateType: "rea.analysis",
|
|
evidenceId: `ev_${"c".repeat(64)}`,
|
|
operation: "inspect_macho",
|
|
normalizedResult,
|
|
subject,
|
|
limitations: ["Parent limitation."],
|
|
});
|
|
|
|
it("retains binary names and exact numbers alongside partial command fields", () => {
|
|
const segment = {
|
|
...report.segments.items[0]!,
|
|
name: null,
|
|
name_bytes_hex: "ff",
|
|
vm_size: null,
|
|
vm_size_hex: "0x20000000000000",
|
|
file_offset: null,
|
|
file_offset_hex: "0x20000000000001",
|
|
file_size: null,
|
|
file_size_hex: "0x20000000000002",
|
|
sections: coverage([
|
|
{
|
|
segment: null,
|
|
segment_bytes_hex: "ff",
|
|
name: null,
|
|
name_bytes_hex: "fe",
|
|
address: "0x1",
|
|
size: null,
|
|
size_hex: "0x20000000000000",
|
|
file_offset: 0,
|
|
alignment: null,
|
|
alignment_exponent: 53,
|
|
flags: [],
|
|
},
|
|
]),
|
|
};
|
|
const dependency = {
|
|
path: null,
|
|
path_bytes_hex: "fd",
|
|
kind: "LC_LOAD_DYLIB",
|
|
current_version: null,
|
|
compatibility_version: null,
|
|
};
|
|
const command = {
|
|
index: 0,
|
|
kind: "LC_UNKNOWN_0x7fff",
|
|
fields: {
|
|
command_code: 32767,
|
|
command_offset: 184,
|
|
cmdsize: 8,
|
|
command_sha256: "a".repeat(64),
|
|
},
|
|
};
|
|
const observed = {
|
|
...report,
|
|
segments: coverage([segment], false),
|
|
dependencies: coverage([dependency], false),
|
|
load_commands: {
|
|
items: [command],
|
|
total: 1,
|
|
exhaustive: true,
|
|
limitations: ["Some binary command fields are not decoded."],
|
|
},
|
|
};
|
|
expect(inspectMachoSchema.safeParse(observed).success).toBe(true);
|
|
for (const [facet, expected] of [
|
|
["segments", segment],
|
|
["dependencies", dependency],
|
|
["load_commands", command],
|
|
] as const) {
|
|
const projected = projectAnalysisView(parent(observed), {
|
|
kind: "macho",
|
|
facet,
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
if (!projected.ok || projected.value.kind !== "macho")
|
|
throw new Error("Expected Mach-O page");
|
|
expect(projected.value.item).toEqual([expected]);
|
|
expect(projected.value.source_exhaustive).toBe(facet === "load_commands");
|
|
if (facet === "load_commands")
|
|
expect(projected.value.limitations).toContain(
|
|
"Some binary command fields are not decoded.",
|
|
);
|
|
expect(projected.value.unknowns).not.toEqual([]);
|
|
expect(analysisViewResultSchema.safeParse(projected.value).success).toBe(
|
|
true,
|
|
);
|
|
}
|
|
// Names that decode as UTF-8 carry no exact-byte fields.
|
|
expect(inspectMachoSchema.safeParse(report).success).toBe(true);
|
|
expect(report.segments.items[0]).not.toHaveProperty("name_bytes_hex");
|
|
});
|
|
|
|
it("preserves ambiguous import facts without adding import fields to exports", () => {
|
|
const imported = {
|
|
...symbol("unused"),
|
|
name: null,
|
|
runtime_name: null,
|
|
raw_name: "_value+0x4",
|
|
name_status: "ambiguous",
|
|
addend: null,
|
|
};
|
|
const observed = { ...report, imports: coverage([imported], false) };
|
|
for (const facet of ["imports", "exports"] as const) {
|
|
const projected = projectAnalysisView(parent(observed), {
|
|
kind: "macho",
|
|
facet,
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
if (!projected.ok) throw projected.error;
|
|
expect(projected.value.kind === "macho" && projected.value.item).toEqual(
|
|
observed[facet].items,
|
|
);
|
|
}
|
|
});
|
|
|
|
describe("Mach-O analysis views", () => {
|
|
it("summarizes slice identity and collection counts without rows", () => {
|
|
const projected = projectAnalysisView(parent(), { kind: "summary" });
|
|
if (!projected.ok) throw projected.error;
|
|
expect(projected.value).toMatchObject({
|
|
kind: "summary",
|
|
parent_operation: "inspect_macho",
|
|
artifact: { path: "/bin/tool", sha256: "b".repeat(64) },
|
|
summary: {
|
|
selected_architecture: { name: "arm64", container_file_offset: 0 },
|
|
uuid: report.uuid,
|
|
collections: {
|
|
imports: { returned: 3, total: null, exhaustive: false },
|
|
exports: { returned: 1, total: 1, exhaustive: true },
|
|
segments: { returned: 1, total: 1, exhaustive: true },
|
|
encryption: { returned: 1, total: 1, exhaustive: true },
|
|
},
|
|
provenance_count: 1,
|
|
limitation_count: 3,
|
|
},
|
|
coverage: { status: "complete-within-view", examined: 11, total: 11 },
|
|
limitations: [
|
|
"Imports and exports combine dyld_info and nm.",
|
|
"Parent limitation.",
|
|
"nm output may omit stripped symbols.",
|
|
],
|
|
unknowns: [expect.stringContaining("exhaustive imports;")],
|
|
});
|
|
expect(JSON.stringify(projected.value)).not.toContain("_malloc");
|
|
});
|
|
|
|
it("pages original rows with the producer's own coverage", () => {
|
|
const first = projectAnalysisView(parent(), {
|
|
kind: "macho",
|
|
facet: "imports",
|
|
offset: 1,
|
|
limit: 1,
|
|
});
|
|
if (!first.ok) throw first.error;
|
|
expect(first.value).toMatchObject({
|
|
kind: "macho",
|
|
item: [importSymbol("_free")],
|
|
source_total: null,
|
|
source_exhaustive: false,
|
|
coverage: { status: "page", examined: 1, total: 3, next_offset: 2 },
|
|
});
|
|
expect(first.value.limitations).toContain(
|
|
"nm output may omit stripped symbols.",
|
|
);
|
|
// Rows whose keys include an earlier row shape's keys stay whole.
|
|
for (const facet of ["architectures", "segments", "encryption"] as const) {
|
|
const page = projectAnalysisView(parent(), {
|
|
kind: "macho",
|
|
facet,
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
if (!page.ok) throw page.error;
|
|
expect(page.value.kind === "macho" && page.value.item).toEqual(
|
|
report[facet].items,
|
|
);
|
|
}
|
|
const provenance = projectAnalysisView(parent(), {
|
|
kind: "macho",
|
|
facet: "provenance",
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
if (!provenance.ok) throw provenance.error;
|
|
expect(provenance.value).toMatchObject({
|
|
item: report.provenance,
|
|
source_total: 1,
|
|
source_exhaustive: true,
|
|
coverage: { exhausted: true, next_offset: null },
|
|
});
|
|
const past = projectAnalysisView(parent(), {
|
|
kind: "macho",
|
|
facet: "exports",
|
|
offset: 5,
|
|
limit: 64,
|
|
});
|
|
if (!past.ok) throw past.error;
|
|
expect(past.value.coverage).toMatchObject({
|
|
status: "empty",
|
|
exhausted: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Mach-O view uncertainty", () => {
|
|
it("keeps a missing subject unknown instead of inventing identity", () => {
|
|
const projected = projectAnalysisView(parent(report, null), {
|
|
kind: "summary",
|
|
});
|
|
if (!projected.ok) throw projected.error;
|
|
expect(projected.value.artifact).toEqual({ path: "", sha256: null });
|
|
expect(projected.value.unknowns).toContain(
|
|
"The parent has no recorded subject, so the inspected artifact path and SHA-256 are unknown.",
|
|
);
|
|
});
|
|
|
|
it("rejects other view kinds and malformed retained results", () => {
|
|
const wrongKind = projectAnalysisView(parent(), {
|
|
kind: "wasm",
|
|
facet: "exports",
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
|
|
expect(wrongKind.error).toMatchObject({
|
|
_tag: "AnalysisInputError",
|
|
issues: [
|
|
{
|
|
path: ["view"],
|
|
message: expect.stringMatching(/summary and macho views/iu),
|
|
},
|
|
],
|
|
});
|
|
const contradictory = projectAnalysisView(
|
|
parent(report, {
|
|
name: "tool",
|
|
digest: { sha256: "b".repeat(64) },
|
|
format: "elf",
|
|
architecture: null,
|
|
local_path: "/bin/tool",
|
|
}),
|
|
{ kind: "summary" },
|
|
);
|
|
if (contradictory.ok) throw new Error("expected a subject refusal");
|
|
expect(contradictory.error).toMatchObject({
|
|
issues: [{ path: ["source", "subject"] }],
|
|
});
|
|
const malformed = projectAnalysisView(
|
|
parent({ ...report, imports: { items: "x" } }),
|
|
{ kind: "summary" },
|
|
);
|
|
expect(malformed.ok).toBe(false);
|
|
if (!malformed.ok) expect(malformed.error._tag).toBe("AnalysisInputError");
|
|
});
|
|
});
|
|
|
|
it("validates a prototype-named command field rather than dropping an invalid value", () => {
|
|
const fields =
|
|
inspectMachoSchema.shape.load_commands.shape.items.element.shape.fields;
|
|
const parsed = fields.safeParse(
|
|
Object.fromEntries([["__proto__", { invalid: true }]]),
|
|
);
|
|
expect(parsed.success).toBe(false);
|
|
if (parsed.success) throw new Error("Expected an invalid command field");
|
|
expect(parsed.error.issues).toContainEqual(
|
|
expect.objectContaining({ path: ["__proto__"] }),
|
|
);
|
|
});
|