From d46449e54bf050cf65818da17226b4acedb7cd7e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:41:28 +0800 Subject: [PATCH] fix(native): keep signature failure evidence instead of collapsing it (#1084) A regular CodeResources file replaced by a directory or FIFO before open was reported absent. That hid the replacement; it is now unreadable with reason changed. A failed anchor apple probe kept only an exit code. The caller-visible limitation now includes the probe's diagnostic. Definitive codesign rejections are a phrase list, including unrecognized bundle format, instead of one growing regular expression. Unlisted nonzero diagnostics stay unknown. Co-authored-by: Cursor Agent --- src/native/CodesignVerification.ts | 36 +++++++++++--- src/native/NativeSignatureInspection.ts | 10 ++++ src/native/NativeSignaturePosture.ts | 11 ++++- .../codesignVerificationReasons.test.ts | 3 ++ .../native/nativeSignaturePosture.test.ts | 49 +++++++++++++++++-- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/native/CodesignVerification.ts b/src/native/CodesignVerification.ts index 7e91f59fe9..6bff2dc512 100644 --- a/src/native/CodesignVerification.ts +++ b/src/native/CodesignVerification.ts @@ -44,6 +44,33 @@ const operationalReason = (reason: string): boolean => reason, ); +/** + * Definitive codesign rejections. A nonzero exit whose text is not in this + * list, and is not an operational failure, stays `unknown` so an unrecognized + * diagnostic is not reported as a broken signature. + */ +const INVALID_SIGNATURE_REASONS = [ + "a resource envelope is obsolete", + "a sealed resource is missing or invalid", + "bundle format unrecognized, invalid, or unsuitable", + "code or signature modified", + "file added:", + "file modified:", + "invalid or unsupported format", + "invalid signature", + "resource envelope is obsolete", + "resource fork, Finder information, or similar detritus not allowed", + "signature is invalid", + "unsealed contents present", +] as const; + +const invalidSignatureReason = (reason: string): boolean => { + const text = reason.toLowerCase(); + return INVALID_SIGNATURE_REASONS.some((phrase) => + text.startsWith(phrase.toLowerCase()), + ); +}; + /** Operational diagnostics can coexist with an independently proven invalid component. */ export const codesignOperationalFailure = ( capture: NativeCommandCapture, @@ -61,14 +88,7 @@ const verificationStatus = ( reason, ), ); - if ( - reasons.some((reason) => - /^(?:a sealed resource is missing or invalid|code or signature modified|invalid signature|invalid or unsupported format|resource envelope is obsolete|signature is invalid|unsealed contents present|file modified:|file added:|a resource envelope is obsolete|resource fork, Finder information, or similar detritus not allowed)/iu.test( - reason, - ), - ) - ) - return "invalid"; + if (reasons.some(invalidSignatureReason)) return "invalid"; if (unsignedReason && !unsigned) return "invalid"; if (reasons.some(operationalReason)) return "unknown"; if (unsignedReason) return "unsigned"; diff --git a/src/native/NativeSignatureInspection.ts b/src/native/NativeSignatureInspection.ts index fc970c9543..7b88d91613 100644 --- a/src/native/NativeSignatureInspection.ts +++ b/src/native/NativeSignatureInspection.ts @@ -23,6 +23,7 @@ import type { NativeObservation } from "./NativeObservation.js"; import { signatureVerification, codesignOperationalFailure, + codesignReasons, } from "./CodesignVerification.js"; import { parseCodeSignature } from "./parsers/codesign.js"; import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js"; @@ -119,11 +120,18 @@ export const inspectNativeSignature = async (options: { if (!verified.ok) return verified; const captures = [display, requirements, entitlements, verified.value]; let appleOrigin = false; + let appleOriginFailure: string | undefined; if (!unsigned && (parsed.code_directory?.platform_identifier ?? 0) !== 0) { const anchor = await capture(["--verify", "-R=anchor apple", code], signal); if (!anchor.ok) return anchor; captures.push(anchor.value); appleOrigin = anchor.value.exitCode === 0; + if (!appleOrigin) { + const reason = codesignReasons(anchor.value, code).find( + (line) => line.trim().length > 0, + ); + appleOriginFailure = reason ?? `codesign exited ${anchor.value.exitCode}`; + } } const slices = await inspectSignatureSlices( target, @@ -152,6 +160,8 @@ export const inspectNativeSignature = async (options: { limitations.push( `Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`, ); + if (appleOriginFailure !== undefined) + limitations.push(`Apple-origin check failed: ${appleOriginFailure}`); if (verification.status === "unknown") limitations.push( "Signature verification is inconclusive (permission, I/O or unrecognized diagnostic); this is not a proven broken signature.", diff --git a/src/native/NativeSignaturePosture.ts b/src/native/NativeSignaturePosture.ts index 97069759ea..0633f397e6 100644 --- a/src/native/NativeSignaturePosture.ts +++ b/src/native/NativeSignaturePosture.ts @@ -89,7 +89,16 @@ export const stapledTicket = async ( ); try { const opened = await handle.stat(); - if (!opened.isFile()) return absent; + // The path was a regular file at lstat. A directory or FIFO now means + // the ticket was replaced; absence would hide that change. + if (!opened.isFile()) + return { + status: "unreadable", + path: relative, + sha256: null, + size: null, + reason: "changed", + }; // If the path was replaced between lstat and open, or mutated while // hashing, the digest would describe the wrong bytes: dev and ino catch // replacement, while size and mtime catch same-inode writes. Report any diff --git a/tests/boundary/providers/native/codesignVerificationReasons.test.ts b/tests/boundary/providers/native/codesignVerificationReasons.test.ts index 8a3682cae6..0e8b4c27da 100644 --- a/tests/boundary/providers/native/codesignVerificationReasons.test.ts +++ b/tests/boundary/providers/native/codesignVerificationReasons.test.ts @@ -43,6 +43,9 @@ it("keeps operational, unsigned, definite invalid and unrecognized failures dist expect(status("permission denied")).toBe("unknown"); expect(status("I/O error")).toBe("unknown"); expect(status("invalid signature")).toBe("invalid"); + expect(status("bundle format unrecognized, invalid, or unsuitable")).toBe( + "invalid", + ); expect( status( "resource fork, Finder information, or similar detritus not allowed", diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index 880874c5bd..c235ed55a9 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmod, lstat, mkdir, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -244,6 +244,16 @@ describe("native signature posture boundaries", () => { expect(result.provenance.map(({ command }) => command.at(-2))).toContain( "-R=anchor apple", ); + if (anchorExit === 0) + expect( + result.limitations.some((line) => + line.startsWith("Apple-origin check failed"), + ), + ).toBe(false); + else + expect(result.limitations).toContain( + "Apple-origin check failed: code failed to satisfy specified code requirement(s)", + ); return Object.fromEntries( result.security_facets.map(({ facet, state }) => [facet, state]), ); @@ -495,6 +505,28 @@ describe("signature architecture coverage", () => { }); describe("signature target version binding", () => { + it("reports a regular ticket replaced by a directory as changed", async () => { + const { app } = await fixtureApp(notarizationTicketFixture()); + const ticket = await stapledTicket( + { sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") }, + undefined, + async (path, phase) => { + const metadata = await lstat(path); + if (phase === "before") { + await rm(path); + await mkdir(path); + } + return metadata; + }, + ); + expect(ticket).toMatchObject({ + status: "unreadable", + path: "Contents/CodeResources", + reason: "changed", + sha256: null, + }); + }); + it.each(["EACCES", "EPERM", "EIO"])( "preserves final ticket-path failure %s instead of inventing mutation", async (code) => { @@ -612,8 +644,19 @@ class PlatformRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) { const result = await super.run(tool, arguments_); if (!result.ok || tool !== "codesign") return result; - if (arguments_.includes("-R=anchor apple")) - return ok({ ...result.value, exitCode: this.anchorExit }); + if (arguments_.includes("-R=anchor apple")) { + const target = arguments_.at(-1) ?? ""; + const stderr = + this.anchorExit === 0 + ? "" + : `${target}: code failed to satisfy specified code requirement(s)\n`; + return ok({ + ...result.value, + exitCode: this.anchorExit, + stderr, + stderrBytes: Buffer.byteLength(stderr), + }); + } if (!arguments_.includes("--verbose=4")) return result; const stderr = `${result.value.stderr}Platform identifier=26\n`; return ok({