fix(native): compare signed slice posture and reject ticket mutation
This commit is contained in:
1 parent
d13143b5c5
commit
6a5cedfec4
3 files changed
+228
-30
No files matched your search
@@ -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 = (
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in new issue
Block a user