fix(native): preserve verification captures and separate path fragments

This commit is contained in:
morluto committed 2026-10-08 20:35:31 +08:00
1 parent fa68ca054c
commit 1a5eec54e1
11 files changed
+108 -31

No files matched your search

+2 -2
View File
@@ -713,9 +713,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "6d9070bbd91f3278b12057a1450fb1aea8637b92086c350e99c67550461b5753",
"tools_sha256": "a01436e56f2b5f6fade1cbe76e058950878384a4b06c90e50cff294461fb2673",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "359b62409b2b7fd66199f8a7c4a95f019c94fbedc15684d03dc0081f5f5aaf0f",
"combined_sha256": "2923cde444b64475f5aae28694dc132a24146601d3a2d402426eaced892b8fea",
"providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_0fc038c8ea578513110ca088de232e3ef9cbeaa1d24454efb48e6ae9728bab74",
"manifest_id": "ecm_c305a964b1f8f015d8604502d37a62d58cdf21b347ae7683c45c6b0188bd535c",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "8edabbb6137a55d7734039eafab4d2e4555872e223ae4b449f2569408e91af5b"
"sha256": "0739eb95a4ddbe3c6163eeb37926690e66656580d0e3d88475145a0b5dca8b98"
}
],
"claims": [
+10 -4
View File
@@ -130,10 +130,16 @@ try {
);
await withArtifactMcp(app, async (client) => {
const viaMcp = await artifactMcpResult(client, "inspect_signature");
assert.deepEqual(
{ ...viaMcp, provenance: [] },
{ ...plain, provenance: [] },
);
const semantic = (value) => ({
...value,
provenance: [],
verification: {
...value.verification,
raw_stdout: "",
raw_stderr: "",
},
});
assert.deepEqual(semantic(viaMcp), semantic(plain));
});
const tool = join(app, "Contents/Helpers/rea-tool");
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "33"
tool_count: 139
catalog_digest: "359b62409b2b7fd66199f8a7c4a95f019c94fbedc15684d03dc0081f5f5aaf0f"
catalog_digest: "2923cde444b64475f5aae28694dc132a24146601d3a2d402426eaced892b8fea"
---
# REA
+3
View File
@@ -33,6 +33,9 @@ export const signatureVerificationSchema = z.object({
exit_code: z.number().int().nullable(),
diagnostics: z.array(z.string()),
validated_nested_code: z.array(z.string()),
prepared_nested_code: z.array(z.string()).default([]),
raw_stdout: z.string().default(""),
raw_stderr: z.string().default(""),
});
/** Local ticket presence and content identity, without a remote notarization claim. */
+28 -4
View File
@@ -19,10 +19,23 @@ export const codesignReasons = (
const observed = `${capture.stderr}\n${capture.stdout}`;
const text =
path.length === 0 ? observed : observed.split(path).join("$SELECTED_PATH");
return text
.split("\n")
.filter((line) => !/^--(?:prepared|validated):/u.test(line))
.map((line) => codesignReason(line, "$SELECTED_PATH"));
const lines = text.split("\n");
const progress = lines.some((line) =>
/^--(?:prepared|validated):/u.test(line),
);
return (
lines
.filter((line) => !/^--(?:prepared|validated):/u.test(line))
// Unprefixed continuation text can belong to an unescaped nested filename.
// Keep it as raw evidence, but do not turn it into a verification reason.
.filter(
(line) =>
!progress ||
line.startsWith("$SELECTED_PATH: ") ||
line.startsWith("$SELECTED_PATH/"),
)
.map((line) => codesignReason(line, "$SELECTED_PATH"))
);
};
/** Classify only explicit diagnostic reasons; nonzero exit alone proves no invalidity. */
@@ -83,6 +96,17 @@ export const signatureVerification = (
),
exit_code: capture.exitCode,
diagnostics,
prepared_nested_code: [
...new Set(
lines.flatMap((line) =>
line.startsWith("--prepared:")
? [line.slice("--prepared:".length)]
: [],
),
),
].sort(),
raw_stdout: capture.stdout,
raw_stderr: capture.stderr,
validated_nested_code: [
...new Set(
lines.flatMap((line) =>
-4
View File
@@ -7,10 +7,6 @@ it("observes cancellation between nested filesystem lookups", async () => {
await expect(
unconfirmedNestedCode(
{
path: "/app",
status: "valid",
exit_code: 0,
diagnostics: [],
validated_nested_code: ["/app/first", "/app/second"],
},
controller.signal,
+1 -2
View File
@@ -1,6 +1,5 @@
import { lstat, readdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { InspectSignature } from "../domain/native/nativeInspection.js";
/** Filesystem seam for path-completeness checks and cancellation regressions. */
export interface NativeNestedPathView {
@@ -20,7 +19,7 @@ const errorCode = (cause: unknown) =>
/** Preserve reported paths, but qualify missing, denied or newline-ambiguous records. */
export const unconfirmedNestedCode = async (
verification: NonNullable<InspectSignature["verification"]>,
verification: { readonly validated_nested_code: readonly string[] },
signal?: AbortSignal,
view: NativeNestedPathView = FILE_SYSTEM,
): Promise<string[]> => {
+11 -5
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { constants, type Stats } from "node:fs";
import { lstat, open } from "node:fs/promises";
import { join } from "node:path";
@@ -56,6 +56,9 @@ const bundleDirFromPlist = (plist: string): string | undefined => {
export const stapledTicket = async (
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
signal?: AbortSignal,
pathProbe: (path: string, phase: "before" | "after") => Promise<Stats> = (
path,
) => lstat(path),
): Promise<InspectSignature["stapled_ticket"]> => {
signal?.throwIfAborted();
const bundle = appBundle(target);
@@ -73,7 +76,7 @@ export const stapledTicket = async (
const path = join(bundle, relative);
try {
signal?.throwIfAborted();
const metadata = await lstat(path);
const metadata = await pathProbe(path, "before");
if (!metadata.isFile()) {
signal?.throwIfAborted();
return absent;
@@ -147,7 +150,7 @@ export const stapledTicket = async (
// handle still describes the unlinked original: re-resolve the path and
// refuse a digest that no longer belongs to the bundle.
try {
const current = await lstat(path);
const current = await pathProbe(path, "after");
if (!sameTicketFile(current, opened))
return {
status: "unreadable",
@@ -156,7 +159,10 @@ export const stapledTicket = async (
size: null,
reason: "changed",
};
} catch {
} catch (cause: unknown) {
signal?.throwIfAborted();
const code = errorCode(cause);
if (code !== "ENOENT" && code !== "ENOTDIR") throw cause;
return {
status: "unreadable",
path: relative,
@@ -180,7 +186,7 @@ export const stapledTicket = async (
if (code === "ENOENT" || code === "ENOTDIR") return absent;
// A privacy or ACL denial leaves the ticket's presence unknown, not the
// whole signature inspection.
if (code === "EACCES" || code === "EPERM")
if (code === "EACCES" || code === "EPERM" || code === "EIO")
return {
status: "unreadable",
path: relative,
@@ -84,3 +84,22 @@ it("preserves definitive invalidity when other components have operational failu
expect(observed.status).toBe("invalid");
expect(observed.diagnostics).toHaveLength(2);
});
it("preserves unmatched prepared records and ignores unprefixed nested-name fragments as reasons", async () => {
const path = "/Applications/Fixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const stderr = `--prepared:${path}/Contents/Helpers/odd\ninvalid signature\n${path}: permission denied\n`;
const result = signatureVerification(
{ ...capture.value, exitCode: 1, stderr },
path,
false,
);
expect(result.status).toBe("unknown");
expect(result.prepared_nested_code).toEqual([`${path}/Contents/Helpers/odd`]);
expect(result.raw_stderr).toBe(stderr);
expect(result.diagnostics).toContain("invalid signature");
});
@@ -1,10 +1,11 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { chmod, lstat, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { stapledTicket } from "../../../../src/native/NativeSignaturePosture.js";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { ok } from "../../../../src/domain/result.js";
import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js";
@@ -28,7 +29,10 @@ class TamperedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const stderr = TAMPERED_STDERR;
const stderr = TAMPERED_STDERR.replaceAll(
"/Applications/Fixture.app",
arguments_.at(-1) ?? "",
);
return ok({
...result.value,
stdout: "",
@@ -111,17 +115,17 @@ describe("native signature posture", () => {
expect(signature.ok).toBe(true);
if (!signature.ok) return;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.verification).toEqual({
expect(result.verification).toMatchObject({
path: app,
status: "invalid",
exit_code: 1,
diagnostics: [
"/Applications/Fixture.app: a sealed resource is missing or invalid",
"file modified: /Applications/Fixture.app/Contents/Resources/en.lproj/Main.nib",
`${app}: a sealed resource is missing or invalid`,
`file modified: ${app}/Contents/Resources/en.lproj/Main.nib`,
],
validated_nested_code: [
"/Applications/Fixture.app/Contents/PlugIns/A.appex",
"/Applications/Fixture.app/Contents/XPCServices/B.xpc",
`${app}/Contents/PlugIns/A.appex`,
`${app}/Contents/XPCServices/B.xpc`,
],
});
expect(result.stapled_ticket.status).toBe("absent");
@@ -491,6 +495,26 @@ describe("signature architecture coverage", () => {
});
describe("signature target version binding", () => {
it.each(["EACCES", "EPERM", "EIO"])(
"preserves final ticket-path failure %s instead of inventing mutation",
async (code) => {
const { app } = await fixtureApp(notarizationTicketFixture());
const ticket = await stapledTicket(
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
undefined,
async (path, phase) => {
if (phase === "after")
throw Object.assign(new Error("host lookup failed"), { code });
return lstat(path);
},
);
expect(ticket).toMatchObject({
status: "unreadable",
reason: code,
sha256: null,
});
},
);
it("downgrades facets when the executable changes between captures", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(