fix(cli): reject empty native and managed targets before resolution (#7956)
Local file commands in the core binary, native, managed and native-tool CLI groups declared their positional target as a plain string. An empty argument therefore reached path admission, where it resolved to the working directory, as docs/cli.md says it never should. Declare those targets with the shared nonempty, NUL-free local path schema already used by analyze, so the CLI rejects them as a VALIDATION_ERROR naming the field before any analysis runner is called. Explicit "." is unchanged. Fixes #7436 Fixes #7404
This commit is contained in:
5 files changed
+174
-26
No files matched your search
@@ -174,7 +174,9 @@ const registerOverviewCommands = (
|
||||
cli.command(CLI_COMMANDS.inspect, {
|
||||
description: "Inspect an app overview with evidence",
|
||||
args: z.object({
|
||||
path: z.string().describe("App, program, or analysis database path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"App, program, or analysis database path",
|
||||
),
|
||||
}),
|
||||
options: overviewOptions,
|
||||
run: ({ args, options }) =>
|
||||
@@ -197,7 +199,7 @@ const registerDecompileCommand = (
|
||||
cli.command(CLI_COMMANDS.decompile, {
|
||||
description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail.`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -290,7 +292,7 @@ const registerXrefsCommand = (
|
||||
cli.command(CLI_COMMANDS.xrefs, {
|
||||
description: `List bounded references to an analyzed address. ${namedAddressGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -335,7 +337,7 @@ const registerTraceCommand = (
|
||||
cli.command(CLI_COMMANDS.trace, {
|
||||
description: `Trace a literal feature through analyzed references. ${querySelectorGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -385,7 +387,7 @@ const registerFunctionCommand = (
|
||||
cli.command(CLI_COMMANDS.function, {
|
||||
description: `Analyze one complete function with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -444,7 +446,7 @@ const registerInstructionsCommand = (
|
||||
cli.command(CLI_COMMANDS.instructions, {
|
||||
description: `Read every raw instruction without decompiling. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -489,7 +491,7 @@ const registerSearchCommand = (
|
||||
cli.command(CLI_COMMANDS.search, {
|
||||
description: `Search every analyzed string or procedure name. ${patternSelectorGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MAX_BYTE_SEARCH_MATCHES,
|
||||
} from "../domain/native/bytePattern.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
|
||||
import {
|
||||
addressOrProcedureGuidance,
|
||||
@@ -47,7 +48,7 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.searchBytes, {
|
||||
description: "Find masked byte patterns in initialized provider memory",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
pattern: bytePatternFields.pattern,
|
||||
}),
|
||||
options: z.object({
|
||||
@@ -103,7 +104,9 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
|
||||
description:
|
||||
"Inspect native load observations and supported snapshot verification",
|
||||
args: z.object({ path: z.string().describe("Local executable path") }),
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
}),
|
||||
options: z.object({
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
...nativeOpenOptionFields,
|
||||
@@ -133,7 +136,7 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.readBytes, {
|
||||
description: "Read exact provider memory bytes at one analysis address",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
address: z.string().describe("Exact provider memory address"),
|
||||
}),
|
||||
options: z.object({
|
||||
@@ -158,7 +161,7 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.addressToFileOffset, {
|
||||
description: "Resolve an analysis address to its original file byte offset",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
address: z.string().describe("Exact provider memory address"),
|
||||
}),
|
||||
options: z.object({
|
||||
@@ -177,7 +180,9 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.traceNativeValues, {
|
||||
description: "Trace bounded native def-use and call dependencies",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local native executable or app path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Local native executable or app path",
|
||||
),
|
||||
procedure: nativeValueTraceInputSchema.shape.procedure.describe(
|
||||
"Explicit native procedure symbol or address",
|
||||
),
|
||||
@@ -287,7 +292,9 @@ export const registerCoreNativeCommands = (
|
||||
? "Inspect one native instruction with decoded operand facts"
|
||||
: "Resolve one static native call site",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local native executable or app path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Local native executable or app path",
|
||||
),
|
||||
address: z.string().describe("Exact native address"),
|
||||
}),
|
||||
options: z.object({
|
||||
@@ -317,7 +324,9 @@ const registerNativeDispatchMetadataCommand = (
|
||||
cli.command(CLI_COMMANDS.inspectNativeDispatchMetadata, {
|
||||
description: "Inspect typed Objective-C and Swift dispatch metadata",
|
||||
args: z.object({
|
||||
path: z.string().describe("Target path used to bind the result evidence"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Target path used to bind the result evidence",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
maxRecords: nativeDispatchMetadataInputSchema.shape.max_records.describe(
|
||||
@@ -355,7 +364,9 @@ const registerNativeUiActionCommand = (
|
||||
description:
|
||||
"Trace a compiled UI action selector to its native handler and direct callees",
|
||||
args: z.object({
|
||||
path: z.string().describe("Target path used to bind the result evidence"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Target path used to bind the result evidence",
|
||||
),
|
||||
action: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -412,7 +423,7 @@ const registerNativeApiCommand = (
|
||||
cli.command(CLI_COMMANDS.inspectNativeApi, {
|
||||
description: `Reconstruct one native function API boundary with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`,
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
path: localPathStringSchema.describe("App or program path"),
|
||||
address: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -455,7 +466,7 @@ const registerAnnotationCommand = (
|
||||
cli.command(CLI_COMMANDS.annotateNativeFunction, {
|
||||
description: "Edit function annotations and return refreshed analysis",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
path: localPathStringSchema.describe("Local executable path"),
|
||||
procedure: z
|
||||
.string()
|
||||
.describe("Exact function entry address or unique name"),
|
||||
|
||||
@@ -41,7 +41,9 @@ const registerInspectManagedArtifact = (
|
||||
cli.command(CLI_COMMANDS.inspectManagedArtifact, {
|
||||
description: "Inspect PE/CLI identity without loading target code",
|
||||
args: z.object({
|
||||
path: z.string().describe("Managed PE executable or assembly path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Managed PE executable or assembly path",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "inspect-managed-artifact", () =>
|
||||
@@ -64,7 +66,9 @@ const registerInspectManagedMembers = (
|
||||
description:
|
||||
"Inspect PE/CLI metadata members, signatures, CIL anchors, and ldstr string literals without loading target code",
|
||||
args: z.object({
|
||||
path: z.string().describe("Managed PE executable or assembly path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Managed PE executable or assembly path",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
type: managedMembersInputSchema.shape.type_names.describe(
|
||||
@@ -111,7 +115,9 @@ const registerInspectManagedNativeBoundaries = (
|
||||
description:
|
||||
"Inspect PE/CLI PInvoke and native implementation boundary declarations without loading target code",
|
||||
args: z.object({
|
||||
path: z.string().describe("Managed PE executable or assembly path"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Managed PE executable or assembly path",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "inspect-managed-native-boundaries", () =>
|
||||
@@ -126,8 +132,12 @@ const registerInspectManagedNativeBoundaries = (
|
||||
};
|
||||
|
||||
const compareManagedMemberArgs = z.object({
|
||||
leftPath: z.string().describe("Baseline managed PE executable or assembly"),
|
||||
rightPath: z.string().describe("Candidate managed PE executable or assembly"),
|
||||
leftPath: localPathStringSchema.describe(
|
||||
"Baseline managed PE executable or assembly",
|
||||
),
|
||||
rightPath: localPathStringSchema.describe(
|
||||
"Candidate managed PE executable or assembly",
|
||||
),
|
||||
});
|
||||
|
||||
const registerCompareManagedMembers = (
|
||||
|
||||
@@ -24,6 +24,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { isCliOperationFailure, logCliCommand } from "../cliLogging.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { swiftSymbolsSchema } from "../contracts/native/nativeToolContracts.js";
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { registerNativeCallCommands } from "./nativeCallCommands.js";
|
||||
@@ -96,7 +97,7 @@ const registerNativeUiCommands = (
|
||||
runProviderAnalysis: DirectAnalysis["runProviderAnalysis"],
|
||||
): void => {
|
||||
const pathArgument = z.object({
|
||||
path: z.string().describe("Local native executable or app path"),
|
||||
path: localPathStringSchema.describe("Local native executable or app path"),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.observeNativeUi, {
|
||||
description:
|
||||
@@ -198,7 +199,9 @@ const registerNativeCommands = (
|
||||
] as const) {
|
||||
cli.command(command, {
|
||||
description: `Run ${tool} without launching Hopper`,
|
||||
args: z.object({ path: z.string().describe("Mach-O or app path") }),
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("Mach-O or app path"),
|
||||
}),
|
||||
options: z.object({
|
||||
nativeCommandTimeoutMs: nativeCommandTimeoutField,
|
||||
}),
|
||||
@@ -217,7 +220,9 @@ const registerNativeCommands = (
|
||||
}
|
||||
cli.command(CLI_COMMANDS.inspectPlist, {
|
||||
description: "Parse app plist metadata without launching Hopper",
|
||||
args: z.object({ path: z.string().describe("App or Mach-O path") }),
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe("App or Mach-O path"),
|
||||
}),
|
||||
options: z.object({
|
||||
relativePath: z
|
||||
.string()
|
||||
@@ -247,7 +252,9 @@ const registerNativeCommands = (
|
||||
cli.command(CLI_COMMANDS.demangleSwift, {
|
||||
description: "Demangle Swift symbols without Hopper",
|
||||
args: z.object({
|
||||
path: z.string().describe("Artifact path used for evidence identity"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Artifact path used for evidence identity",
|
||||
),
|
||||
symbols: swiftSymbolsSchema.describe("Swift mangled symbols to demangle"),
|
||||
}),
|
||||
options: z.object({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Cli } from "incur";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js";
|
||||
import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js";
|
||||
import { registerManagedCommands } from "../../../src/cli/managedCommands.js";
|
||||
import { registerUtilityCommands } from "../../../src/cli/utilityCommands.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
|
||||
const registry = () => {
|
||||
const failure = {
|
||||
ok: false as const,
|
||||
error: { code: "invalid_request", message: "Admitted target control" },
|
||||
};
|
||||
const run = () =>
|
||||
vi.fn(async (_path: string, ..._rest: unknown[]) => failure);
|
||||
const runDirectAnalysis = run();
|
||||
const runProviderAnalysis = run();
|
||||
const cli = Cli.create("empty-target-admission", { sync: false });
|
||||
registerCoreBinaryCommands(cli, silentLogger, runDirectAnalysis, {});
|
||||
registerCoreNativeCommands(cli, silentLogger, runDirectAnalysis);
|
||||
registerManagedCommands(cli, silentLogger, runProviderAnalysis);
|
||||
registerUtilityCommands(
|
||||
cli,
|
||||
silentLogger,
|
||||
{},
|
||||
{
|
||||
runProviderAnalysis,
|
||||
runSessionStatus: async () => ({ ok: false, error: null }),
|
||||
},
|
||||
);
|
||||
const serve = async (argv: readonly string[]): Promise<unknown> => {
|
||||
let stdout = "";
|
||||
await cli.serve([...argv, "--json"], {
|
||||
env: {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => undefined,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
return { serve, runs: [runDirectAnalysis, runProviderAnalysis] };
|
||||
};
|
||||
|
||||
// Each command with its other required arguments; the target comes first.
|
||||
const commands: readonly (readonly string[])[] = [
|
||||
["inspect"],
|
||||
["decompile", "main"],
|
||||
["xrefs", "0x1000"],
|
||||
["trace", "feature"],
|
||||
["function", "0x1000"],
|
||||
["instructions", "0x1000"],
|
||||
["search", "name"],
|
||||
["search-bytes", "00"],
|
||||
["inspect-native-load-image"],
|
||||
["read-bytes", "0x1000"],
|
||||
["address-to-file-offset", "0x1000"],
|
||||
["trace-native-values", "main"],
|
||||
["inspect-native-instruction", "0x1000"],
|
||||
["resolve-native-call-targets", "0x1000"],
|
||||
["inspect-native-dispatch-metadata"],
|
||||
["trace-native-ui-action", "openDocument:"],
|
||||
["inspect-native-api", "0x1000"],
|
||||
["annotate-native-function", "main", "--name", "entry"],
|
||||
["inspect-managed-artifact"],
|
||||
["inspect-managed-members"],
|
||||
["inspect-managed-native-boundaries"],
|
||||
["observe-native-ui", "--pid", "1", "--window-id", "1"],
|
||||
[
|
||||
"capture-native-ui-scenario",
|
||||
"--pid",
|
||||
"1",
|
||||
"--window-id",
|
||||
"1",
|
||||
"--steps",
|
||||
'[{"kind":"wait","milliseconds":0}]',
|
||||
],
|
||||
["inspect-macho"],
|
||||
["inspect-signature"],
|
||||
["list-architectures"],
|
||||
["inspect-plist"],
|
||||
["demangle-swift", "$s4main"],
|
||||
];
|
||||
|
||||
it.each(commands)(
|
||||
"rejects an empty %s target before resolution and admits explicit .",
|
||||
async (command, ...rest) => {
|
||||
const { serve, runs } = registry();
|
||||
// docs/cli.md: an empty string never selects the working directory.
|
||||
expect(await serve([command, "", ...rest])).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [
|
||||
expect.objectContaining({ path: "path", code: "too_small" }),
|
||||
],
|
||||
});
|
||||
for (const run of runs) expect(run).not.toHaveBeenCalled();
|
||||
await serve([command, ".", ...rest]);
|
||||
expect(runs.flatMap((run) => run.mock.calls.map(([path]) => path))).toEqual(
|
||||
["."],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ argv: ["", "."], field: "leftPath" },
|
||||
{ argv: [".", ""], field: "rightPath" },
|
||||
])(
|
||||
"rejects an empty compare-managed-members $field before reading either input",
|
||||
async ({ argv, field }) => {
|
||||
const { serve } = registry();
|
||||
expect(await serve(["compare-managed-members", ...argv])).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [
|
||||
expect.objectContaining({ path: field, code: "too_small" }),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user