fix(ida): supervise regex inventory matching (#4862)

This commit is contained in:
JG Tromp authored and GitHub committed 2026-10-23 02:27:36 +08:00
1 parent e3a00bb5f9
commit db8556e724
3 files changed
+170 -11

No files matched your search

+31 -7
View File
@@ -9,7 +9,12 @@ import {
import {
analysisSearchPredicate,
analysisSearchQuerySchema,
type AnalysisSearchQuery,
} from "../domain/analysisSearch.js";
import {
matchRegexValues,
REGEX_MATCHING_LIMITATION,
} from "../process/RegexMatcher.js";
import {
AnalysisInputError,
AnalysisCancelledError,
@@ -584,6 +589,29 @@ export class IdaOperationRunner {
};
}
private async searchItems<T extends { readonly value: string }>(
operation: IdaOperation,
items: T[],
query: AnalysisSearchQuery | undefined,
): Promise<T[]> {
if (query === undefined) return items;
if (query.mode === "literal") {
const matches = analysisSearchPredicate(query);
return items.filter(({ value }) => matches(value));
}
this.limitations.push(REGEX_MATCHING_LIMITATION);
const matched = await matchRegexValues({
operation,
pattern: query.pattern,
caseSensitive: query.case_sensitive,
values: items.map(({ value }) => value),
signal: this.options.signal,
});
if (!matched.ok) throw matched.error;
const indices = new Set(matched.value);
return items.filter((_, index) => indices.has(index));
}
async run(
operation: IdaOperation,
parameters: Readonly<Record<string, JsonValue>>,
@@ -592,16 +620,12 @@ export class IdaOperationRunner {
operation === "search_procedures" || operation === "search_strings"
? analysisSearchQuerySchema.parse(parameters)
: undefined;
const matches =
query === undefined ? undefined : analysisSearchPredicate(query);
if (operation === "list_procedures" || operation === "search_procedures") {
const items = (await this.functions()).map((fn) => ({
address: fn.address,
value: fn.name,
}));
return matches === undefined
? items
: items.filter(({ value }) => matches(value));
return this.searchItems(operation, items, query);
}
if (operation === "list_strings" || operation === "search_strings") {
const nativePattern =
@@ -609,8 +633,8 @@ export class IdaOperationRunner {
? nativeLiteralSearchPattern(query.pattern, query.case_sensitive)
: undefined;
const items = await this.strings(nativePattern);
return matches !== undefined
? items.filter(({ value }) => matches(value))
return query !== undefined
? this.searchItems(operation, items, query)
: parameters.address === undefined
? items
: items.filter(
+10 -4
View File
@@ -2,6 +2,7 @@ import type {
AnalysisOperation,
CapabilityDescriptor,
} from "../application/AnalysisProvider.js";
import { REGEX_MATCHING_LIMITATION } from "../process/RegexMatcher.js";
/** This version identifies REA's adapter, not IDA or the upstream MCP server. */
export const IDA_PROVIDER_IDENTITY = Object.freeze({
@@ -98,10 +99,15 @@ export const idaCapabilities = (
changesPermissions: false,
requiresRoot: false,
},
limitations:
native &&
limitations: [
...IDA_LIMITATIONS,
...(native &&
(operation === "list_strings" || operation === "search_strings")
? [...IDA_LIMITATIONS, IDA_NATIVE_STRING_LIMITATION]
: IDA_LIMITATIONS,
? [IDA_NATIVE_STRING_LIMITATION]
: []),
...(operation === "search_procedures" || operation === "search_strings"
? [REGEX_MATCHING_LIMITATION]
: []),
],
}),
);
+129
View File
@@ -461,6 +461,135 @@ describe("attached IDA function observations", () => {
).toBe(false);
await client.close();
});
});
describe("attached IDA regex supervision", () => {
it.each(["search_procedures", "search_strings"] as const)(
"preserves Unicode case semantics and inventory order in %s",
async (operation) => {
const { producer, client } = await fixture();
const values = ["skip", "ÄPFEL", "äpfel"];
producer.overrideCall = (name) =>
name === "list_functions"
? {
data: values.map((name, index) => ({
address: `0x${String(1000 + index)}`,
name,
size: "0x10",
})),
next_offset: null,
}
: name === "list_strings"
? {
data: values.map((string, index) => ({
address: `0x${String(3000 + index)}`,
length: string.length,
string,
})),
next_offset: null,
}
: undefined;
try {
for (const case_sensitive of [false, true]) {
const found = await client.execute(operation, {
pattern: "^ä",
mode: "regex",
case_sensitive,
});
expect(found.ok && found.value.result).toEqual(
values.flatMap((value, index) =>
index === 0 || (case_sensitive && index === 1)
? []
: [
{
address: `0x${String((operation === "search_strings" ? 3000 : 1000) + index)}`,
value,
},
],
),
);
}
} finally {
await client.close();
}
},
);
it.each(["search_procedures", "search_strings"] as const)(
"cancels CPU-bound %s matching and keeps the session usable",
async (operation) => {
const { producer, client } = await fixture();
const value = "a".repeat(26) + "!";
producer.overrideCall = (name) =>
name === "list_functions"
? {
data: [{ address: "0x1000", name: value, size: "0x10" }],
next_offset: null,
}
: name === "list_strings"
? {
data: [
{ address: "0x3000", length: value.length, string: value },
],
next_offset: null,
}
: undefined;
await client.execute("health", {});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 50);
try {
const result = await client.execute(
operation,
{ pattern: "^(a+)+$", mode: "regex" },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisCancelledError");
const recovered = await client.execute(operation, {
pattern: "!",
mode: "literal",
});
expect(recovered.ok && recovered.value.result).toHaveLength(1);
} finally {
clearTimeout(timer);
await client.close();
}
},
15_000,
);
it("bounds regex matching without caller cancellation and preserves subsequent searches", async () => {
const { producer, client } = await fixture();
const value = "a".repeat(40) + "!";
producer.overrideCall = (name) =>
name === "list_strings"
? {
data: [{ address: "0x3000", length: value.length, string: value }],
next_offset: null,
}
: undefined;
try {
const result = await client.execute("search_strings", {
pattern: "^(a+)+$",
mode: "regex",
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
const recovered = await client.execute("search_strings", {
pattern: "!$",
mode: "regex",
});
expect(recovered.ok && recovered.value.result).toHaveLength(1);
expect(recovered.ok && recovered.value.limitations).toContain(
"Regex mode uses ECMAScript Unicode semantics in a cancellable worker with a 5-second matching deadline; matching failure leaves the service available.",
);
} finally {
await client.close();
}
}, 15_000);
});
describe("attached IDA function failure handling", () => {
it("rejects mismatched identities and target switches rather than returning a successful observation", async () => {
const { producer, client, target } = await fixture();
producer.inputSha256 = "a".repeat(64);