fix(native): project decoded entitlement scalars through plist owners

Consume shared value and exact-number projections before JSON validation
so decoded plist scalars cannot leak as ordinary dictionaries. Carry
projection limitations into signature evidence with entitlement context.

Related: #4906. Controlled command-runner regression passed. Real
codesign refused the authored non-finite entitlement; this does not claim
real entitlement support. Existing real signature CLI controls passed.
This commit is contained in:
morluto committed 2026-10-18 07:48:25 +08:00
1 parent 6c98708793
commit 69324b0f86
2 files changed
+80 -4

No files matched your search

+39 -4
View File
@@ -61,6 +61,14 @@ import {
resolvedAnalysisError,
} from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
projectPlistValue,
nonFiniteRealLimitation,
unknownRealLimitation,
negativeZeroRealLimitation,
unknownDateLimitation,
} from "../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../domain/apple/plistNumbers.js";
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
import {
omittedPrototypeKeysLimitation,
@@ -648,6 +656,7 @@ class NativeMacOSClient implements AnalysisClient {
const captures = [display.value, requirements.value, entitlements.value];
const limitations = [
...parsed.limitations,
...entitlementValue.limitations.map((note) => `Entitlements: ${note}`),
"Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined.",
...(entitlementValue.omittedPrototypeKeys === 0
? []
@@ -1199,14 +1208,40 @@ const invocation = (
const parseEntitlements = (
output: string,
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
): {
readonly value: JsonValue;
readonly omittedPrototypeKeys: number;
readonly limitations: readonly string[];
} => {
const start = output.indexOf("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
if (start < 0 || end < start)
return { value: null, omittedPrototypeKeys: 0, limitations: [] };
const decoded = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
const projected = projectPlistValue(decoded.value);
const numbers = createPlistNumberProjection(decoded.numberLiterals);
const value = numbers.project(projected.value);
return {
value,
omittedPrototypeKeys: decoded.omittedPrototypeKeys,
limitations: [
...numbers.limitations(),
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...(projected.unknownRealCount === 0
? []
: [unknownRealLimitation(projected.unknownRealCount)]),
...(projected.negativeZeroRealCount === 0
? []
: [negativeZeroRealLimitation(projected.negativeZeroRealCount)]),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
],
};
};
/** plutil prefixes its diagnostic with the path the message already names. */
@@ -74,6 +74,47 @@ describe("native signature target identity", () => {
});
});
it("projects typed plist scalars from a controlled entitlement response", async () => {
const { path, target } = await fixture();
const replay = new NativeFixtureRunner();
const xml = `<?xml version="1.0"?><plist><dict>
<key>positive</key><real>+infinity</real>
<key>negative</key><real>-inf</real>
<key>nan</key><real>nan</real>
<key>integer</key><integer>9007199254740993</integer>
<key>date</key><date>2001-01-01T00:00:00Z</date>
<key>data</key><data>AQID</data><key>control</key><true/>
</dict></plist>`;
const runner: NativeCommandRunner = {
run(tool, args) {
return args.includes("--entitlements")
? new NativeFixtureRunner({ codesign: xml }).run(tool, args)
: replay.run(tool, args);
},
};
const result = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(target)
.execute("inspect_signature", {});
if (!result.ok) throw result.error;
const signature = inspectSignatureSchema.parse(result.value.result);
expect(signature.entitlements).toEqual({
positive: { $plist_type: "real", value: "+infinity" },
negative: { $plist_type: "real", value: "-infinity" },
nan: { $plist_type: "real", value: "nan" },
integer: { $plist_type: "integer", decimal: "9007199254740993" },
date: { $plist_type: "date", iso8601: "2001-01-01T00:00:00.000Z" },
data: { $plist_type: "data", base64: "AQID" },
control: true,
});
expect(signature.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("Entitlements: 3 non-finite real value(s)"),
expect.stringContaining("unambiguous integer value(s)"),
]),
);
expect(await readFile(path, "utf8")).toBe(CONTENT);
});
it.each(["changed", "missing", "directory", "symlink"] as const)(
"rejects a %s target before commands run",
async (replacement) => {