fix(native): unknown verification on I/O failure, recheck ticket path, keep CR paths

This commit is contained in:
morluto committed 2026-10-08 16:43:03 +08:00
1 parent 96c2a93755
commit cc706f347f
4 files changed
+94 -22

No files matched your search

+1 -1
View File
@@ -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()),
+4 -10
View File
@@ -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;
+47 -8
View File
@@ -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<InspectSignature["verification"]> => {
// 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,
@@ -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[]) {