From 9f5dd56e2f2da18d16f17abd594f108ca43e5e4f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:46:42 +0800 Subject: [PATCH] fix(native): validate XML plist source admission --- docs/native-investigation.md | 7 +- src/native/NativeMacOSProvider.ts | 70 +++++--- .../nativePlistSelectionFailureCli.test.ts | 165 +++++++++++++++--- 3 files changed, 194 insertions(+), 48 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index fe2a1d5b48..4dfa9609c4 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -464,9 +464,10 @@ numbers requiring XML type facts retain the `plutil` route. Invalid UTF-8 and encoding-declaration mismatches remain explicit failures. Direct-source provenance records `file` without claiming conversion; conversion limitations and invocations appear only when conversion actually ran. -Malformed XML structure remains an input failure; malformed scalar literals -retain their exact source text, reason and counted unknown-scalar limitation, -using the same scalar interpretation as archive readers. +XML sources must also pass native format validation, with native refusals retaining +their diagnostics and successful validation recorded in provenance. Original +unknown scalar text, reasons and limitations survive successful validation; +output-budget omissions remain omissions. Source input is limited to 64 MiB. Binary and direct XML decoding reserve an estimated 64 MiB decode budget before parsing; this is not a hard process-memory diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 76aa576078..9e08cbbd79 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1090,8 +1090,31 @@ class NativeMacOSClient implements AnalysisClient { const source = await readNativePlistBytes(fileInput); if (!source.ok) return source; let parsed: ReturnType; + const provenance = [ + invocation(classified, fileInput.selection.path, "$PLIST"), + ]; if (format === "xml") { parsed = parsePlistXmlSource(source.value); + if (parsed.ok) { + const validated = await this.#run( + "inspect_plist", + "plutil", + ["-lint", "--", fileInput.selection.path], + { + signal: fileInput.signal, + acceptNonZero: true, + timeoutMs: fileInput.timeoutMs, + }, + ); + if (!validated.ok) return validated; + if (validated.value.exitCode !== 0) + return err( + nativePlistDecodeFailure(fileInput.selection, validated.value), + ); + provenance.push( + invocation(validated.value, fileInput.selection.path, "$PLIST"), + ); + } if ( !parsed.ok && parsed.error instanceof XmlPlistTextDecodeError && @@ -1125,9 +1148,7 @@ class NativeMacOSClient implements AnalysisClient { format, path: fileInput.selection.path, resourceRoot: this.bundleResourceRoot, - provenance: [ - invocation(classified, fileInput.selection.path, "$PLIST"), - ], + provenance, }), ); } @@ -1168,24 +1189,7 @@ class NativeMacOSClient implements AnalysisClient { if (!converted.ok) return converted; xml = converted.value; if (xml.exitCode !== 0) { - const capture = failureCapture(xml, false); - return err( - unreadablePlist( - fileInput.selection, - `plutil could not decode it as a property list (${plutilDiagnostic(capture, path)})`, - { - capturedOutput: { - stdout: capture.stdout, - stderr: capture.stderr, - truncated: capture.truncated, - stdout_bytes: capture.stdoutBytes, - stderr_bytes: capture.stderrBytes, - exit_code: capture.exitCode, - signal: capture.signal, - }, - }, - ), - ); + return err(nativePlistDecodeFailure(fileInput.selection, xml)); } parsed = jsonAttempt === undefined @@ -1560,7 +1564,29 @@ const parseEntitlements = ( }; }; -/** Failed plutil conversions can report on either stream; preserve both. */ +/** Native admission failures preserve the selected/default source and physical command output. */ +const nativePlistDecodeFailure = ( + selection: NativePlistSelection, + command: NativeCommandCapture, +): AnalysisError => { + const capture = failureCapture(command, false); + return unreadablePlist( + selection, + `plutil could not decode it as a property list (${plutilDiagnostic(capture, selection.path)})`, + { + capturedOutput: { + stdout: capture.stdout, + stderr: capture.stderr, + truncated: capture.truncated, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + exit_code: capture.exitCode, + signal: capture.signal, + }, + }, + ); +}; + const plutilDiagnostic = ( capture: Pick, path: string, diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index 2b9e9c088a..5caa814b8e 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -148,12 +148,18 @@ describe.skipIf(process.platform !== "darwin")( "native plist CLI decode diagnostics", () => { cliTest.for([ - "not a property list", - "value 42 ", - "valuex", - ])( + ["not a property list", "Property List error:"], + [ + "value 42 ", + "Unknown character", + ], + [ + "valuex", + "Encountered non-empty ", + ], + ] as const)( "preserves native decode diagnostics for %s", - async (source, { cli }) => { + async ([source, diagnostic], { cli }) => { const app = await fixtureApp(); const malformed = join(app, "Contents/Broken.plist"); await writeFile(malformed, source); @@ -171,15 +177,11 @@ describe.skipIf(process.platform !== "darwin")( expect(selected.json).toMatchObject({ code: "invalid_request", details: { - issues: [ - { message: expect.stringContaining("Property List error:") }, - ], + issues: [{ message: expect.stringContaining(diagnostic) }], captured_output: { exit_code: 1 }, }, }); - expect(capturedDiagnostic(selected.json)).toContain( - "Property List error:", - ); + expect(capturedDiagnostic(selected.json)).toContain(diagnostic); const directory = await createTestTempDirectory( "rea-plist-default-diagnostic-", ); @@ -196,13 +198,11 @@ describe.skipIf(process.platform !== "darwin")( expect(fallback.json).toMatchObject({ code: "capability_unavailable", details: { - reason: expect.stringContaining("Property List error:"), + reason: expect.stringContaining(diagnostic), captured_output: { exit_code: 1 }, }, }); - expect(capturedDiagnostic(fallback.json)).toContain( - "Property List error:", - ); + expect(capturedDiagnostic(fallback.json)).toContain(diagnostic); expect(await readFile(malformed, "utf8")).toBe(source); expect(await readFile(defaultPlist, "utf8")).toBe(source); }, @@ -214,11 +214,27 @@ describe.skipIf(process.platform !== "darwin")( "native plist CLI direct target selection", () => { cliTest.for([ - "value 42 ", - "valuex", - ])( + [ + "value 42 ", + "Unknown character", + ], + [ + "valuex", + "Encountered non-empty ", + ], + [ + "__proto__x", + "Encountered non-empty ", + ], + ["", "Encountered unknown tag null"], + ["", "Encountered empty plist tag"], + [ + "12", + "plist can only include one object", + ], + ] as const)( "classifies the selected malformed plist as input: %s", - async (source, { cli }) => { + async ([source, diagnostic], { cli }) => { const root = await createTestTempDirectory("rea-plist-target-input-"); const path = join(root, "chosen.plist"); await writeFile(path, source); @@ -234,15 +250,13 @@ describe.skipIf(process.platform !== "darwin")( issues: [ { path: ["path"], - message: expect.stringContaining("Property List error:"), + message: expect.stringContaining(diagnostic), }, ], captured_output: { exit_code: 1 }, }, }); - expect(capturedDiagnostic(result.json)).toContain( - "Property List error:", - ); + expect(capturedDiagnostic(result.json)).toContain(diagnostic); } expect(await readFile(path, "utf8")).toBe(source); }, @@ -250,6 +264,111 @@ describe.skipIf(process.platform !== "darwin")( }, ); +describe.skipIf(process.platform !== "darwin")( + "native plist CLI source admission", + () => { + cliTest( + "accepts native nested plist syntax without imposing an XML grammar allowlist", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-plist-nested-admission-", + ); + const path = join(root, "chosen.plist"); + const source = "1"; + await writeFile(path, source); + const result = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + source_path: path, + provenance: expect.arrayContaining([ + expect.objectContaining({ + tool: "plutil", + command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"], + }), + ]), + }, + }); + expect(await readFile(path, "utf8")).toBe(source); + }, + ); + cliTest( + "omits a valid prototype key after native validation", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-valid-omission-"); + const path = join(root, "chosen.plist"); + const source = + "__proto__42keptvalue"; + await writeFile(path, source); + const result = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + value: { kept: "value" }, + provenance: [ + expect.objectContaining({ tool: "file" }), + expect.objectContaining({ + tool: "plutil", + command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"], + }), + ], + limitations: expect.arrayContaining([ + expect.stringContaining( + "1 dictionary entry keyed __proto__ was omitted", + ), + ]), + }, + }); + expect(await readFile(path, "utf8")).toBe(source); + }, + ); + cliTest( + "retains original unknown scalar facts after successful native validation", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-plist-source-admission-", + ); + const path = join(root, "chosen.plist"); + const source = + "value1e999"; + await writeFile(path, source); + const result = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + format: "xml", + source_path: path, + value: { + value: { + $plist_type: "unknown", + element: "real", + literal: "1e999", + reason: "unrepresentable", + }, + }, + provenance: expect.arrayContaining([ + expect.objectContaining({ + tool: "plutil", + command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"], + }), + ]), + }, + }); + expect(await readFile(path, "utf8")).toBe(source); + }, + ); + }, +); + describe.skipIf(process.platform !== "darwin")( "native plist CLI fixed allowances", () => {