diff --git a/docs/product-catalog.json b/docs/product-catalog.json index ae4fcf4bf6..84185c0dfe 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -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" } } diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index aec79b983a..e73f3da98c 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -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": [ diff --git a/scripts/verify/apple/code-signature.mjs b/scripts/verify/apple/code-signature.mjs index 910d26c32e..8aaae6208c 100644 --- a/scripts/verify/apple/code-signature.mjs +++ b/scripts/verify/apple/code-signature.mjs @@ -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"); diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index 5d2716e919..c1761894cc 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -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 diff --git a/src/domain/native/codeSigningPosture.ts b/src/domain/native/codeSigningPosture.ts index 8f7b812ecc..edf475d8ee 100644 --- a/src/domain/native/codeSigningPosture.ts +++ b/src/domain/native/codeSigningPosture.ts @@ -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. */ diff --git a/src/native/CodesignVerification.ts b/src/native/CodesignVerification.ts index 3a35d63759..a313536cf7 100644 --- a/src/native/CodesignVerification.ts +++ b/src/native/CodesignVerification.ts @@ -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) => diff --git a/src/native/NativeNestedCode.test.ts b/src/native/NativeNestedCode.test.ts index 9bf0a5fd02..aac172a9a8 100644 --- a/src/native/NativeNestedCode.test.ts +++ b/src/native/NativeNestedCode.test.ts @@ -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, diff --git a/src/native/NativeNestedCode.ts b/src/native/NativeNestedCode.ts index e207a1ca69..68f1aa2de3 100644 --- a/src/native/NativeNestedCode.ts +++ b/src/native/NativeNestedCode.ts @@ -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, + verification: { readonly validated_nested_code: readonly string[] }, signal?: AbortSignal, view: NativeNestedPathView = FILE_SYSTEM, ): Promise => { diff --git a/src/native/NativeSignaturePosture.ts b/src/native/NativeSignaturePosture.ts index d354311fee..97069759ea 100644 --- a/src/native/NativeSignaturePosture.ts +++ b/src/native/NativeSignaturePosture.ts @@ -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 = ( + path, + ) => lstat(path), ): Promise => { 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, diff --git a/tests/boundary/providers/native/codesignVerificationReasons.test.ts b/tests/boundary/providers/native/codesignVerificationReasons.test.ts index 43e10ecbb1..c3afda91ec 100644 --- a/tests/boundary/providers/native/codesignVerificationReasons.test.ts +++ b/tests/boundary/providers/native/codesignVerificationReasons.test.ts @@ -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"); +}); diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index e0c1f48f6f..880874c5bd 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -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(