fix(native): preserve mixed-slice signature observations (#5765)

* fix(native): preserve mixed-slice signature observations

* fix(native): retain signature facts when slice inspection fails

* fix(native): accumulate known slice observations before failure
This commit is contained in:
Jeff
2026-10-21 00:59:10 -04:00
committed by GitHub
parent 71b696582b
commit 89b1941c8e
2 changed files with 189 additions and 58 deletions
+89 -54
View File
@@ -667,6 +667,10 @@ class NativeMacOSClient implements AnalysisClient {
binding: SignatureTargetBinding,
signal?: AbortSignal,
): Promise<Result<InspectSignature, AnalysisError>> {
const executablePaths = [
binding.snapshotPath,
...(await canonicalPath(binding.snapshotPath)),
];
const display = await this.#captureSignature(
binding,
["-d", "--verbose=4"],
@@ -676,10 +680,12 @@ class NativeMacOSClient implements AnalysisClient {
const unsigned = isNonzeroUnsignedObservation(
display.value,
binding.snapshotPath,
executablePaths,
);
const displayFailure = codeSignCaptureFailure(
display.value,
binding.snapshotPath,
executablePaths,
);
if (displayFailure !== null) return err(displayFailure);
const requirements = await this.#captureSignature(
@@ -691,6 +697,7 @@ class NativeMacOSClient implements AnalysisClient {
const requirementsFailure = codeSignCaptureFailure(
requirements.value,
binding.snapshotPath,
executablePaths,
);
if (requirementsFailure !== null) return err(requirementsFailure);
const entitlements = await this.#captureSignature(
@@ -702,13 +709,15 @@ class NativeMacOSClient implements AnalysisClient {
const entitlementsFailure = codeSignCaptureFailure(
entitlements.value,
binding.snapshotPath,
executablePaths,
);
if (entitlementsFailure !== null) return err(entitlementsFailure);
// codesign echoes the resolved executable path in its diagnostics.
const parsed = parseCodeSignature(display.value.stderr, unsigned, [
binding.snapshotPath,
...(await canonicalPath(binding.snapshotPath)),
]);
const parsed = parseCodeSignature(
display.value.stderr,
unsigned,
executablePaths,
);
// The requirement is printed to stdout; stderr echoes the path.
const requirementText =
/designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null;
@@ -727,49 +736,79 @@ class NativeMacOSClient implements AnalysisClient {
];
const mixedSigning =
!unsigned &&
(isNonzeroUnsignedObservation(requirements.value, binding.snapshotPath) ||
isNonzeroUnsignedObservation(entitlements.value, binding.snapshotPath));
(isNonzeroUnsignedObservation(
requirements.value,
binding.snapshotPath,
executablePaths,
) ||
isNonzeroUnsignedObservation(
entitlements.value,
binding.snapshotPath,
executablePaths,
));
const observation = () =>
inspectSignatureSchema.parse({
...parsed,
designated_requirement: requirementText,
entitlements: entitlementValue.value,
provenance: captures.map((capture) =>
invocation(capture, binding.snapshotPath),
),
limitations,
});
if (mixedSigning) {
limitations.push(
"Partial signature coverage: aggregate signing metadata describes codesign’s default slice; Mach-O slices have mixed signing states.",
);
const slices = await this.#inspectMixedSignatureSlices(
binding,
parsed.format,
executablePaths,
{ captures, limitations },
{
requirements: requirements.value.exitCode,
entitlements: entitlements.value.exitCode,
},
signal,
);
if (!slices.ok) return slices;
captures.push(...slices.value.captures);
limitations.push(...slices.value.limitations);
if (!slices.ok) {
limitations.push(
"Architecture-specific signature inspection did not complete; remaining slice signing states are unknown.",
);
return err(slices.error.retainPartialObservation(observation()));
}
}
const provenance = captures.map((capture) =>
invocation(capture, binding.snapshotPath),
);
const version = await verifySignatureTarget(this.target, binding, signal);
if (!version.ok) return version;
const result = inspectSignatureSchema.parse({
...parsed,
designated_requirement: requirementText,
entitlements: entitlementValue.value,
provenance,
limitations,
});
return ok(result);
if (!version.ok)
return err(version.error.retainPartialObservation(observation()));
return ok(observation());
}
async #inspectMixedSignatureSlices(
binding: SignatureTargetBinding,
format: string | null,
executablePaths: readonly string[],
observation: { captures: NativeCommandCapture[]; limitations: string[] },
aggregateExits: Readonly<{
requirements: number | null;
entitlements: number | null;
}>,
signal?: AbortSignal,
): Promise<Result<MixedSignatureSlices, AnalysisError>> {
const captures: NativeCommandCapture[] = [];
): Promise<Result<void, AnalysisError>> {
const { captures, limitations } = observation;
const unsigned: string[] = [];
const unclassified: string[] = [];
const unsignedNote = limitations.length;
limitations.push(
"One or more Mach-O slices are unsigned, but their architectures could not be classified.",
);
if (aggregateExits.requirements !== 0)
limitations.push(
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
);
if (aggregateExits.entitlements !== 0)
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
for (const architecture of codeSignArchitectures(format, this.target)) {
const slice = await this.#captureSignature(
binding,
@@ -779,28 +818,17 @@ class NativeMacOSClient implements AnalysisClient {
if (!slice.ok) return slice;
captures.push(slice.value);
if (slice.value.exitCode === 0) continue;
if (isNonzeroUnsignedObservation(slice.value, binding.snapshotPath))
unsigned.push(architecture);
else unclassified.push(architecture);
const failure = codeSignCaptureFailure(
slice.value,
binding.snapshotPath,
executablePaths,
);
if (failure !== null) return err(failure);
unsigned.push(architecture);
limitations[unsignedNote] =
`Unsigned Mach-O slices: ${unsigned.join(", ")}.`;
}
const limitations = [
unsigned.length > 0
? `Unsigned Mach-O slices: ${unsigned.join(", ")}.`
: "One or more Mach-O slices are unsigned, but their architectures could not be classified.",
];
if (unclassified.length > 0)
limitations.push(
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
);
if (aggregateExits.requirements !== 0)
limitations.push(
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
);
if (aggregateExits.entitlements !== 0)
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
return ok({ captures, limitations });
return ok(undefined);
}
async #captureSignature(
@@ -1131,11 +1159,6 @@ interface NativeObservation {
readonly locations: readonly EvidenceLocation[];
}
interface MixedSignatureSlices {
readonly captures: readonly NativeCommandCapture[];
readonly limitations: readonly string[];
}
const isNativeOperation = (
operation: AnalysisOperation,
): operation is NativeToolName =>
@@ -1318,13 +1341,15 @@ const translateCodeSignExitFailure = (
);
/**
* Accept only codesign's exact unsigned diagnostic for the bound snapshot.
* Accept only codesign's exact unsigned diagnostic for the bound snapshot,
* optionally preceded by its verified Executable path echo.
* The operand is framed byte-for-byte so caller-controlled path text cannot
* supply the phrase that determines signature state.
*/
const isNonzeroUnsignedObservation = (
capture: NativeCommandCapture,
snapshotPath: string,
executablePaths: readonly string[],
): boolean => {
if (
capture.exitCode === null ||
@@ -1335,8 +1360,16 @@ const isNonzeroUnsignedObservation = (
)
return false;
const diagnostic = `${snapshotPath}: code object is not signed at all`;
return [diagnostic, `${diagnostic}\n`, `${diagnostic}\r\n`].includes(
capture.stderr,
return [
"",
...executablePaths.flatMap((path) => [
`Executable=${path}\n`,
`Executable=${path}\r\n`,
]),
].some((prefix) =>
[diagnostic, `${diagnostic}\n`, `${diagnostic}\r\n`].some(
(body) => capture.stderr === `${prefix}${body}`,
),
);
};
@@ -1351,8 +1384,10 @@ const canonicalPath = async (path: string): Promise<string[]> => {
const codeSignCaptureFailure = (
capture: NativeCommandCapture,
snapshotPath: string,
executablePaths: readonly string[],
): AnalysisError | null =>
capture.exitCode === 0 || isNonzeroUnsignedObservation(capture, snapshotPath)
capture.exitCode === 0 ||
isNonzeroUnsignedObservation(capture, snapshotPath, executablePaths)
? null
: translateCodeSignExitFailure(capture);
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises";
import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js";
import { realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
@@ -96,7 +97,7 @@ it("reports unsigned slices when a universal Mach-O has mixed signatures", async
);
const client = new NativeMacOSProvider(
{},
signatureRunner({ signed, unsigned, entitlements }),
signatureRunner({ signed, unsigned, entitlements, unsignedHeader: true }),
"darwin",
).createClient(await signatureTarget(signedArchitecture));
@@ -110,6 +111,7 @@ it("reports unsigned slices when a universal Mach-O has mixed signatures", async
designated_requirement: null,
entitlements: { "com.apple.security.app-sandbox": true },
limitations: expect.arrayContaining([
"Partial signature coverage: aggregate signing metadata describes codesign’s default slice; Mach-O slices have mixed signing states.",
"Unsigned Mach-O slices: x86_64.",
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
]),
@@ -155,6 +157,99 @@ it("rejects nonzero supplemental codesign output that is not an unsigned observa
});
});
it.each([
"extra diagnostic",
"foreign executable",
"slice diagnostic",
"later slice diagnostic",
])("rejects an unsigned response with %s", async (kind) => {
const signed = (await fixture("codesign.txt")).replace(
/^Format=.*$/mu,
"Format=Mach-O universal (x86_64 arm64)",
);
const replay = signatureRunner({
signed,
unsigned: "code object is not signed at all",
entitlements: await fixture("entitlements.xml"),
unsignedHeader: true,
});
const runner: NativeCommandRunner = {
async run(tool, args, options) {
const result = await replay.run(tool, args, options);
if (!result.ok) return result;
if (kind === "later slice diagnostic") {
if (!args.includes("arm64")) return result;
const stderr = "later architecture probe failed\n";
return ok({
...result.value,
exitCode: 1,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
if (result.value.exitCode === 0) return result;
const isSlice = args.includes("-a");
if ((kind === "slice diagnostic") !== isSlice) return result;
const stderr =
kind === "foreign executable"
? result.value.stderr.replace(
/^Executable=.*\n/u,
"Executable=/foreign/program\n",
)
: `${result.value.stderr}additional codesign failure\n`;
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
},
};
const result = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(await signatureTarget())
.execute("inspect_signature", {});
expect(result).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
if (kind.endsWith("slice diagnostic") && !result.ok) {
const partial = inspectSignatureSchema.parse(
result.error.partialObservation,
);
expect(partial).toMatchObject({
signed: true,
identifier: "com.example.fixture",
entitlements: { "com.apple.security.app-sandbox": true },
designated_requirement: null,
limitations: expect.arrayContaining([
expect.stringContaining("Partial signature coverage"),
expect.stringContaining("did not complete"),
]),
});
expect(partial.provenance).toHaveLength(
kind === "later slice diagnostic" ? 5 : 4,
);
expect(partial.provenance[0]?.command).toContain("--verbose=4");
expect(partial.provenance[1]?.command).toContain("-r-");
expect(partial.provenance[2]?.command).toContain("--entitlements");
if (kind === "later slice diagnostic") {
expect(partial.limitations).toEqual(
expect.arrayContaining([
"Unsigned Mach-O slices: x86_64.",
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
]),
);
expect(partial.provenance[4]).toMatchObject({
command: expect.arrayContaining(["-a", "arm64"]),
exit: { code: 1, signal: null },
});
}
expect(partial.provenance[3]).toMatchObject({
command: expect.arrayContaining(["-a", "x86_64"]),
exit: { code: 1, signal: null },
});
}
});
it("retains native inventory facts from captured Apple tool output", async () => {
const outputs: Record<string, string> = {};
for (const tool of ["lipo", "otool", "nm", "dwarfdump", "vtool"])
@@ -403,8 +498,9 @@ const signatureRunner = (outputs: {
readonly signed: string;
readonly unsigned: string;
readonly entitlements: string;
readonly unsignedHeader?: boolean;
}): NativeCommandRunner => ({
run(
async run(
tool: string,
arguments_: readonly string[],
options: NativeCommandOptions,
@@ -424,7 +520,7 @@ const signatureRunner = (outputs: {
selectedResponse.output.includes("code object is not signed at all")
? {
...selectedResponse,
output: `${arguments_.at(-1)}: code object is not signed at all\n`,
output: `${outputs.unsignedHeader ? `Executable=${await realpath(arguments_.at(-1)!)}\n` : ""}${arguments_.at(-1)}: code object is not signed at all\n`,
}
: selectedResponse;
const capture: NativeCommandCapture = {