diff --git a/src/domain/native/codeSigningPosture.ts b/src/domain/native/codeSigningPosture.ts index e678d924a9..72003f901a 100644 --- a/src/domain/native/codeSigningPosture.ts +++ b/src/domain/native/codeSigningPosture.ts @@ -45,7 +45,7 @@ export const sealedResourcesSchema = z.object({ /** Local `codesign --verify --deep --strict` outcome for the signed code. */ export const signatureVerificationSchema = z.object({ path: z.string(), - status: z.enum(["valid", "invalid", "unsigned"]), + status: z.enum(["valid", "invalid", "unsigned", "unknown"]), exit_code: z.number().int().nullable(), diagnostics: z.array(z.string()), validated_nested_code: z.array(z.string()), diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 763cba07b1..51ba729f50 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -422,21 +422,15 @@ class NativeMacOSClient implements AnalysisClient { unsigned, ); limitations.push(...(await unconfirmedNestedCode(verification))); - if ( - verification.status === "invalid" && - verification.diagnostics.some((line) => - /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/iu.test( - line, - ), - ) - ) + if (verification.status === "unknown") limitations.push( - "Signature verification reported a permission or I/O diagnostic; the invalid status may reflect unreadable nested code rather than a broken signature.", + "Signature verification could not read its target (permission or I/O diagnostic); the unknown status reflects an operational failure, not a proven broken signature.", ); // Deep verification covers nested code: when it fails only there, the // main executable's own signature may still be valid. Check it directly // before hiding its facets as unauthenticated. - let mainInvalid = verification.status === "invalid"; + let mainInvalid = + verification.status === "invalid" || verification.status === "unknown"; if (mainInvalid && !unsigned && verifyPath !== this.target.path) { const mainVerify = await this.#verifySignature(this.target.path, signal); if (!mainVerify.ok) return mainVerify; diff --git a/src/native/NativeSignaturePosture.ts b/src/native/NativeSignaturePosture.ts index 5ca7e792c5..e768ec3231 100644 --- a/src/native/NativeSignaturePosture.ts +++ b/src/native/NativeSignaturePosture.ts @@ -54,26 +54,43 @@ const bundleDirFromPlist = (plist: string): string | undefined => { * `--prepared:`/`--validated:` progress lines arrive in no fixed order; they * are reported as a sorted list of validated nested code instead. */ +/** Diagnostics showing verification could not read its target. */ +export const verificationIOFailure = ( + diagnostics: readonly string[], +): boolean => + diagnostics.some((line) => + /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/iu.test( + line, + ), + ); + export const signatureVerification = ( capture: NativeCommandCapture, path: string, unsigned: boolean, ): NonNullable => { - // Preserve trailing whitespace: a nested path may end in a space or tab. - // Strip only the line delimiter and leading whitespace for record parsing. + // Preserve trailing whitespace and carriage returns: a nested path may end + // in a space, tab, or CR. codesign delimits records with LF, so split on LF + // only and strip leading whitespace for record parsing. const lines = `${capture.stderr}\n${capture.stdout}` - .split(/\r?\n/u) - .map((line) => line.replace(/\r$/u, "")) + .split(/\n/u) .filter((line) => line.length > 0); const record = (line: string): string => line.replace(/^\s+/u, ""); + const diagnostics = lines.filter( + (line) => !/^--(?:prepared|validated):/u.test(record(line)), + ); return { path, status: - capture.exitCode === 0 ? "valid" : unsigned ? "unsigned" : "invalid", + capture.exitCode === 0 + ? "valid" + : unsigned + ? "unsigned" + : verificationIOFailure(diagnostics) + ? "unknown" + : "invalid", exit_code: capture.exitCode, - diagnostics: lines.filter( - (line) => !/^--(?:prepared|validated):/u.test(record(line)), - ), + diagnostics, validated_nested_code: [ ...new Set( lines.flatMap((line) => { @@ -156,6 +173,28 @@ export const stapledTicket = async ( size: null, reason: "changed", }; + // An atomic replacement swaps the pathname to a new inode while the + // 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); + if (current.dev !== opened.dev || current.ino !== opened.ino) + return { + status: "unreadable", + path: relative, + sha256: null, + size: null, + reason: "changed", + }; + } catch { + return { + status: "unreadable", + path: relative, + sha256: null, + size: null, + reason: "changed", + }; + } return { status: "present", path: relative, diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index 05018240a4..5779c2b520 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -330,7 +330,27 @@ describe("native signature posture slice-aware cases", () => { ); }); - it("flags permission diagnostics separately from invalid signatures", async () => { + it("preserves a trailing carriage return in reported nested paths", async () => { + const { app, executable } = await fixtureApp(undefined); + const signature = await new NativeMacOSProvider( + new CarriageReturnPathRunner(), + "darwin", + ) + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + if (!signature.ok) throw signature.error; + const result = inspectSignatureSchema.parse(signature.value.result); + // The CR is a valid pathname byte, not a line delimiter: it is kept and + // the nonexistent path is flagged rather than silently normalized. + expect(result.verification?.validated_nested_code).toEqual([ + `${app}/Contents/Helpers/cr\r`, + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("which does not exist"), + ); + }); + + it("reports verification I/O failures as unknown, not invalid", async () => { const { app, executable } = await fixtureApp(undefined); const signature = await new NativeMacOSProvider( new PermissionDeniedRunner(), @@ -340,9 +360,11 @@ describe("native signature posture slice-aware cases", () => { .execute("inspect_signature", {}); if (!signature.ok) throw signature.error; const result = inspectSignatureSchema.parse(signature.value.result); - expect(result.verification?.status).toBe("invalid"); + expect(result.verification?.status).toBe("unknown"); expect(result.limitations).toContainEqual( - expect.stringContaining("permission or I/O diagnostic"), + expect.stringContaining( + "operational failure, not a proven broken signature", + ), ); }); }); @@ -459,6 +481,23 @@ class UniversalMixedRunner extends FixtureRunner { } } +/** A nested helper whose file name ends in a carriage return. */ +class CarriageReturnPathRunner 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 bundle = arguments_.at(-1) ?? ""; + const stderr = `--validated:${bundle}/Contents/Helpers/cr\r\n${bundle}: valid on disk\n`; + return ok({ + ...result.value, + stdout: "", + stderr, + stdoutBytes: 0, + stderrBytes: Buffer.byteLength(stderr), + }); + } +} + /** Verification fails because nested code cannot be read. */ class PermissionDeniedRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) {