Merge pull request #701 from kaoru0822-kitauji/fix/mixed-signature-slices

fix(native): inspect mixed-signature Mach-O slices
This commit is contained in:
Kaoru0822 2026-10-06 05:07:08 -07:00 committed by GitHub
commit 1dccd0dee0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 253 additions and 27 deletions

View File

@ -331,55 +331,62 @@ class NativeMacOSClient implements AnalysisClient {
{ signal, acceptNonZero: true },
);
if (!display.ok) return display;
const unsigned = /not signed at all|code object is not signed/iu.test(
`${display.value.stdout}\n${display.value.stderr}`,
);
if (!unsigned && display.value.exitCode !== 0)
return err(
translateCommandFailure(
"inspect_signature",
new NativeCommandFailure(
"codesign",
"nonzero-exit",
display.value.exitCode,
),
),
);
const displayOutput = commandOutput(display.value);
const unsigned = isUnsignedCodeSignObservation(display.value);
const displayFailure = codeSignCaptureFailure(display.value);
if (displayFailure !== null) return err(displayFailure);
const requirements = await this.#run(
"inspect_signature",
"codesign",
["-d", "-r-", this.target.path],
{ signal, acceptNonZero: unsigned },
{ signal, acceptNonZero: true },
);
if (!requirements.ok) return requirements;
const requirementsFailure = codeSignCaptureFailure(requirements.value);
if (requirementsFailure !== null) return err(requirementsFailure);
const entitlements = await this.#run(
"inspect_signature",
"codesign",
["-d", "--entitlements", ":-", this.target.path],
{ signal, acceptNonZero: unsigned },
{ signal, acceptNonZero: true },
);
if (!entitlements.ok) return entitlements;
const parsed = parseCodeSignature(
`${display.value.stdout}\n${display.value.stderr}`,
unsigned,
);
const entitlementsFailure = codeSignCaptureFailure(entitlements.value);
if (entitlementsFailure !== null) return err(entitlementsFailure);
const parsed = parseCodeSignature(displayOutput, unsigned);
const requirementText =
/designated\s*=>\s*(.+)$/mu.exec(
`${requirements.value.stdout}\n${requirements.value.stderr}`,
commandOutput(requirements.value),
)?.[1] ?? null;
const entitlementValue = parseEntitlements(
`${entitlements.value.stdout}\n${entitlements.value.stderr}`,
commandOutput(entitlements.value),
);
const captures = [display.value, requirements.value, entitlements.value];
const limitations = [...parsed.limitations];
const mixedSigning =
!unsigned &&
(isNonzeroUnsignedObservation(requirements.value) ||
isNonzeroUnsignedObservation(entitlements.value));
if (mixedSigning) {
const slices = await this.#inspectMixedSignatureSlices(
displayOutput,
requirements.value.exitCode !== 0,
entitlements.value.exitCode !== 0,
signal,
);
if (!slices.ok) return slices;
captures.push(...slices.value.captures);
limitations.push(...slices.value.limitations);
}
const provenance = captures.map((capture) =>
invocation(capture, this.target.path),
);
const provenance = [
display.value,
requirements.value,
entitlements.value,
].map((capture) => invocation(capture, this.target.path));
const result = inspectSignatureSchema.parse({
...parsed,
designated_requirement: requirementText,
entitlements: entitlementValue,
provenance,
limitations,
});
return ok({
result: jsonValueSchema.parse(result),
@ -389,6 +396,52 @@ class NativeMacOSClient implements AnalysisClient {
});
}
async #inspectMixedSignatureSlices(
displayOutput: string,
requirementsUnavailable: boolean,
entitlementsUnavailable: boolean,
signal?: AbortSignal,
): Promise<Result<MixedSignatureSlices, AnalysisError>> {
const captures: NativeCommandCapture[] = [];
const unsigned: string[] = [];
const unclassified: string[] = [];
for (const architecture of codeSignArchitectures(
displayOutput,
this.target,
)) {
const slice = await this.#run(
"inspect_signature",
"codesign",
["-d", "-a", architecture, "--verbose=4", this.target.path],
{ signal, acceptNonZero: true },
);
if (!slice.ok) return slice;
captures.push(slice.value);
if (slice.value.exitCode === 0) continue;
if (isNonzeroUnsignedObservation(slice.value))
unsigned.push(architecture);
else unclassified.push(architecture);
}
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 (requirementsUnavailable)
limitations.push(
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
);
if (entitlementsUnavailable)
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
return ok({ captures, limitations });
}
async #inspectPlist(
parameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
@ -485,6 +538,11 @@ interface NativeObservation {
readonly locations: readonly EvidenceLocation[];
}
interface MixedSignatureSlices {
readonly captures: readonly NativeCommandCapture[];
readonly limitations: readonly string[];
}
const isNativeOperation = (
operation: AnalysisOperation,
): operation is NativeToolName =>
@ -505,6 +563,55 @@ const translateCommandFailure = (
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
};
const translateCodeSignExitFailure = (
capture: NativeCommandCapture,
): AnalysisError =>
translateCommandFailure(
"inspect_signature",
new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode),
);
const commandOutput = (capture: NativeCommandCapture): string =>
`${capture.stdout}\n${capture.stderr}`;
const isUnsignedCodeSignObservation = (
capture: NativeCommandCapture,
): boolean =>
/not signed at all|code object is not signed/iu.test(commandOutput(capture));
const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean =>
capture.exitCode !== null &&
capture.exitCode !== 0 &&
isUnsignedCodeSignObservation(capture);
const codeSignCaptureFailure = (
capture: NativeCommandCapture,
): AnalysisError | null =>
capture.exitCode === 0 || isNonzeroUnsignedObservation(capture)
? null
: translateCodeSignExitFailure(capture);
const codeSignArchitectures = (
displayOutput: string,
target: BinaryTarget,
): string[] => {
const universal = /^Format=Mach-O universal \(([^)\r\n]+)\)$/mu.exec(
displayOutput,
)?.[1];
if (universal !== undefined) {
const architectures = universal
.split(/\s+/u)
.filter((architecture) =>
/^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture),
);
if (architectures.length > 0) return [...new Set(architectures)];
}
if (target.kind !== "executable" || target.format !== "mach-o") return [];
return target.availableArchitectures.map((architecture) =>
architecture === "x86" ? "i386" : architecture,
);
};
const invocation = (
capture: NativeCommandCapture,
artifactPath: string,

View File

@ -1,4 +1,11 @@
import { expect, it } from "vitest";
import { err, ok } from "../../../../src/domain/result.js";
import {
NativeCommandFailure,
type NativeCommandCapture,
type NativeCommandOptions,
type NativeCommandRunner,
} from "../../../../src/native/CommandRunner.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import { parseDyldSymbols } from "../../../../src/native/parsers/dyldInfo.js";
@ -33,6 +40,74 @@ it("distinguishes a codesign execution failure from an observed unsigned artifac
}
});
it("reports unsigned slices when a universal Mach-O has mixed signatures", async () => {
const signedFixture = await fixture("codesign.txt");
const unsigned = await fixture("dyld-inventory/codesign-unsigned.txt");
const entitlements = await fixture("entitlements.xml");
for (const signedArchitecture of ["arm64", "arm64e"]) {
const signed = signedFixture.replace(
/^Format=.*$/mu,
`Format=Mach-O universal (x86_64 ${signedArchitecture})`,
);
const client = new NativeMacOSProvider(
signatureRunner({ signed, unsigned, entitlements }),
"darwin",
).createClient(machoTarget("/private/fixture"));
const execution = await client.execute("inspect_signature", {});
expect(execution.ok).toBe(true);
if (!execution.ok) continue;
expect(execution.value.result).toMatchObject({
signed: true,
identifier: "com.example.fixture",
designated_requirement: null,
entitlements: { "com.apple.security.app-sandbox": true },
limitations: expect.arrayContaining([
"Unsigned Mach-O slices: x86_64.",
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
]),
provenance: expect.arrayContaining([
expect.objectContaining({
command: [
"/usr/bin/codesign",
"-d",
"-a",
"x86_64",
"--verbose=4",
"$ARTIFACT",
],
exit: { code: 1, signal: null },
}),
expect.objectContaining({
command: expect.arrayContaining(["-a", signedArchitecture]),
exit: { code: 0, signal: null },
}),
]),
});
}
});
it("rejects nonzero supplemental codesign output that is not an unsigned observation", async () => {
const signed = await fixture("codesign.txt");
const entitlements = await fixture("entitlements.xml");
const client = new NativeMacOSProvider(
signatureRunner({
signed,
unsigned: "/private/fixture: invalid or unsupported format\n",
entitlements,
}),
"darwin",
).createClient(machoTarget("/private/fixture"));
const execution = await client.execute("inspect_signature", {});
expect(execution).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
});
it("retains native inventory facts from captured Apple tool output", async () => {
const outputs: Record<string, string> = {};
for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"])
@ -200,3 +275,47 @@ Section
sections: [{ segment: "0001", name: "0002", size: 8 }],
});
});
const signatureRunner = (outputs: {
readonly signed: string;
readonly unsigned: string;
readonly entitlements: string;
}): NativeCommandRunner => ({
run(
tool: string,
arguments_: readonly string[],
options: NativeCommandOptions,
) {
const architectureIndex = arguments_.indexOf("-a");
const architecture =
architectureIndex < 0 ? null : arguments_[architectureIndex + 1];
const response = arguments_.includes("-r-")
? { output: outputs.unsigned, exitCode: 1 }
: arguments_.includes("--entitlements")
? { output: outputs.entitlements, exitCode: 0 }
: architecture === "x86_64"
? { output: outputs.unsigned, exitCode: 1 }
: { output: outputs.signed, exitCode: 0 };
const capture: NativeCommandCapture = {
tool,
executable: `/usr/bin/${tool}`,
executableSha256: "a".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: [...arguments_],
stdout: "",
stderr: response.output,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(response.output),
exitCode: response.exitCode,
signal: null,
};
return Promise.resolve(
response.exitCode === 0 || options.acceptNonZero === true
? ok(capture)
: err(
new NativeCommandFailure(tool, "nonzero-exit", response.exitCode),
),
);
},
});