fix(native): unknown verification on I/O failure, recheck ticket path, keep CR paths
This commit is contained in:
1 parent
96c2a93755
commit
cc706f347f
4 files changed
+94
-22
No files matched your search
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in new issue
Block a user