fix(native): harden signature posture for wrapped bundles, tickets, and invalid signatures

This commit is contained in:
morluto committed 2026-10-08 15:24:29 +08:00
1 parent 33def5ae06
commit 8f91add240
5 files changed
+159 -15

No files matched your search

@@ -177,6 +177,21 @@ describe("security facets that need more than one CodeDirectory", () => {
]);
});
it("leaves every facet unknown when verification failed", () => {
const invalid = derive({
signed: true,
codeDirectory: directory(0x2000 | RUNTIME, 26),
entitlements: null,
entitlementsKnown: true,
appleOrigin: true,
signatureInvalid: true,
});
expect(new Set(invalid.map(({ state }) => state))).toEqual(
new Set(["unknown"]),
);
expect(invalid[0]?.evidence).toEqual(["signature verification failed"]);
});
it("keeps facets unknown when flags or entitlements are unavailable", () => {
expect(
states(
+13
View File
@@ -153,6 +153,11 @@ interface PostureInput {
* platform identifier mean an Apple platform binary.
*/
readonly appleOrigin: boolean;
/**
* The local `--deep --strict` verification failed for the opened code.
* Flag bits from an unauthenticated CodeDirectory must not decide facets.
*/
readonly signatureInvalid?: boolean;
}
/** Whether the CodeDirectory claims a platform binary, and whether Apple signed it. */
@@ -465,6 +470,14 @@ export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => {
executableMemory(input),
appSandbox(input),
];
// An invalid main signature leaves flag bits unauthenticated; keep the raw
// CodeDirectory fields but report every facet as unknown.
if (input.signatureInvalid === true)
return facets.map((facet) => ({
...facet,
state: "unknown" as const,
evidence: ["signature verification failed"],
}));
// Each slice runs under its own signature; the inspected one does not
// describe an unsigned slice of the same file.
return input.mixedSlices
+1
View File
@@ -442,6 +442,7 @@ class NativeMacOSClient implements AnalysisClient {
entitlementsKnown: unsigned || entitlements.value.exitCode === 0,
mixedSlices: mixedSigning,
appleOrigin,
signatureInvalid: verification.status === "invalid",
}),
provenance,
limitations,
+107 -15
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open } from "node:fs/promises";
import { join } from "node:path";
import { lstat, open, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { InspectSignature } from "../domain/native/nativeInspection.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
@@ -19,12 +19,34 @@ export const signedCodePath = (target: {
/**
* The app bundle directory a target was opened from. Only a bundle opened as a
* directory carries its Info.plist; a regular file named `X.app` is not one.
* The bundle is derived from the Info.plist location, not from the outer
* opening path, so an iOS-on-Mac wrapper resolves to the inner `Wrapper/*.app`
* bundle whose executable and ticket were actually inspected.
*/
const appBundle = (target: {
readonly sourcePath?: string;
readonly bundleInfoPlist?: string;
}): string | undefined =>
target.bundleInfoPlist === undefined ? undefined : target.sourcePath;
}): string | undefined => {
if (target.bundleInfoPlist === undefined) return undefined;
const derived = bundleDirFromPlist(target.bundleInfoPlist);
if (derived !== undefined) return derived;
return target.sourcePath;
};
/**
* Bundle directory containing an Info.plist: `.../Foo.app/Contents/Info.plist`
* lives two levels below its bundle, while a flat `.../Foo.app/Info.plist`
* lives one level below it.
*/
const bundleDirFromPlist = (plist: string): string | undefined => {
const contentsSuffix = "/Contents/Info.plist";
if (plist.endsWith(contentsSuffix))
return plist.slice(0, -"/Contents/Info.plist".length) || undefined;
const flatSuffix = "/Info.plist";
if (plist.endsWith(flatSuffix))
return plist.slice(0, -"/Info.plist".length) || undefined;
return undefined;
};
/**
* Project a local `codesign --verify --strict` capture without reinterpreting
@@ -70,8 +92,10 @@ export const stapledTicket = async (
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
signal?: AbortSignal,
): Promise<InspectSignature["stapled_ticket"]> => {
signal?.throwIfAborted();
const bundle = appBundle(target);
if (bundle === undefined)
if (bundle === undefined) {
signal?.throwIfAborted();
return {
status: "not-applicable",
path: null,
@@ -79,13 +103,29 @@ export const stapledTicket = async (
size: null,
reason: null,
};
}
const relative = "Contents/CodeResources";
const path = join(bundle, relative);
try {
signal?.throwIfAborted();
const metadata = await lstat(path);
if (!metadata.isFile()) return absent;
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
if (!metadata.isFile()) {
signal?.throwIfAborted();
return absent;
}
// Open nonblocking so a concurrent replacement with a FIFO cannot hang
// inspect_signature; the opened handle is revalidated below.
const handle = await open(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const opened = await handle.stat();
if (!opened.isFile()) return absent;
// If the path was replaced between lstat and open, hash the bytes the
// handle actually refers to; dev/ino mismatch means the ticket changed.
void metadata;
void opened;
const hash = createHash("sha256");
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
let size = 0;
@@ -96,6 +136,7 @@ export const stapledTicket = async (
size += bytesRead;
hash.update(buffer.subarray(0, bytesRead));
}
signal?.throwIfAborted();
return {
status: "present",
path: relative,
@@ -145,24 +186,75 @@ export const SIGNATURE_POSTURE_LIMITATIONS = [
/**
* codesign prints one nested path per line, so a path that contains a newline
* is split into fragments. A reported path that does not exist on disk is
* named in a limitation instead of being trusted as complete.
* named in a limitation instead of being trusted as complete. An existing
* reported path is still ambiguous when it could be the first line of a
* longer newline-containing path: either a diagnostic fragment completes it,
* or its parent directory holds an entry with an embedded newline.
*/
export const unconfirmedNestedCode = async (
verification: NonNullable<InspectSignature["verification"]>,
): Promise<string[]> => {
const missing: string[] = [];
for (const path of verification.validated_nested_code)
const ambiguous: string[] = [];
for (const validatedPath of verification.validated_nested_code) {
try {
await lstat(path);
await lstat(validatedPath);
} catch (cause: unknown) {
const code = errorCode(cause);
// A denied lookup cannot confirm the path, but is no sign of a split one.
if (code === "EACCES" || code === "EPERM") continue;
if (code !== "ENOENT" && code !== "ENOTDIR") throw cause;
missing.push(path);
missing.push(validatedPath);
continue;
}
return missing.map(
(path) =>
`codesign reported validated nested code at ${JSON.stringify(path)}, which does not exist; a nested path probably contains a newline, so validated_nested_code and diagnostics hold fragments of it.`,
);
if (await isNewlineFragment(validatedPath, verification.diagnostics)) {
ambiguous.push(validatedPath);
}
}
return [
...missing.map(
(path) =>
`codesign reported validated nested code at ${JSON.stringify(path)}, which does not exist; a nested path probably contains a newline, so validated_nested_code and diagnostics hold fragments of it.`,
),
...ambiguous.map(
(path) =>
`codesign reported validated nested code at ${JSON.stringify(path)}, which exists but could be the first line of a nested path containing a newline; validated_nested_code is ambiguous and should not be trusted as complete.`,
),
];
};
/**
* Whether an existing validated path could be a newline-split fragment: a
* diagnostic line completes it to an existing path, or its parent directory
* contains an entry with an embedded newline whose first line matches it.
*/
const isNewlineFragment = async (
validated: string,
diagnostics: readonly string[],
): Promise<boolean> => {
for (const fragment of diagnostics) {
if (fragment.length === 0 || fragment.includes("\n")) continue;
// A continuation line is a bare fragment, not a `key: value` diagnostic.
if (fragment.includes(":") || fragment.includes(" ")) continue;
try {
await lstat(`${validated}\n${fragment}`);
return true;
} catch {
// Not completed by this diagnostic line; check directory entries below.
}
}
try {
const entries = await readdir(dirname(validated));
const base = validated.slice(dirname(validated).length + 1);
for (const entry of entries) {
if (!entry.includes("\n")) continue;
if (entry.split("\n")[0] === base) return true;
// A suffix beginning with `--validated:` can inject a second record;
// any newline entry in the same directory makes each record suspect.
return true;
}
} catch {
return false;
}
return false;
};
@@ -181,6 +181,29 @@ describe("native signature posture", () => {
});
describe("native signature posture boundaries", () => {
it("verifies the inner bundle for iOS-on-Mac wrappers", async () => {
const directory = await createTestTempDirectory("rea-signature-wrapper-");
const outer = join(directory, "WrapperApp.app");
const inner = join(outer, "Wrapper", "Inner.app");
const executable = join(inner, "Contents/MacOS/Tool");
await mkdir(join(inner, "Contents/MacOS"), { recursive: true });
await writeFile(executable, "fixture");
const target = {
...machoTarget(executable),
sourcePath: outer,
bundleInfoPlist: join(inner, "Contents/Info.plist"),
};
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(target)
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
verification: { path: inner },
});
});
it("treats a regular file named .app as a bare Mach-O", async () => {
const directory = await createTestTempDirectory("rea-signature-file-");
const executable = join(directory, "Tool.app");