fix(native): preserve verification captures and separate path fragments
This commit is contained in:
1 parent
fa68ca054c
commit
1a5eec54e1
11 files changed
+108
-31
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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[]> => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user