fix(native): compare signed slice posture and reject ticket mutation

This commit is contained in:
morluto committed 2026-10-08 16:59:08 +08:00
1 parent d13143b5c5
commit 6a5cedfec4
3 files changed
+228 -30

No files matched your search

+92 -23
View File
@@ -25,6 +25,7 @@ import {
type NativeToolName,
} from "../contracts/native/nativeToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { canonicalJson } from "../domain/comparisonSemantics.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
@@ -412,6 +413,10 @@ class NativeMacOSClient implements AnalysisClient {
);
if (!sliceState.ok) return sliceState;
const mixedSigning = sliceState.value.mixed;
const aggregateUnsigned =
sliceState.value.signed === undefined
? unsigned
: !sliceState.value.signed;
captures.push(...sliceState.value.captures);
limitations.push(...sliceState.value.limitations);
// Only the top-level signature decides "unsigned"; unsigned nested
@@ -419,7 +424,7 @@ class NativeMacOSClient implements AnalysisClient {
const verification = signatureVerification(
verify.value,
verifyPath,
unsigned,
aggregateUnsigned,
);
limitations.push(...(await unconfirmedNestedCode(verification)));
if (verification.status === "unknown")
@@ -442,12 +447,13 @@ class NativeMacOSClient implements AnalysisClient {
);
const result = inspectSignatureSchema.parse({
...parsed,
signed: !aggregateUnsigned,
designated_requirement: requirementText,
entitlements: entitlementValue.value,
verification,
stapled_ticket: await stapledTicket(this.target, signal),
security_facets: deriveSecurityFacets({
signed: !unsigned,
signed: !aggregateUnsigned,
codeDirectory: parsed.code_directory,
entitlements: entitlementValue.value,
entitlementsKnown: unsigned || entitlements.value.exitCode === 0,
@@ -485,13 +491,12 @@ class NativeMacOSClient implements AnalysisClient {
readonly mixed: boolean;
readonly captures: readonly NativeCommandCapture[];
readonly limitations: readonly string[];
readonly signed?: boolean;
},
AnalysisError
>
> {
const universalArchitectures = universalSliceArchitectures(format);
const universal =
universalArchitectures !== undefined && universalArchitectures.length > 1;
const universal = codeSignArchitectures(format, this.target).length > 1;
const gate =
!unsigned &&
(isNonzeroUnsignedObservation(requirements) ||
@@ -509,6 +514,7 @@ class NativeMacOSClient implements AnalysisClient {
mixed: gate || (universal && slices.value.mixed),
captures: slices.value.captures,
limitations: slices.value.limitations,
signed: slices.value.signed,
});
}
@@ -536,6 +542,7 @@ class NativeMacOSClient implements AnalysisClient {
const signed: string[] = [];
const unsigned: string[] = [];
const unclassified: string[] = [];
const postures = new Map<string, string>();
for (const architecture of codeSignArchitectures(format, this.target)) {
const slice = await this.#run(
"inspect_signature",
@@ -547,6 +554,15 @@ class NativeMacOSClient implements AnalysisClient {
captures.push(slice.value);
if (slice.value.exitCode === 0) {
signed.push(architecture);
const posture = await this.#slicePosture(
architecture,
slice.value,
signal,
);
if (!posture.ok) return posture;
captures.push(posture.value.capture);
if (posture.value.posture === null) unclassified.push(architecture);
else postures.set(architecture, posture.value.posture);
continue;
}
if (isNonzeroUnsignedObservation(slice.value))
@@ -555,11 +571,21 @@ class NativeMacOSClient implements AnalysisClient {
}
// Slices differ in signing state when a signed slice coexists with an
// unsigned or unclassifiable one; all-signed or all-unsigned files agree.
// Signed slices can still disagree among themselves: per-architecture
// display is authoritative per slice, so differing flags or entitlements
// also make the aggregate facets unknown.
const postureDiffers = new Set(postures.values()).size > 1;
const mixed =
signed.length > 0 && (unsigned.length > 0 || unclassified.length > 0);
unclassified.length > 0 ||
(signed.length > 0 && unsigned.length > 0) ||
postureDiffers;
const limitations: string[] = [];
if (unsigned.length > 0)
limitations.push(`Unsigned Mach-O slices: ${unsigned.join(", ")}.`);
if (postureDiffers && unsigned.length === 0 && unclassified.length === 0)
limitations.push(
"Signed Mach-O slices differ in CodeDirectory flags or entitlements; aggregate facets are unknown.",
);
if (unclassified.length > 0)
limitations.push(
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
@@ -572,7 +598,56 @@ class NativeMacOSClient implements AnalysisClient {
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
return ok({ captures, limitations, mixed });
return ok({ captures, limitations, mixed, signed: signed.length > 0 });
}
/**
* Canonical posture of one signed slice: sorted CodeDirectory flag names and
* entitlements. Null when the slice display cannot be parsed; the slice
* still counts as signed from its zero exit code.
*/
async #slicePosture(
architecture: string,
display: NativeCommandCapture,
signal?: AbortSignal,
): Promise<
Result<
{
readonly posture: string | null;
readonly capture: NativeCommandCapture;
},
AnalysisError
>
> {
const entitlements = await this.#run(
"inspect_signature",
"codesign",
["-d", "--entitlements", ":-", "-a", architecture, this.target.path],
{ signal, acceptNonZero: true },
);
if (!entitlements.ok) return entitlements;
const parsed = parseCodeSignature(display.stderr, false, [
this.target.path,
]);
const directory = parsed.code_directory;
if (
directory?.flags === null ||
directory === null ||
entitlements.value.exitCode !== 0
)
return ok({ posture: null, capture: entitlements.value });
const entitlementValue = parseEntitlements(entitlements.value.stdout);
return ok({
posture: canonicalJson(
{
flags: directory.flags.value,
platform: directory.platform_identifier,
entitlements: entitlementValue.value,
},
"Signature slice posture",
),
capture: entitlements.value,
});
}
async #inspectPlist(
@@ -709,6 +784,7 @@ interface MixedSignatureSlices {
readonly limitations: readonly string[];
/** A signed slice coexists with an unsigned or unclassifiable one. */
readonly mixed: boolean;
readonly signed: boolean;
}
const isNativeOperation = (
@@ -782,7 +858,7 @@ const universalSliceArchitectures = (
const architectures = universal
.split(/\s+/u)
.filter((architecture) =>
/^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture),
/^(?:i386|x86_64|x86_64h|armv7|arm64|arm64e)$/u.test(architecture),
);
return architectures.length > 0 ? [...new Set(architectures)] : undefined;
};
@@ -791,21 +867,14 @@ const codeSignArchitectures = (
format: string | null,
target: BinaryTarget,
): string[] => {
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
format ?? "",
)?.[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 declared = universalSliceArchitectures(format) ?? [];
const available =
target.kind === "executable" && target.format === "mach-o"
? target.availableArchitectures.map((architecture) =>
architecture === "x86" ? "i386" : architecture,
)
: [];
return [...new Set([...declared, ...available])];
};
const invocation = (
+34 -5
View File
@@ -143,9 +143,11 @@ export const stapledTicket = async (
try {
const opened = await handle.stat();
if (!opened.isFile()) return absent;
// If the path was replaced between lstat and open, or during hashing,
// the digest would describe the wrong bytes; report it as changed.
if (opened.dev !== metadata.dev || opened.ino !== metadata.ino)
// If the path was replaced between lstat and open, or mutated while
// hashing, the digest would describe the wrong bytes: dev and ino catch
// replacement, while size and mtime catch same-inode writes. Report any
// drift as changed instead of a stale digest.
if (!sameTicketFile(opened, metadata))
return {
status: "unreadable",
path: relative,
@@ -165,7 +167,7 @@ export const stapledTicket = async (
}
signal?.throwIfAborted();
const closing = await handle.stat();
if (closing.dev !== opened.dev || closing.ino !== opened.ino)
if (!sameTicketFile(closing, opened) || size !== opened.size)
return {
status: "unreadable",
path: relative,
@@ -178,7 +180,7 @@ export const stapledTicket = async (
// 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)
if (!sameTicketFile(current, opened))
return {
status: "unreadable",
path: relative,
@@ -222,6 +224,33 @@ export const stapledTicket = async (
}
};
/**
* Whether two observations describe the same file version. Device and inode
* catch replacement; size and mtime catch same-inode writes and truncations
* whose digest would otherwise mix multiple file states.
*/
const sameTicketFile = (
left: {
readonly dev: number;
readonly ino: number;
readonly size: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
},
right: {
readonly dev: number;
readonly ino: number;
readonly size: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
},
): boolean =>
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs;
const absent = {
status: "absent",
path: null,
@@ -350,6 +350,31 @@ describe("native signature posture slice-aware cases", () => {
);
});
it("marks facets unknown when signed slices differ in posture", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalPostureDiffRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
// Every slice verifies, but their CodeDirectory flags disagree.
expect(result.verification?.status).toBe("valid");
expect(
result.security_facets.find(
({ facet }) => facet === "library-validation",
),
).toMatchObject({
state: "unknown",
evidence: ["architecture slices differ in signing state"],
});
expect(result.limitations).toContainEqual(
expect.stringContaining("differ in CodeDirectory flags or entitlements"),
);
});
it("reports verification I/O failures as unknown, not invalid", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
@@ -369,6 +394,44 @@ describe("native signature posture slice-aware cases", () => {
});
});
describe("signature architecture coverage", () => {
it("probes a valid x86_64h subtype rather than dropping it", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalMixedRunner("x86_64h"),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.limitations).toContain("Unsigned Mach-O slices: x86_64h.");
expect(
result.security_facets.every(({ state }) => state === "unknown"),
).toBe(true);
});
it("retains per-slice entitlement captures used to compare posture", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalPostureDiffRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
const commands = result.provenance.filter(
({ command }) =>
command.includes("-a") && command.includes("--entitlements"),
);
expect(commands).toHaveLength(2);
expect(result.entitlements).toEqual({
"com.apple.security.app-sandbox": true,
});
});
});
/** A signed bundle whose nested helper is unsigned. */
class UnsignedNestedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
@@ -452,12 +515,16 @@ class NestedOnlyFailureRunner extends FixtureRunner {
* slice while the x86_64 slice is unsigned.
*/
class UniversalMixedRunner extends FixtureRunner {
constructor(private readonly foreignArchitecture = "x86_64") {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
const architectureIndex = arguments_.indexOf("-a");
if (architectureIndex >= 0) {
if (arguments_[architectureIndex + 1] !== "x86_64") return result;
if (arguments_[architectureIndex + 1] !== this.foreignArchitecture)
return result;
const stderr = `${arguments_.at(-1)}: code object is not signed at all\n`;
return ok({
...result.value,
@@ -471,7 +538,7 @@ class UniversalMixedRunner extends FixtureRunner {
if (!arguments_.includes("--verbose=4")) return result;
const stderr = result.value.stderr.replace(
/^Format=.*$/mu,
"Format=Mach-O universal (x86_64 arm64)",
`Format=Mach-O universal (${this.foreignArchitecture} arm64)`,
);
return ok({
...result.value,
@@ -498,6 +565,39 @@ class CarriageReturnPathRunner extends FixtureRunner {
}
}
/**
* A universal binary whose slices all verify but carry different
* CodeDirectory flags (hardened arm64, plain x86_64).
*/
class UniversalPostureDiffRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
if (arguments_.includes("--entitlements")) return result;
const architectureIndex = arguments_.indexOf("-a");
const architecture =
architectureIndex < 0 ? null : arguments_[architectureIndex + 1];
let stderr = result.value.stderr;
if (architecture === null)
stderr = stderr.replace(
/^Format=.*$/mu,
"Format=Mach-O universal (x86_64 arm64)",
);
else if (architecture === "x86_64")
stderr = stderr.replace(
/flags=0x[0-9a-f]+\([^)]*\)/u,
"flags=0x2(adhoc)",
);
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[]) {