Merge pull request #701 from kaoru0822-kitauji/fix/mixed-signature-slices
fix(native): inspect mixed-signature Mach-O slices
This commit is contained in:
commit
1dccd0dee0
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue