diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 382374e70c..a57ab6ec12 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -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( + operation: IdaOperation, + items: T[], + query: AnalysisSearchQuery | undefined, + ): Promise { + 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>, @@ -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( diff --git a/src/ida/IdaProviderCapabilities.ts b/src/ida/IdaProviderCapabilities.ts index 1394310d93..e19e7763d1 100644 --- a/src/ida/IdaProviderCapabilities.ts +++ b/src/ida/IdaProviderCapabilities.ts @@ -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] + : []), + ], }), ); diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index b9e34485e1..2b0e6fb835 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -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);