fix(providers)!: preserve readiness, coverage, and diagnostic ownership

Share tagged readiness observations, retain partial provider facts, authenticate bridge identity, bound diagnostics, and preserve cancellation and lost-session reasons. Fail closed for unparsed MIPS ABI attributes and macOS saved Hopper databases without a script-capable bridge route.

Reviewed fixes: #2010, #2108, #2513, #2695, #2731, #2771, #2828, #2867, #2868, #2870, #2880, #2919, #2945, #2961, #2981, #2998, #3000, #3002.

Validation: 285 focused source/boundary tests passed. Real Ghidra, IDA, Linux Hopper, and Windows provider runs were not repeated.

BREAKING CHANGE: MIPS targets with unresolved GNU ABI attributes and saved Hopper databases on macOS are refused rather than advertised as supported.
This commit is contained in:
morluto committed 2026-10-14 08:28:14 +08:00
1 parent ec56c1da04
commit d4bd65c53c
80 files changed
+6696 -634

No files matched your search

+455 -119
View File
@@ -5,9 +5,11 @@ import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketTimeoutException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.Channels;
@@ -81,6 +83,7 @@ import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.listing.Listing;
import ghidra.program.model.listing.Variable;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
@@ -297,7 +300,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
Files.move(pendingPath, endpointPath);
}
}
acceptClient(server, descriptor);
acceptAuthenticatedLoopbackClient(server, descriptor);
}
finally {
Files.deleteIfExists(pendingPath);
@@ -309,6 +312,50 @@ public final class ReaGhidraBridge extends HeadlessScript {
return System.getProperty("os.name", "").startsWith("Windows");
}
private static String readLoopbackHandshake(
SocketChannel client, int maxBytes, long timeoutMillis) throws IOException {
long deadline = System.nanoTime() + timeoutMillis * 1_000_000L;
InputStream input = client.socket().getInputStream();
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
while (true) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) throw new SocketTimeoutException("Bridge authentication timed out");
client.socket().setSoTimeout((int) Math.max(1,
Math.min(Integer.MAX_VALUE, (remaining + 999_999L) / 1_000_000L)));
int value = input.read();
if (value == -1) return null;
if (value == '\n') return bytes.toString(StandardCharsets.UTF_8);
if (bytes.size() == maxBytes) throw new IOException("Bridge authentication frame is too large");
bytes.write(value);
}
}
private void acceptAuthenticatedLoopbackClient(
ServerSocketChannel server, SessionDescriptor descriptor) throws Exception {
while (true) {
try (SocketChannel client = server.accept()) {
Request first;
try {
String line = readLoopbackHandshake(client, 64 * 1024, 1_000);
if (line == null) continue;
first = parseRequest(line, descriptor.token);
}
catch (IOException | RuntimeException rejected) {
// A rejected unauthenticated peer cannot consume the bridge session.
continue;
}
client.socket().setSoTimeout(0);
try (BufferedReader reader = new BufferedReader(
Channels.newReader(client, StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(
Channels.newWriter(client, StandardCharsets.UTF_8))) {
serveClient(descriptor, reader, writer, first);
}
return;
}
}
}
private void acceptClient(
ServerSocketChannel server,
SessionDescriptor descriptor) throws Exception {
@@ -319,26 +366,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
BufferedWriter writer = new BufferedWriter(
Channels.newWriter(client, StandardCharsets.UTF_8)
)) {
serveClient(descriptor, reader, writer);
serveClient(descriptor, reader, writer, null);
}
}
private void serveClient(
SessionDescriptor descriptor,
BufferedReader reader,
BufferedWriter writer) throws Exception {
BufferedWriter writer,
Request first) throws Exception {
while (true) {
String line = reader.readLine();
if (line == null) {
return;
}
Request request;
try {
request = parseRequest(line, descriptor.token);
}
catch (RuntimeException exception) {
writeFailure(writer, 1, "invalid_request", "Bridge request is invalid");
return;
Request request = first;
first = null;
if (request == null) {
String line = reader.readLine();
if (line == null) return;
try {
request = parseRequest(line, descriptor.token);
}
catch (RuntimeException exception) {
writeFailure(writer, 1, "invalid_request", "Bridge request is invalid");
return;
}
}
try {
if (request.method.equals("shutdown")) {
@@ -405,7 +454,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
case "xrefs" -> xrefs(request.params);
case "analyze_function" -> {
FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params);
yield functionObservation(observation.dossier(), observation.limitations());
JsonObject result = functionObservation(observation.dossier(), observation.limitations());
result.add("relationship_coverage", observation.relationshipCoverage());
yield result;
}
case "set_address_name", "set_addresses_names" -> {
if (!descriptor.transport.equals("unix-socket"))
@@ -884,6 +935,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonObject result = functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
@@ -975,7 +1027,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
return functionObservation(
GSON.toJsonTree(renderAssembly(scan.instructions)),
noReturnCallLimitations(scan.instructions)
noReturnCallLimitations(function, scan.instructions)
);
}
@@ -994,7 +1046,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"The fast path does not invoke the decompiler or scan whole-program names and strings."
);
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
List<String> boundaryLimitations = noReturnCallLimitations(function, scan.instructions);
for (String limitation : boundaryLimitations) limitations.add(limitation);
JsonObject result = new JsonObject();
result.add("procedure", procedureIdentity(function));
@@ -1016,12 +1068,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint()));
}
return functionObservation(
result,
noReturnCallLimitations(
if (callers) {
JsonObject observation = functionObservation(result, noReturnCallLimitations(function,
currentProgram.getListing().getInstructions(function.getBody(), true)
)
));
observation.add("relationship_coverage", callRelationshipCoverage(null,
unrepresentedIncomingCalls(function, observed)));
monitor.checkCancelled();
return observation;
}
CallBoundaryObservation calls = callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), observed, false, null
);
return callRelationshipObservation(result, calls);
}
private JsonObject procedureInfo(JsonObject params) throws Exception {
@@ -1041,6 +1100,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return functionObservation(
result,
noReturnCallLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
@@ -1071,18 +1131,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("direction", direction);
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) {
if (!instruction.getFlowType().isCall()) continue;
boolean resolved = false;
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
if (!resolved) {
JsonObject site = new JsonObject();
site.addProperty("address", canonicalAddress(instruction.getAddress()));
site.addProperty("reason", "Instruction decoder identifies a call but ReferenceManager supplies no call destination");
unresolvedCalls.add(site);
}
}
JsonArray unresolvedCalls = direction.equals("outgoing")
? callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
).unresolvedCalls() : new JsonArray();
result.add("unresolved_calls", unresolvedCalls);
return result;
}
@@ -1490,16 +1542,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
DecompileResults decompilation = decompile(function);
String pseudocode = decompiledText(decompilation);
List<Reference> incomingReferences = collectReferences(function, "incoming")
.stream()
JsonObject availability = new JsonObject();
availability.add("pseudocode", facetAvailability(
pseudocode != null,
function.isExternal() ? "Ghidra does not decompile external functions."
: function.getBody().isEmpty() ? "Ghidra has no function body to decompile."
: "Ghidra completed decompilation without returning pseudocode."
));
if (pseudocode == null) {
pseudocode = "";
}
List<Reference> bodyIncoming = collectReferences(function, "incoming");
List<Reference> incomingReferences = bodyIncoming.stream()
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
.toList();
List<Reference> outgoingReferences = collectReferences(function, "outgoing");
JsonArray incoming = referenceEdges(incomingReferences);
JsonArray outgoing = referenceEdges(outgoingReferences);
JsonArray comments = comments(scan.instructions);
JsonArray callers = procedureIdentities(function.getCallingFunctions(monitor));
JsonArray callees = procedureIdentities(function.getCalledFunctions(monitor));
JsonArray comments = comments(function);
Set<Function> callerFunctions = function.getCallingFunctions(monitor);
JsonArray callers = procedureIdentities(callerFunctions);
Set<Function> calleeFunctions = function.getCalledFunctions(monitor);
JsonArray callees = procedureIdentities(calleeFunctions);
JsonArray referencedStrings = referencedStrings(outgoingReferences);
JsonArray referencedNames = referencedNames(outgoingReferences);
JsonArray blocks = basicBlockValues(function);
@@ -1511,7 +1575,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
procedure.add("locals", functionLocals(function));
JsonObject result = new JsonObject();
result.add("procedure", procedure);
result.addProperty("pseudocode", pseudocode == null ? "" : pseudocode);
result.addProperty("pseudocode", pseudocode);
result.add("assembly", assembly);
result.add("comments", comments);
result.add("callers", callers);
@@ -1520,20 +1584,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("outgoing_references", outgoing);
result.add("referenced_strings", referencedStrings);
result.add("referenced_names", referencedNames);
JsonObject facets = new JsonObject();
JsonObject pseudocodeAvailability = new JsonObject();
pseudocodeAvailability.addProperty("available", pseudocode != null);
if (pseudocode == null) {
pseudocodeAvailability.addProperty("reason", "Ghidra did not supply pseudocode.");
}
facets.add("pseudocode", pseudocodeAvailability);
for (String facet : List.of("assembly", "comments", "callers", "callees",
"references", "strings_names", "cfg")) {
JsonObject available = new JsonObject();
available.addProperty("available", true);
facets.add(facet, available);
}
result.add("facet_availability", facets);
boolean swift = swiftProgram();
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
@@ -1547,6 +1597,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
nativeApiBoundary(function, decompilation)
);
result.add("native_value_flow", nativeValueFlow(decompilation));
// Successful full queries distinguish collected empty sets from missing facts.
// Availability does not claim that Ghidra resolved every runtime relationship.
for (String facet : List.of("comments", "callers", "callees", "references", "strings_names", "cfg")) {
availability.add(facet, facetAvailability(true, null));
}
availability.add("assembly", facetAvailability(
!scan.truncated(), "The function instruction scan was truncated."
));
result.add("facet_availability", availability);
JsonArray limitations = new JsonArray();
limitations.add(
"Unresolved computed or indirect flows without target addresses are not represented as reference edges."
@@ -1562,7 +1621,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
if (swift && aarch64) {
limitations.add(
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores. Literals stored one word at a time, chosen by conditional selects, or built at runtime are not reported; candidate pairs at unresolved jumps are retained as unknown omissions."
);
if (swiftLiterals.omissions().size() > 0) {
limitations.add(
@@ -1574,8 +1633,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
);
}
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
for (String limitation : boundaryLimitations) {
CallBoundaryObservation calls = callBoundaryObservation(
function, scan.instructions, calleeFunctions, true, outgoingReferences);
result.add("unresolved_calls", calls.unresolvedCalls());
for (String limitation : calls.limitations()) {
limitations.add(limitation);
}
if (decompilation != null && decompilation.getErrorMessage() != null &&
@@ -1583,7 +1644,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
}
result.add("limitations", limitations);
return new FunctionAnalysisObservation(result, boundaryLimitations);
monitor.checkCancelled();
int missingCallers = 0;
for (Reference reference : bodyIncoming) {
monitor.checkCancelled();
if (unrepresentedCaller(reference, callerFunctions)) missingCallers++;
}
return new FunctionAnalysisObservation(result, calls.limitations(),
callRelationshipCoverage(calls, missingCallers));
}
private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
@@ -3287,54 +3355,276 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private List<String> noReturnCallLimitations(Iterable<Instruction> instructions)
private static JsonObject facetAvailability(boolean available, String reason) {
JsonObject result = new JsonObject();
result.addProperty("available", available);
if (!available) result.addProperty("reason", reason);
return result;
}
private JsonObject callRelationshipObservation(JsonElement value, CallBoundaryObservation calls)
throws Exception {
monitor.checkCancelled();
JsonObject result = functionObservation(value, calls.limitations());
result.add("relationship_coverage", callRelationshipCoverage(calls, null));
return result;
}
private static JsonObject callRelationshipCoverage(
CallBoundaryObservation calls, Integer missingCallers) {
JsonObject coverage = completeResolvedCallCoverage();
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
JsonArray directions = new JsonArray();
if (missingCallers != null && missingCallers > 0) directions.add("callers");
String callerLimitation = missingCallers != null && missingCallers > 0
? "Ghidra's caller inventory omits sources of " + missingCallers +
" call reference(s) into this function's body." : null;
if ((calls != null && omitted == null) || (calls == null && missingCallers == null)) {
coverage.addProperty("status", "unknown");
String reason = calls == null ? "No call inventory was examined."
: "The callee inventory was not examined alongside decoded call targets.";
coverage.addProperty("reason", reason + (callerLimitation == null ? "" : " " + callerLimitation));
if (calls == null && missingCallers == null) directions.add("callers");
directions.add("callees");
coverage.add("limited_directions", directions);
return coverage;
}
JsonArray limitations = new JsonArray();
if (callerLimitation != null) limitations.add(callerLimitation);
if (omitted != null && omitted > 0) {
limitations.add("Ghidra's callee inventory omits targets of " + omitted +
" call reference(s) or decoded call(s) without destinations.");
directions.add("callees");
}
if (!limitations.isEmpty()) {
coverage.addProperty("status", "partial");
coverage.add("limitations", limitations);
coverage.add("limited_directions", directions);
}
return coverage;
}
private static JsonObject completeResolvedCallCoverage() {
JsonObject coverage = new JsonObject();
coverage.addProperty("status", "complete");
JsonArray diagnostics = new JsonArray();
diagnostics.add(
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals."
);
coverage.add("diagnostics", diagnostics);
return coverage;
}
private boolean unrepresentedCaller(Reference reference, Set<Function> callers) {
if (!reference.getReferenceType().isCall()) return false;
Function caller = containingFunction(reference.getFromAddress());
return caller == null || !callers.contains(caller);
}
private int unrepresentedIncomingCalls(Function function, Set<Function> callers)
throws Exception {
int[] omitted = { 0 };
visitReferences(function, "incoming", reference -> {
if (unrepresentedCaller(reference, callers)) omitted[0]++;
});
return omitted[0];
}
private List<String> noReturnCallLimitations(
Function function, Iterable<Instruction> instructions)
throws Exception {
List<String> limitations = new ArrayList<>();
Instruction beforePrevious = null;
Instruction previous = null;
for (Instruction instruction : instructions) {
monitor.checkCancelled();
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) {
continue;
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
if (seh != null) limitations.add(seh);
}
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
.append(canonicalAddress(instruction.getAddress()));
if (destinations.length == 0) {
limitation.append(" with no resolved callee; its no-return basis is unknown.");
}
else {
limitation.append(" to ");
for (int index = 0; index < destinations.length; index++) {
if (index > 0) limitation.append(", ");
Address destination = destinations[index];
limitation.append(canonicalAddress(destination));
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
if (target == null) {
limitation.append(" (callee identity unknown)");
}
else {
limitation.append(" ").append(procedureName(target));
limitation.append(" (external=").append(target.isExternal());
limitation.append(", thunk=").append(target.isThunk());
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
}
}
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
}
}
limitations.add(limitation.toString());
beforePrevious = previous;
previous = instruction;
}
return limitations;
}
private CallBoundaryObservation callBoundaryObservation(
Function function, Iterable<Instruction> instructions, Set<Function> returnedCallees,
boolean includeUnresolvedSites, List<Reference> observedReferences)
throws Exception {
List<String> limitations = new ArrayList<>();
CallBoundaryReferences calls = new CallBoundaryReferences(returnedCallees, includeUnresolvedSites);
Instruction beforePrevious = null;
Instruction previous = null;
if (observedReferences == null) visitReferences(function, "outgoing", calls::observe);
else for (Reference reference : observedReferences) {
monitor.checkCancelled();
calls.observe(reference);
}
for (Instruction instruction : instructions) {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
if (seh != null) limitations.add(seh);
}
beforePrevious = previous;
previous = instruction;
if (!instruction.getFlowType().isCall()) continue;
boolean destination = false;
for (Reference reference : instruction.getReferencesFrom()) {
if (!reference.getReferenceType().isCall()) continue;
destination = true;
break;
}
if (!destination) {
calls.unrepresented++;
calls.unresolved(instruction.getAddress(),
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
}
}
return new CallBoundaryObservation(List.copyOf(limitations),
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
}
private String borlandSehHandlerLimitation(
Function function, Instruction handlerPush, Instruction framePush,
Instruction frameLink) {
if (!immediatelyPrecedes(handlerPush, framePush) ||
!immediatelyPrecedes(framePush, frameLink) ||
!handlerPush.getMnemonicString().equalsIgnoreCase("PUSH") ||
handlerPush.getNumOperands() != 1 ||
!framePush.getMnemonicString().equalsIgnoreCase("PUSH") ||
framePush.getNumOperands() != 1 ||
!frameLink.getMnemonicString().equalsIgnoreCase("MOV") ||
frameLink.getNumOperands() != 2 ||
!sameFsMemoryOperand(framePush, frameLink) ||
!hasRegisterOperand(frameLink, 1, "ESP"))
return null;
Object[] handlerOperand = handlerPush.getOpObjects(0);
if (handlerOperand.length != 1 || !(handlerOperand[0] instanceof Scalar scalar) ||
scalar.bitLength() != 32)
return null;
Address handler = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress(scalar.getUnsignedValue());
if (function.getBody().contains(handler)) return null;
return "Ghidra Listing shows a Borland/Delphi-style SEH frame registration at " +
canonicalAddress(frameLink.getAddress()) + " (push " + canonicalAddress(handler) +
" followed by fs-segment stack chaining); handler " + canonicalAddress(handler) +
" lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
}
private static boolean immediatelyPrecedes(Instruction first, Instruction second) {
Address fallThrough = first.getFallThrough();
return fallThrough != null && fallThrough.equals(second.getAddress());
}
private static boolean sameFsMemoryOperand(Instruction first, Instruction second) {
String firstOperand = normalizedFsMemoryOperand(first);
String secondOperand = normalizedFsMemoryOperand(second);
return firstOperand != null && firstOperand.equalsIgnoreCase(secondOperand);
}
private static String normalizedFsMemoryOperand(Instruction instruction) {
String representation = instruction.getDefaultOperandRepresentation(0);
representation = representation.replaceAll("(?i)\\b(?:byte|word|dword|qword)\\s+ptr\\s+", "")
.replaceAll("\\s+", "");
return representation.regionMatches(true, 0, "fs:", 0, 3) &&
representation.indexOf('[') >= 0 ? representation : null;
}
private static boolean hasRegisterOperand(
Instruction instruction, int operand, String name) {
for (Object object : instruction.getOpObjects(operand)) {
if (object instanceof Register register && register.getName().equalsIgnoreCase(name))
return true;
}
return false;
}
private final class CallBoundaryReferences {
private final Set<Function> returnedCallees;
private final TreeMap<Address, String> unresolved;
private int unrepresented;
CallBoundaryReferences(Set<Function> returnedCallees, boolean includeUnresolvedSites) {
this.returnedCallees = returnedCallees;
unresolved = includeUnresolvedSites ? new TreeMap<>() : null;
}
void observe(Reference reference) {
if (!reference.getReferenceType().isCall()) return;
// Reference identity follows the same containing-function owner as referenceEdge.
Function target = containingFunction(reference.getToAddress());
if (returnedCallees != null && (target == null || !returnedCallees.contains(target)))
unrepresented++;
if (target == null) unresolved(reference.getFromAddress(),
"A call destination has no known containing Ghidra procedure");
}
void unresolved(Address address, String reason) {
if (unresolved != null) unresolved.put(address, reason);
}
JsonArray unresolvedCalls() {
if (unresolved == null) return null;
JsonArray result = new JsonArray();
for (var item : unresolved.entrySet()) {
JsonObject site = new JsonObject();
site.addProperty("address", canonicalAddress(item.getKey()));
site.addProperty("reason", item.getValue());
result.add(site);
}
return result;
}
}
private String terminalCallLimitation(Instruction instruction) {
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal())
return null;
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
.append(canonicalAddress(instruction.getAddress()));
if (destinations.length == 0) {
limitation.append(" with no resolved callee; its no-return basis is unknown.");
}
else {
limitation.append(" to ");
for (int index = 0; index < destinations.length; index++) {
if (index > 0) limitation.append(", ");
Address destination = destinations[index];
limitation.append(canonicalAddress(destination));
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
if (target == null) {
limitation.append(" (callee identity unknown)");
}
else {
limitation.append(" ").append(procedureName(target));
limitation.append(" (external=").append(target.isExternal());
limitation.append(", thunk=").append(target.isThunk());
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
}
}
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
}
}
return limitation.toString();
}
private static String renderAssembly(List<Instruction> instructions) {
return String.join("\n", renderAssemblyLines(instructions));
}
@@ -3358,15 +3648,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<Reference> collectReferences(
Function function,
String direction) throws Exception {
TreeMap<String, Reference> observed = new TreeMap<>();
visitReferences(function, direction, reference -> addReference(observed, reference));
List<Reference> result = new ArrayList<>(observed.values());
result.sort(REFERENCE_ORDER);
return result;
}
@FunctionalInterface
private interface ReferenceVisitor {
void accept(Reference reference) throws Exception;
}
private void visitReferences(Function function, String direction, ReferenceVisitor visitor)
throws Exception {
// References can target instruction interiors or originate in embedded
// data. Use actual body ownership, including an external's exact entry.
AddressSet scope = new AddressSet(function.getBody());
scope.add(function.getEntryPoint());
AddressSet scope = functionObservationScope(function);
boolean outgoing = direction.equals("outgoing");
AddressIterator addresses = outgoing
? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true)
: currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true);
TreeMap<String, Reference> observed = new TreeMap<>();
while (addresses.hasNext()) {
monitor.checkCancelled();
Address address = addresses.next();
@@ -3374,7 +3676,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Reference reference : currentProgram.getReferenceManager()
.getReferencesFrom(address)) {
monitor.checkCancelled();
addReference(observed, reference);
if (!reference.isEntryPointReference()) visitor.accept(reference);
}
}
else {
@@ -3382,19 +3684,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
.getReferencesTo(address);
while (iterator.hasNext()) {
monitor.checkCancelled();
addReference(observed, iterator.next());
Reference reference = iterator.next();
if (!reference.isEntryPointReference()) visitor.accept(reference);
}
}
}
List<Reference> result = new ArrayList<>(observed.values());
result.sort(REFERENCE_ORDER);
return result;
}
private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
if (reference.isEntryPointReference()) {
return;
}
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
canonicalAddress(reference.getToAddress()) + "\u0000" +
reference.getReferenceType().getName() + "\u0000" +
@@ -3403,6 +3700,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
observed.putIfAbsent(key, reference);
}
private static AddressSet functionObservationScope(Function function) {
AddressSet scope = new AddressSet(function.getBody());
scope.add(function.getEntryPoint());
return scope;
}
private JsonArray referenceEdges(List<Reference> references) {
JsonArray result = new JsonArray();
for (Reference reference : references) {
@@ -3556,23 +3859,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private static JsonArray comments(List<Instruction> instructions) {
private JsonArray comments(Function function) throws Exception {
Listing listing = currentProgram.getListing();
AddressSet scope = functionObservationScope(function);
AddressIterator addresses = listing.getCommentAddressIterator(scope, true);
TreeMap<String, JsonObject> observed = new TreeMap<>();
for (Instruction instruction : instructions) {
while (addresses.hasNext()) {
monitor.checkCancelled();
Address address = addresses.next();
for (CommentType type : CommentType.values()) {
String text = instruction.getComment(type);
String text = listing.getComment(type, address);
if (text == null || text.isEmpty()) {
continue;
}
String kind = type == CommentType.EOL ? "inline" : "comment";
JsonObject item = new JsonObject();
item.addProperty("address", canonicalAddress(instruction.getAddress()));
item.addProperty("address", canonicalAddress(address));
item.addProperty("kind", kind);
item.addProperty("text", text);
String key = canonicalAddress(instruction.getAddress()) + "\u0000" +
String key = canonicalAddress(address) + "\u0000" +
kind + "\u0000" + type.name() + "\u0000" + text;
observed.put(key, item);
}
}
}
JsonArray result = new JsonArray();
observed.values().forEach(result::add);
@@ -3643,13 +3951,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
* The canonical empty string is a small literal with a zero count.
*/
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
return swiftStringLiterals(function, new SwiftLiteralBudget());
}
// An explicit budget makes capacity omissions deterministic in producer tests.
private SwiftStringLiteralDecode swiftStringLiterals(
Function function,
SwiftLiteralBudget budget
) throws Exception {
Register[] registers = new Register[31];
for (int index = 0; index < registers.length; index++) {
registers[index] = currentProgram.getRegister("x" + index);
}
TreeMap<String, JsonObject> observed = new TreeMap<>();
TreeMap<String, JsonObject> omitted = new TreeMap<>();
SwiftLiteralBudget budget = new SwiftLiteralBudget();
Map<String, Boolean> selectedValues = new HashMap<>();
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
@@ -3657,6 +3972,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
// Returning true ends only the current flow, so every queued flow checks the shared stop.
if (budget.stopped()) return true;
FlowType flow = instruction.getFlowType();
Address[] flowTargets = instruction.getFlows();
boolean unresolvedJump = flow.isJump() && !flow.isConditional() && flowTargets.length == 0;
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
Long word = registerConstant(context, pair[0]);
Long object = registerConstant(context, pair[1]);
@@ -3706,12 +4024,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonObject item = overCapacity
? swiftStringOmission(layout, word, object)
: swiftStringItem(value, layout, word, object);
if (unresolvedJump && !overCapacity)
item = swiftStringOmission(layout, word, object, "unresolved_jump");
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
JsonArray pairRegisters = new JsonArray();
pairRegisters.add(pair[0].getName());
pairRegisters.add(pair[1].getName());
item.add("registers", pairRegisters);
(overCapacity ? omitted : observed).put(key, item);
(overCapacity || unresolvedJump ? omitted : observed).put(key, item);
if (overCapacity) {
budget.stop();
return true;
@@ -3747,11 +4067,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
}
// A jump is a proven tail call only when it has resolved targets and all leave the
// body. A targetless computed jump can still dispatch inside the function.
// body. For unresolved targetless jumps, candidate literals remain unknown omissions.
Address[] targets = instruction.getFlows();
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
if (!flow.isCall() && !returns && !tailCall) return pairs;
boolean unresolvedJump = flow.isJump() && !flow.isConditional() && targets.length == 0;
if (!flow.isCall() && !returns && !tailCall && !unresolvedJump) return pairs;
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
int lastFirstRegister = returns ? 0 : 6;
for (int index = 0; index <= lastFirstRegister; index++) {
@@ -3894,6 +4215,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
}
SwiftLiteralBudget(long remaining) {
if (remaining < 0) throw new IllegalArgumentException("Swift literal budget must be nonnegative");
this.remaining = remaining;
}
boolean takeState() {
if (remaining < BYTES_PER_RECORD) return false;
remaining -= BYTES_PER_RECORD;
@@ -3932,6 +4258,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
return swiftStringOmission(layout, word, object, "capacity");
}
private static JsonObject swiftStringOmission(
SwiftLiteralLayout layout,
long word,
long object,
String reason
) {
JsonObject item = new JsonObject();
item.addProperty("representation", layout.representation());
if (layout.address() == null) {
@@ -3940,7 +4275,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
item.addProperty("address", canonicalAddress(layout.address()));
}
item.addProperty("byte_length", layout.byteLength());
item.addProperty("reason", "capacity");
item.addProperty("reason", reason);
item.add("words", swiftStringWords(word, object));
return item;
}
@@ -4508,7 +4843,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record InventoryItem(Address address, String value, JsonObject facts) {}
private record AddressNameRequest(String inputAddress, Address address, String name) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
private record SwiftLiteralLayout(
String representation,
+6 -1
View File
@@ -736,7 +736,12 @@ def _analyze_function(document, params):
def _dispatch(method, params):
"""Dispatch only the closed operation set implemented by REA's public tools."""
if method == "health":
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
return {
"name": "REA Hopper bridge",
"version": "1.0.0",
"run_id": REA_RUN_ID,
"process_id": os.getpid(),
}
if method in ("shutdown", "shutdown_document"):
document = _session_document()
if document is None:
+40 -28
View File
@@ -73,32 +73,32 @@ existing analysis families directly, for example
## Operations
| Tool | Kind | What it does |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inspect_adb_client` | read | Resolved binary, version, install path; never contacts the server |
| `list_adb_devices` | read | Serial, state, transport, model metadata, inferred emulator/physical kind with basis |
| `inspect_adb_device` | read | Fixed `getprop` whitelist: build, SDK, patch, fingerprint, product, ABIs, debuggable, `ro.kernel.qemu` |
| `inspect_adb_display` | read | `wm size` and `wm density` as declared |
| `list_adb_features` | read | `pm list features` (hardware/software features, hex GL versions) |
| `list_adb_services` | read | `service list` binder services and interfaces |
| `list_adb_processes` | read | `ps -A`: user, pid, ppid, rss, state, name |
| `inspect_adb_window` | read | `dumpsys window` focus declarations (what is on screen) |
| `read_adb_setting` | read | One `settings get` value; device-side null is reported as such |
| `read_adb_logcat` | read | Bounded `logcat -d -t` dump for one buffer, optional pid filter |
| `list_adb_packages` | read | `pm list packages -f` with scope; base APK device paths |
| `resolve_adb_packages` | read | Case-insensitive package-name substring search with exact-match flag |
| `inspect_adb_package` | read | `dumpsys package` projection: versions, install times, installer, flags, permission count |
| `list_adb_directory` | read | NUL-framed immediate children, including hidden entries: kinds, permissions, owner, group, sizes, symlink targets |
| `pull_adb_package` | read | Complete split APK set from `pm path`, each file digested |
| `pull_adb_file` | read | One absolute device path pulled and digested |
| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions |
| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested |
| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); refuses existing targets without explicit overwrite; verifies via the device's own sha256sum |
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict |
| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 |
| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) |
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
| Tool | Kind | What it does |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inspect_adb_client` | read | Resolved binary, version, install path; never contacts the server |
| `list_adb_devices` | read | Serial, state, transport, model metadata, inferred emulator/physical kind with basis |
| `inspect_adb_device` | read | Fixed `getprop` whitelist: build, SDK, patch, fingerprint, product, ABIs, debuggable, `ro.kernel.qemu` |
| `inspect_adb_display` | read | `wm size` and `wm density` as declared |
| `list_adb_features` | read | `pm list features` (hardware/software features, hex GL versions) |
| `list_adb_services` | read | `service list` binder services and interfaces |
| `list_adb_processes` | read | `ps -A`: user, pid, ppid, rss, state, name |
| `inspect_adb_window` | read | `dumpsys window` focus declarations (what is on screen) |
| `read_adb_setting` | read | One `settings get` value; device-side null is reported as such |
| `read_adb_logcat` | read | Bounded `logcat -d -t` dump for one buffer, optional pid filter |
| `list_adb_packages` | read | `pm list packages -f` with scope; base APK device paths |
| `resolve_adb_packages` | read | Case-insensitive package-name substring search with exact-match flag |
| `inspect_adb_package` | read | `dumpsys package` projection: versions, install times, installer, flags, permission count |
| `list_adb_directory` | read | NUL-framed immediate children, including hidden entries: kinds, permissions, owner, group, sizes, symlink targets |
| `pull_adb_package` | read | Complete split APK set from `pm path`, each file digested |
| `pull_adb_file` | read | One absolute device path pulled and digested |
| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions |
| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested |
| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); checks for existing targets and refuses them without explicit overwrite (the check is not atomic); compares digests when device `sha256sum` is available |
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict |
| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 |
| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) |
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
## Interpret the result
@@ -118,6 +118,16 @@ existing analysis families directly, for example
while POSIX hosts retain valid POSIX filenames such as names with spaces or
colons. A package pull checks reported names against the destination
filesystem before pulling, and refuses names that would share a local path.
- **Package path refusals are diagnosable.** Refusals from `pull_adb_package`
return `unreadable_output` with `retryable: false`, keep the `pm path` output
in `details.captured_output`, and name the refusal in `details.reason`.
`unsafe_device_path` lists refused entries in
`details.diagnostics.rejected_paths` as `{device_path, reason}`;
`duplicate_local_destination` reports the shared `file_name` and both
`device_paths`; `local_destination_unavailable` reports the `device_path`,
`file_name`, and filesystem `error_code`. Diagnostics retain raw device
strings, while the caller-facing message escapes invisible and bidirectional
characters so they cannot hide text or reorder the path.
- **Splits are part of the answer.** `pm path` enumerates the base APK and
every installed split; pulls cover the whole set. System packages whose
APKs keep other names report the `unknown` role rather than guessing.
@@ -127,8 +137,10 @@ existing analysis families directly, for example
partial coverage instead of dropping rows.
- **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and
`uninstall_adb_package` declare `mutatesTarget` and never run implicitly:
pushes refuse existing device files unless `overwrite` is set and verify
the transfer with the device's own `sha256sum`; installs preserve the
pushes check for existing device files before transfer and refuse them
unless `overwrite` is set. The check is not atomic, so a different process
can create the path before `adb push` writes it. When available, the device's
`sha256sum` verifies the transfer; installs preserve the
device's `Failure [...]` reason verbatim.
- **Producer identity stays attached.** Every result embeds the producing
adb client (`binary_path`, `path_source`, `version`, `installed_path`).
+9 -7
View File
@@ -52,13 +52,15 @@ section names or the record being inside the initial 4 KiB header probe.
Program and section declarations must agree; duplicate, conflicting, truncated
or out-of-file records are rejected. Stripped section tables and section-only
records are supported. Extended table numbering is explicitly unsupported.
For carved ELFs whose section table extends beyond the captured file, REA
examines every complete captured section header and records the unavailable
headers as a limitation. A valid program-header ABI record can still establish
the supported declaration. Available section declarations must agree with it;
their duplicate, conflicting or unreadable records remain errors. A missing
program-header record does not make ABI declarations in uncaptured sections
absent or supply a default ABI.
GNU `SHT_GNU_ATTRIBUTES` sections are refused by numeric section type until REA
parses and reconciles their declarations; section names and payload sizes do not
control detection or allocation. For carved ELFs whose section table extends
beyond the captured file, REA examines every complete captured section header
and records the unavailable headers as a limitation. The provider refuses an
incomplete ABI declaration inspection even when a program-header ABI record is
valid, because an unavailable section header could carry an unsupported or
conflicting declaration. A missing program-header record does not make ABI
declarations in uncaptured sections absent or supply a default ABI.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
+10 -7
View File
@@ -154,12 +154,15 @@ An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. Instruction windows
and dossiers also retain these warnings in their result limitations. An
unresolved callee or unestablished terminal-flow basis remains explicit. These
observations qualify the analysis; they do not prove that a no-return flag is
incorrect or recover excluded code. Analysis profiles version this observation
contract so older snapshots cannot replay unqualified results.
available. On x86-32, the same views also report the exact Borland/Delphi-style
FS exception-registration pattern when its handler address falls outside the
function body. This is a static registration observation; it does not establish
handler execution or change the body. Instruction windows and dossiers also
retain these warnings in their result limitations. An unresolved callee or
unestablished terminal-flow basis remains explicit. These observations qualify
the analysis; they do not prove that a no-return flag is incorrect or recover
excluded code. Analysis profiles version this observation contract so older
snapshots cannot replay unqualified results.
On non-Windows hosts, the ephemeral import repair clears no-return flags on the
known returning external ABI names `malloc`, `__tls_get_addr`, `memcpy`, `memset`,
@@ -189,7 +192,7 @@ host/provider acceptance run with only the host compiler.
`npm run verify:ghidra:windows` checks source-owned native x86-64 PE applications;
`-- --x86` selects x86 and `-- --dll` selects DLLs, and the flags can be combined.
These lanes check all 25 read-only operations, target/snapshot/import SHA-256 linkage,
These lanes check all 26 read-only operations, target/snapshot/import SHA-256 linkage,
and project, endpoint, process, and runtime cleanup. The independent native
lane checks DACLs, handle admission, cancellation, and Job Object lifecycle.
`npm run verify:ghidra:windows:package` packs and installs REA into an isolated
+12 -2
View File
@@ -58,6 +58,12 @@ original stable file snapshot, including pointer-backed strings. It does not
scan arbitrary byte strings as compiler metadata or interpret a fat Mach-O
container as one selected architecture.
For ELF images without a named build-info section, discovery checks each
nonempty writable, non-executable load segment in program-header order. This
includes sectionless PIE images with metadata in a later data segment. Named
sections retain priority, and alignment, file mapping and record validation
remain required.
Historical moduleless Go binaries can store their empty module-string header in
declared zero-initialized virtual memory rather than file-backed bytes. The reader
recognizes this only when the complete optional module header lies in that memory
@@ -78,9 +84,13 @@ host permission denial, changed artifacts and cancellation retain distinct
errors. The selected file is never modified; this inspection creates no
temporary files, starts no subprocesses and accesses no network endpoint.
Resource guards bound the selected file to 256 MiB, aggregate embedded strings
Resource guards bound the selected file and cumulative header searches to 256 MiB
each, aggregate embedded strings
to 1 MiB, structural table decoding to 16 MiB and the complete normalized metadata
report to 8 MiB. Oversized results fail without partial success. MCP also applies
report to 8 MiB. Identical search spans with the same virtual alignment are
searched once; partially overlapping spans consume the cumulative search budget
and fail with a CPU resource constraint when it is exhausted. Oversized results
fail without partial success. MCP also applies
its [response transport budget](mcp-contracts.md), which counts both text and
structured content; a report within the metadata limit can still require export
from retained Evidence. These limits are not operating-system memory or CPU
+2 -2
View File
@@ -137,11 +137,11 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
All profiles expose `list_documents` as the single target admitted for the session. Attached mode verifies its digest against IDA metadata before returning it; headless mode binds the digest-checked private input. `inspect_address_context`, `trace_feature`, and `binary_overview` can therefore resolve that identity without upstream document-listing tools. Segment enumeration is optional: when the selected profile cannot provide it, `binary_overview` still returns document and procedure/string counts and omits `segments` and `segment_count`; omission means unobserved, not empty. The modern headless profile also supports `resolve_containing_procedure`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. Attached profiles do not declare address containment resolution.
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. Whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
`provider.version` identifies the REA adapter; the profile explicitly records `version_scope: "rea-ida-adapter"`. The MCP handshake's `serverInfo` is separate and does not establish IDA or Python distribution versions. Raw observations preserve producer metadata and output. Attached hashes identify the recorded original input; headless identity uses the digest-verified private copy and observed worker input path. Neither proves source-code recovery or an unmodified analysis database.
+14 -1
View File
@@ -19,7 +19,7 @@ additional permission flag or degraded mode is required.
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
- the 26 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
PE load-image inspection returns an explicit unsupported attestation status.
@@ -78,6 +78,19 @@ OS failures. It checks the Ghidra release, headless launcher, full JDK, x64
host, and native controls. A missing or mismatched addon requires the matching
Windows x64 package; changing MCP registration cannot supply that artifact.
A filesystem or access failure identifies the failed native constraint.
An incompatible host uses `unsupported_host`. Missing, mismatched or unloadable
native artifacts use `runtime_missing` and doctor classification
`missing_dependency`. A failed native invocation or invalid inspection uses
`runtime_unavailable` and doctor classification `runtime_failure`; it does not
mean Windows x64 itself is unsupported. Provider diagnostics retain the failure
stage under `windows_native_failure`, together with the corresponding remediation.
Self-test failures preserve the native operation and OS error text. Check the
process's writable local NTFS temporary directory and the reported filesystem,
DACL, Job Object or cleanup constraint, then rerun doctor after resolving it.
Ghidra or Java reinstallation cannot resolve a native self-test failure. The
provider remains unavailable until all native controls are proven; a later query
may retry a failed native invocation, while artifact or malformed-inspection
failures remain stable for the process lifetime.
Other doctor checks, including unconfigured agent integrations, can remain
unready while Ghidra itself is available.
+19
View File
@@ -192,6 +192,25 @@ export const parsePackageListOutput = (
return { packages, unparsedLines };
};
/** Quote raw device paths for prose without allowing bidi or invisible text. */
export const displayDevicePath = (devicePath: string): string => {
let excerpt = "";
let count = 0;
for (const character of devicePath) {
if (count === 160) break;
excerpt += character;
count += 1;
}
const quoted = JSON.stringify(excerpt).replace(
/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\u2800]/gu,
(character) =>
character === " "
? character
: `\\u{${character.codePointAt(0)!.toString(16).toUpperCase()}}`,
);
return excerpt.length === devicePath.length ? quoted : `${quoted} (excerpt)`;
};
/** One APK of an installed package exactly as `pm path` reports it. */
export interface AdbPackagePathEntry {
readonly device_path: string;
+93 -6
View File
@@ -240,11 +240,12 @@ const shellStub = (
): ((root: string) => string) => {
const cases = Object.entries(dispatch)
.map(([key, output]) => {
const [first, second] = key.split(":");
const guard =
second === undefined
? `[ "$4" = "${first}" ]`
: `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`;
const [first, second, third] = key.split(":");
const guard = [
`[ "$4" = "${first}" ]`,
...(second === undefined ? [] : [`[ "$5" = "${second}" ]`]),
...(third === undefined ? [] : [`[ "$6" = "${third}" ]`]),
].join(" && ");
return ` if ${guard}; then printf '%b' '${output.replaceAll("\\", "\\\\").replaceAll("\0", "\\000").replace(/'/gu, "'\\''")}'; exit 0; fi`;
})
.join("\n");
@@ -379,6 +380,30 @@ exit 1`,
expect(result.error.capturedOutput?.stdout).toContain(
pmOutput.split("\n")[0],
);
const projected = projectAnalysisError(result.error);
const duplicate = pmOutput.includes("\n");
expect(projected).toMatchObject({
code: "unreadable_output",
message: expect.stringContaining("pm path for com.example"),
remediation: { action: expect.stringContaining("details.diagnostics") },
details: {
reason: duplicate ? "duplicate_local_destination" : "unsafe_device_path",
diagnostics: {
package_name: "com.example",
...(duplicate
? { device_paths: ["/data/app/x/base.apk", "/data/app/y/base.apk"] }
: {
rejected_paths: [
{
device_path: "/data/app/x/",
reason: "empty_file_name",
},
],
}),
},
captured_output: { stdout: expect.stringContaining("package:") },
},
});
await expect(readFile(join(root, "pull-ran"))).rejects.toThrow();
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -410,7 +435,15 @@ it.each([
if (aliases) {
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("unique local destinations");
expect(projectAnalysisError(result.error)).toMatchObject({
message: expect.stringContaining("unique local destination"),
details: {
reason: "local_destination_unavailable",
diagnostics: {
device_path: `/data/app/y/${secondName}`,
},
},
});
await expect(readFile(pullLog)).rejects.toThrow();
await expect(lstat(packageDirectory)).rejects.toThrow();
return;
@@ -1015,6 +1048,60 @@ it("inspects the display and window focus", async () => {
);
});
it("reads Android 16 focus declarations from displays when windows omits them", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:window:windows": "Window state has no focus fields\\n",
"dumpsys:window:displays":
"mCurrentFocus=Window{abc u0 com.example/.Main}\nmFocusedApp=ActivityRecord{def u0 com.example/.Main t1}\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(result).toMatchObject({
focused_window: "Window{abc u0 com.example/.Main}",
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
observed_lines: [
"Window{abc u0 com.example/.Main}",
"ActivityRecord{def u0 com.example/.Main t1}",
],
});
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("dumpsys window displays"),
);
});
it("describes missing window focus as unknown after both sections omit declarations", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:window:windows": "no focus\\n",
"dumpsys:window:displays": "no focus\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(result).toMatchObject({ focused_window: null, focused_app: null });
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("focus is unknown"),
);
});
it("reads one settings value and reports device-side nulls", async () => {
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
const enabled = await evidenceResult(providerOver(binary), {
+101 -18
View File
@@ -56,6 +56,7 @@ import {
type AdbClientIdentity,
} from "./AdbConfiguration.js";
import {
displayDevicePath,
parseAdbDevicesOutput,
parseGetpropOutput,
parsePackageListOutput,
@@ -417,8 +418,12 @@ const removeOwnedOutputDirectory = async (
const reservePackageDestinations = async (
operation: string,
packageName: string,
packageDirectory: string,
entries: readonly { readonly file_name: string }[],
entries: readonly {
readonly file_name: string;
readonly device_path: string;
}[],
capturedOutput: {
readonly stdout: string;
readonly stderr: string;
@@ -427,6 +432,8 @@ const reservePackageDestinations = async (
): Promise<AnalysisOutputError | undefined> => {
const reservations: {
readonly path: string;
readonly file_name: string;
readonly device_path: string;
readonly identity: OutputDirectoryIdentity;
}[] = [];
for (const entry of entries) {
@@ -438,6 +445,8 @@ const reservePackageDestinations = async (
throw new Error(`Could not reserve local filename ${entry.file_name}`);
reservations.push({
path,
file_name: entry.file_name,
device_path: entry.device_path,
identity: {
dev: created.dev,
ino: created.ino,
@@ -445,10 +454,28 @@ const reservePackageDestinations = async (
},
});
} catch (cause: unknown) {
const errorCode =
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: null;
return new AnalysisOutputError(
operation,
`Package Manager paths cannot be written to unique local destinations at ${JSON.stringify(entry.file_name)}; refusing before pulling any files`,
{ cause, capturedOutput },
"local_destination_unavailable",
{
cause,
capturedOutput,
userMessage: `pm path for ${packageName} reported ${displayDevicePath(entry.device_path)}, whose file name ${displayDevicePath(entry.file_name)} cannot be written to a unique local destination (${errorCode ?? "unknown error"}). No APK was pulled.`,
remediationAction:
"Review details.diagnostics and details.captured_output for the refused destination. Correct the output filesystem conflict before retrying; REA does not rename APKs.",
diagnostics: {
package_name: packageName,
device_path: entry.device_path,
file_name: entry.file_name,
error_code: errorCode,
},
},
);
}
}
@@ -467,10 +494,28 @@ const reservePackageDestinations = async (
);
await rmdir(reservation.path);
} catch (cause: unknown) {
const errorCode =
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: null;
return new AnalysisOutputError(
operation,
`Could not release a local filename reservation before pulling: ${reservation.path}`,
{ cause, capturedOutput },
"local_destination_unavailable",
{
cause,
capturedOutput,
userMessage: `Could not release the local filename reservation for ${displayDevicePath(reservation.device_path)} (${errorCode ?? "unknown error"}); no APK was pulled.`,
remediationAction:
"Review details.diagnostics and details.captured_output for the refused destination. Correct the output filesystem conflict before retrying; REA does not rename APKs.",
diagnostics: {
package_name: packageName,
device_path: reservation.device_path,
file_name: reservation.file_name,
error_code: errorCode,
},
},
);
}
}
@@ -1095,14 +1140,27 @@ export class AdbProvider {
);
}
case "inspect_adb_window": {
const ran = await shell(["dumpsys", "window", "windows"], {
let section = "windows";
const ran = await shell(["dumpsys", "window", section], {
timeoutMs: SHELL_TIMEOUT_MS,
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
});
if (!ran.ok) return ran;
const { focusedWindow, focusedApp } = parseWindowFocusOutput(
let { focusedWindow, focusedApp } = parseWindowFocusOutput(
ran.value.stdout,
);
if (focusedWindow === null && focusedApp === null) {
section = "displays";
const fallback = await shell(["dumpsys", "window", section], {
timeoutMs: SHELL_TIMEOUT_MS,
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
});
if (!fallback.ok) return fallback;
({ focusedWindow, focusedApp } = parseWindowFocusOutput(
fallback.value.stdout,
));
}
const declared = focusedWindow !== null || focusedApp !== null;
return ok(
createAnalysisExecution(
{
@@ -1119,6 +1177,9 @@ export class AdbProvider {
{
limitations: [
"Focus values are the window manager's declarations at read time; no interaction occurred",
declared
? `Focus declarations were read from \`dumpsys window ${section}\``
: "Neither `dumpsys window windows` nor `dumpsys window displays` declared mCurrentFocus or mFocusedApp; focus is unknown, not absent",
],
},
),
@@ -1269,14 +1330,27 @@ export class AdbProvider {
stderr: paths.value.stderr,
truncated: false,
};
if (parsed.rejectedPaths.length > 0)
if (parsed.rejectedPaths.length > 0) {
const first = parsed.rejectedPaths[0]!;
const otherCount = parsed.rejectedPaths.length - 1;
return err(
new AnalysisOutputError(
operation,
`Package Manager reported paths that cannot be represented as host files: ${parsed.rejectedPaths.map((entry) => `${JSON.stringify(entry.device_path)} (${entry.reason})`).join(", ")}`,
{ capturedOutput },
),
new AnalysisOutputError(operation, "unsafe_device_path", {
capturedOutput,
userMessage: `pm path for ${packageName} reported an APK path that cannot be written as a file on this host: ${displayDevicePath(first.device_path)} (${first.reason})${otherCount > 0 ? ` and ${String(otherCount)} other path(s)` : ""}. No APK was pulled.`,
remediationAction:
"Review details.diagnostics for the refused device paths and details.captured_output for the retained pm path output. REA does not rename device-reported APK paths; correct the device path policy before retrying.",
diagnostics: {
package_name: packageName,
rejected_paths: parsed.rejectedPaths.map(
({ device_path, reason }) => ({
device_path,
reason,
}),
),
},
}),
);
}
if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0)
return err(
new AnalysisUnsupportedTargetError(
@@ -1296,11 +1370,19 @@ export class AdbProvider {
for (const entry of parsed.paths) {
if (destinationNames.has(entry.file_name))
return err(
new AnalysisOutputError(
operation,
`Package Manager reported multiple paths with the same local destination ${JSON.stringify(entry.file_name)}; refusing before pulling any files`,
{ capturedOutput },
),
new AnalysisOutputError(operation, "duplicate_local_destination", {
capturedOutput,
userMessage: `pm path for ${packageName} reported multiple APK paths with the same local file name ${displayDevicePath(entry.file_name)}; no APK was pulled.`,
remediationAction:
"Review details.diagnostics and details.captured_output for the conflicting device paths. REA does not rename device-reported paths; correct Package Manager's path response before retrying.",
diagnostics: {
package_name: packageName,
file_name: entry.file_name,
device_paths: parsed.paths
.filter((candidate) => candidate.file_name === entry.file_name)
.map((candidate) => candidate.device_path),
},
}),
);
destinationNames.add(entry.file_name);
}
@@ -1395,6 +1477,7 @@ export class AdbProvider {
};
const destinationError = await reservePackageDestinations(
operation,
packageName,
packageDirectory,
parsed.paths,
capturedOutput,
+1 -1
View File
@@ -172,7 +172,7 @@ export const parseWmDensityOutput = (
return { physical, override };
};
/** Parse the focus declarations from `dumpsys window windows`. */
/** Parse focus declarations from `dumpsys window windows` or `displays`. */
export const parseWindowFocusOutput = (
stdout: string,
): {
+158 -8
View File
@@ -1,6 +1,7 @@
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { delimiter, isAbsolute, join, resolve } from "node:path";
import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js";
import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
@@ -11,7 +12,15 @@ import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
import type { DoctorProviderCheck } from "../application/Doctor.js";
import {
JADX_JAR_CONFIGURATION_REMEDIATION,
JADX_JAVA_PROBE_REMEDIATION,
JADX_JAVA_RUNTIME_REMEDIATION,
JADX_JVM_OPTIONS_REMEDIATION,
JADX_UNSUPPORTED_HOST_REMEDIATION,
JADX_WINDOWS_NATIVE_REMEDIATION,
} from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
@@ -30,16 +39,110 @@ type ConfigurationFailureCode =
| "not_configured"
| "executable_missing"
| "runtime_missing"
| "runtime_unavailable"
| "unsupported_host"
| "unsupported_version"
| "version_unresolved"
| "open_options_invalid";
/**
* Failure kinds tagged at the JADX configuration boundary. Consumers (the
* doctor adapter) project the boundary's remediation and classification for
* a kind; they must not reinterpret the phase/setting evidence to derive
* them.
*/
export type JadxFailureKind =
| "windows_native_artifact"
| "windows_native_runtime"
| "java_runtime"
| "java_home"
| "jvm_options"
| "jar"
| "java_probe"
| "unsupported_host";
/** Runtime mirror of JadxFailureKind for validating the loose diagnostics tag. */
export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [
"windows_native_artifact",
"windows_native_runtime",
"java_runtime",
"java_home",
"jvm_options",
"jar",
"java_probe",
"unsupported_host",
];
export interface JadxFailureOutcome {
readonly remediation: string;
readonly classification: DoctorProviderCheck["classification"];
}
/**
* Project the boundary-owned remediation and classification for a failure
* kind. A null kind (missing or corrupt tag) keeps JAR guidance, matching
* the previous fallthrough for unclassified failures.
*/
export const projectJadxFailureOutcome = (
kind: JadxFailureKind | null,
configured: boolean,
): JadxFailureOutcome => {
const drift = configured ? "config_drift" : "missing_analysis_engine";
switch (kind) {
case "windows_native_artifact":
return {
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
classification: "missing_dependency",
};
case "windows_native_runtime":
return {
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
classification: "runtime_failure",
};
case "java_runtime":
return {
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
classification: "missing_dependency",
};
case "java_home":
return {
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
classification: drift,
};
case "jvm_options":
return {
remediation: JADX_JVM_OPTIONS_REMEDIATION,
classification: drift,
};
case "jar":
return {
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
classification: drift,
};
case "java_probe":
return {
remediation: JADX_JAVA_PROBE_REMEDIATION,
classification: drift,
};
case "unsupported_host":
return {
remediation: JADX_UNSUPPORTED_HOST_REMEDIATION,
classification: "unsupported_host",
};
case null:
return {
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
classification: drift,
};
}
};
class JadxConfigurationFailure extends Error {
constructor(
readonly code: ConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>>,
readonly kind: JadxFailureKind,
options?: ErrorOptions,
) {
super(message, options);
@@ -75,7 +178,7 @@ export const inspectJadxAvailability = async (
status: "unavailable",
code: cause.code,
reason: cause.message,
diagnostics: cause.diagnostics,
diagnostics: { ...cause.diagnostics, failure_kind: cause.kind },
};
throw cause;
}
@@ -191,6 +294,7 @@ const requireEngineArchive = async (
archive_error: reason,
maximum_metadata_read_bytes: JAR_METADATA_READ_MAX_BYTES,
},
"jar",
cause,
);
} finally {
@@ -200,6 +304,7 @@ const requireEngineArchive = async (
"unsupported_version",
`REA_JADX_MCP_JAR ${jar} does not contain the required metadata bridge classes: ${[...missing].join(", ")}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
{ jar_path: jar, phase: "jar-inspection", missing_classes: [...missing] },
"jar",
);
};
@@ -207,25 +312,52 @@ const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
if (process.platform === "win32" && process.arch === "x64") {
const loaded = systemWindowsNativeAuthority();
if (loaded.available && loaded.authority.inspection.protocolStdin === true)
return;
if (loaded.available) {
if (loaded.authority.inspection.protocolStdin === true) return;
throw configurationFailure(
"runtime_missing",
"The bundled Windows native artifact lacks owned protocol stdin; use a matching current Windows x64 REA package.",
{
platform: process.platform,
architecture: process.arch,
phase: "process-ownership",
owned_protocol_stdin: false,
},
"windows_native_artifact",
);
}
const readiness = inspectWindowsNativeReadiness(process.platform, loaded);
throw configurationFailure(
"runtime_missing",
loaded.available
? "The bundled Windows native artifact lacks owned protocol stdin; use a matching current Windows x64 REA package."
: loaded.reason,
readiness.available ? "runtime_unavailable" : readiness.code,
readiness.available
? "Windows native readiness disagreed with the native loader result."
: readiness.reason,
{
platform: process.platform,
architecture: process.arch,
phase: "process-ownership",
owned_protocol_stdin: false,
...(readiness.available
? {}
: {
failure_stage: readiness.failureStage,
remediation: readiness.remediation,
}),
},
readiness.available
? "windows_native_runtime"
: readiness.code === "unsupported_host"
? "unsupported_host"
: readiness.code === "runtime_missing"
? "windows_native_artifact"
: "windows_native_runtime",
);
}
throw configurationFailure(
"unsupported_host",
`Android JADX subprocess ownership is unsupported on ${process.platform}/${process.arch}; use Linux, macOS, or Windows x64 with its bundled native controls.`,
{ platform: process.platform },
"unsupported_host",
);
};
@@ -238,6 +370,7 @@ const resolveJar = async (
"not_configured",
JADX_JAR_CONFIGURATION_REMEDIATION,
{ jar_configured: false },
"jar",
);
try {
await access(jar, constants.R_OK);
@@ -246,6 +379,7 @@ const resolveJar = async (
"executable_missing",
`REA_JADX_MCP_JAR is not a regular file: ${jar}`,
{ jar_path: jar },
"jar",
);
return await realpath(jar);
} catch (cause) {
@@ -254,6 +388,7 @@ const resolveJar = async (
"executable_missing",
`Cannot read REA_JADX_MCP_JAR ${jar}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ jar_path: jar },
"jar",
cause,
);
}
@@ -293,6 +428,7 @@ const resolveJava = async (
"runtime_missing",
`Cannot inspect selected Java executable ${candidate}: ${errorText(cause)}`,
{ java_executable: candidate },
"java_runtime",
cause,
);
}
@@ -301,6 +437,7 @@ const resolveJava = async (
"runtime_missing",
"Cannot find java.exe on the selected Windows PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.",
{ java_executable: executable },
"java_runtime",
);
}
if (isAbsolute(environment.JAVA_HOME))
@@ -309,6 +446,7 @@ const resolveJava = async (
"open_options_invalid",
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
{ java_home: environment.JAVA_HOME },
"java_home",
);
};
@@ -324,6 +462,7 @@ const requireJavaHomeExecutable = async (
"runtime_missing",
`JAVA_HOME java is not a regular file: ${java}`,
{ java_executable: java },
"java_runtime",
);
} catch (cause) {
if (cause instanceof JadxConfigurationFailure) throw cause;
@@ -331,6 +470,7 @@ const requireJavaHomeExecutable = async (
"runtime_missing",
`Cannot execute JAVA_HOME java ${java}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ java_executable: java },
"java_runtime",
cause,
);
}
@@ -351,6 +491,7 @@ const resolveJvmArguments = (
"open_options_invalid",
`${name} must be a positive safe integer; received ${value}.`,
{ setting: name, value },
"jvm_options",
);
if (
name === "REA_JADX_ACTIVE_PROCESSOR_COUNT" &&
@@ -360,6 +501,7 @@ const resolveJvmArguments = (
"open_options_invalid",
`${name} exceeds the JVM's signed 32-bit processor-count range.`,
{ setting: name, value },
"jvm_options",
);
result.push(`${prefix}${value}${suffix}`);
}
@@ -405,6 +547,7 @@ const inspectJavaRuntime = async (
failure: "output_limit",
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
},
"java_probe",
cause,
);
if (output.killed)
@@ -416,12 +559,14 @@ const inspectJavaRuntime = async (
failure: "probe_terminated",
deadline_ms: JAVA_MODULE_LISTING_TIMEOUT_MS,
},
"java_probe",
cause,
);
throw configurationFailure(
"runtime_missing",
`Cannot inspect selected Java runtime ${java}: ${output.stderr.trim() || errorText(cause)}. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules.`,
diagnostics,
"java_runtime",
cause,
);
}
@@ -438,6 +583,7 @@ const inspectJavaRuntime = async (
stderr,
output_truncated: false,
},
"java_runtime",
);
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
if (major === undefined)
@@ -453,6 +599,7 @@ const inspectJavaRuntime = async (
stderr,
output_truncated: false,
},
"java_probe",
);
if (Number(major) < 17)
throw configurationFailure(
@@ -467,6 +614,7 @@ const inspectJavaRuntime = async (
stderr,
output_truncated: false,
},
"java_runtime",
);
};
@@ -474,12 +622,14 @@ const configurationFailure = (
code: ConfigurationFailureCode,
message: string,
diagnostics: Readonly<Record<string, JsonValue>>,
kind: JadxFailureKind,
cause?: unknown,
): JadxConfigurationFailure =>
new JadxConfigurationFailure(
code,
message,
diagnostics,
kind,
cause === undefined ? undefined : { cause },
);
+20
View File
@@ -15,6 +15,26 @@ export const JADX_BRIDGE_IDENTITY = Object.freeze({
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp ${JADX_RELEASE.version} JAR. REA does not download or install it.`;
/** Remediation when the selected Java runtime cannot satisfy the JADX bridge. */
export const JADX_JAVA_RUNTIME_REMEDIATION =
"Select a full JDK including jdk.compiler via JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.";
/** Remediation when a JADX JVM resource override is malformed. */
export const JADX_JVM_OPTIONS_REMEDIATION =
"Set REA_JADX_HEAP_MIB to a positive integer (heap size in MiB) and REA_JADX_ACTIVE_PROCESSOR_COUNT to a positive integer within the JVM's signed 32-bit range.";
/** Remediation when the host cannot run the Android JADX subprocess. */
export const JADX_UNSUPPORTED_HOST_REMEDIATION =
"Use Linux, macOS, or Windows x64 with its bundled native controls for Android JADX analysis.";
/** Remediation when the Windows native backend cannot take process ownership. */
export const JADX_WINDOWS_NATIVE_REMEDIATION =
"Use a matching current Windows x64 REA package with its bundled native controls. The native failure above identifies the failed constraint; Java installation or registration changes cannot enable a missing native backend.";
/** Remediation when the Java module-listing probe cannot establish the runtime. */
export const JADX_JAVA_PROBE_REMEDIATION =
"Verify the selected Java launcher with java --list-modules: it must start promptly and emit a concise module listing including a parseable java.base version. REA does not install Java.";
/** Producing engine identity used by Evidence and the generated product catalog. */
export const JADX_PROVIDER_IDENTITY = Object.freeze({
id: "jadx",
+34 -1
View File
@@ -100,10 +100,12 @@ const decodeStub = (
readonly manifestFile?: string;
readonly stringsFile?: string;
readonly stringsXml?: string;
readonly extraValuesDirectories?: readonly string[];
} = {},
): ((root: string) => string) => {
const withStrings = options.withStrings ?? true;
const withGerman = options.withGerman ?? true;
const extraValuesDirectories = options.extraValuesDirectories ?? [];
const stringsXml = options.stringsXml ?? REAL_STRINGS;
const ymlWrite =
options.ymlFile === undefined
@@ -117,6 +119,12 @@ const decodeStub = (
options.stringsFile === undefined
? `printf '%s\\n' '${stringsXml.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"`
: `cp '${options.stringsFile}' "$5/res/values/strings.xml"`;
const qualifierStrings = extraValuesDirectories
.map(
(name) =>
`printf '%s\\n' '<resources><string name="wrong">Wrong</string></resources>' > "$5/res/values-${name}/strings.xml"`,
)
.join("\\n ");
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "d" ]; then
${
@@ -124,9 +132,10 @@ if [ "$1" = "d" ]; then
? `printf 'Exception in thread "main" brut.androlib.AndrolibException: Could not decode the APK\\n' >&2; exit 1;`
: `${ymlWrite}
${manifestWrite}
mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""}
mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""}${extraValuesDirectories.map((name) => ` "$5/res/values-${name}"`).join("")}
${withStrings ? stringsWrite : ""}
${withGerman ? `printf '%s\\n' '${DE_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values-de/strings.xml"` : ""}
${qualifierStrings}
printf 'I: Using Apktool on stub\\n'
exit 0`
}
@@ -254,6 +263,30 @@ it("preserves empty resource strings and reports markup omissions as partial", a
expect(result.coverage).toBe("partial");
});
it("ignores Android resource qualifiers when discovering locales", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ extraValuesDirectories: ["car", "night", "v21"] }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.locales).toEqual(["de"]);
});
it.each(["car", "night", "v21"])(
"rejects %s as a requested locale",
(locale) => {
expect(
apktoolRequestSchema.safeParse({
operation: "decode_android_resources",
input: { path: "/tmp/probe.apk", locale },
}).success,
).toBe(false);
},
);
it("projects one locale's strings on request", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
+5 -1
View File
@@ -25,7 +25,10 @@ import {
createAnalysisExecution,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
import {
androidLocaleQualifierSchema,
type ApktoolRequest,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
@@ -291,6 +294,7 @@ const countDecodedFiles = async (
const match = /^values-(.+)$/u.exec(entry.name);
if (
match !== null &&
androidLocaleQualifierSchema.safeParse(match[1]).success &&
(await access(join(path, "strings.xml")).then(
() => true,
() => false,
+7
View File
@@ -275,6 +275,12 @@ export type ProviderTargetSupport =
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
/** One owner-observed availability/capability snapshot for provider discovery. */
export interface AnalysisProviderReadinessSnapshot {
readonly availability: ProviderAvailability;
readonly capabilities: readonly CapabilityDescriptor[];
}
/** Deep provider candidate discoverable before one target-bound route is chosen. */
export interface AnalysisProviderCandidate extends AnalysisProvider {
resolveAnalysisProfile(
@@ -282,5 +288,6 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
options?: AnalysisProfileResolutionOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
inspectAvailability(): ProviderAvailability;
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
}
+38 -6
View File
@@ -98,6 +98,21 @@ const entry = (
};
describe("capability failure reasons", () => {
it("keeps an unavailable Android runtime distinct from a missing provider", () => {
const inventory = buildCapabilityInventory(status(), {
...enabledPolicy,
androidAnalysisAvailability: {
status: "unavailable",
code: "runtime_unavailable",
reason: "The JADX native self-test failed.",
diagnostics: {},
},
});
expect(
inventory.find(({ name }) => name === "inspect_android_package"),
).toMatchObject({ available: false, reason: "provider_unavailable" });
});
it("admits physical universal Mach-O targets for native Mach-O operations", () => {
expect(
entry(
@@ -501,7 +516,6 @@ it("reports host denial before target mismatch but keeps composed workflow prere
describe("binary_overview capability requirements", () => {
it("admits a provider with current_document and other mandatory capabilities", () => {
const capabilities = [
"list_segments",
"current_document",
"list_procedures",
"list_strings",
@@ -520,7 +534,6 @@ describe("binary_overview capability requirements", () => {
it("admits a provider with list_documents and other mandatory capabilities", () => {
const capabilities = [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
@@ -556,7 +569,7 @@ describe("binary_overview capability requirements", () => {
});
});
it("reports missing segment enumeration independently when segments are missing", () => {
it("admits a provider without optional segment enumeration", () => {
const capabilities = [
"current_document",
"list_procedures",
@@ -568,12 +581,31 @@ describe("binary_overview capability requirements", () => {
status({ open: true, kind: "executable", capabilities }),
),
).toMatchObject({
available: false,
reason: "provider_missing",
remediation: "Configure a provider for required operation list_segments.",
available: true,
reason: "available",
remediation: null,
});
});
it("keeps binary overview available when a declared segment facet is unavailable", () => {
const capabilities = [
{
operation: "list_segments",
available: false,
reason: "This upstream profile does not expose segment enumeration.",
},
{ operation: "list_documents", available: true, reason: null },
{ operation: "list_procedures", available: true, reason: null },
{ operation: "list_strings", available: true, reason: null },
];
expect(
entry(
"binary_overview",
status({ open: true, kind: "executable", capabilities }),
),
).toMatchObject({ available: true, reason: "available" });
});
it("reports provider_unavailable when declared document selection capability is unavailable", () => {
const capabilities = [
{ operation: "list_segments", available: true, reason: null },
+4 -2
View File
@@ -115,7 +115,6 @@ const ENHANCED_REQUIREMENTS: Readonly<
analyze_swift_types: ["list_procedures"],
find_xrefs_to_name: ["list_names", "xrefs"],
binary_overview: [
"list_segments",
["current_document", "list_documents"],
"list_procedures",
"list_strings",
@@ -462,7 +461,10 @@ const workflowAvailabilityFor = ({
reason:
policy.androidAnalysisAvailability?.code === "unsupported_host"
? "unsupported_host"
: "provider_missing",
: policy.androidAnalysisAvailability?.code ===
"runtime_unavailable"
? "provider_unavailable"
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.reason ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
+29 -15
View File
@@ -525,33 +525,47 @@ export class EnhancedTools {
this.#call("list_strings", {}, signal),
],
);
if (!segmentsResult.ok) return segmentsResult;
if (
!segmentsResult.ok &&
!(segmentsResult.error instanceof AnalysisCapabilityUnavailableError)
)
return segmentsResult;
if (!proceduresResult.ok) return proceduresResult;
if (!stringsResult.ok) return stringsResult;
const segments = parseSegments(segmentsResult.value);
if (!segments.ok) return segments;
const segments = segmentsResult.ok
? parseSegments(segmentsResult.value)
: undefined;
if (segments !== undefined && !segments.ok) return segments;
const procedureCount = parseListCount(proceduresResult.value, "procedures");
if (!procedureCount.ok) return procedureCount;
const stringCount = parseListCount(stringsResult.value, "strings");
if (!stringCount.ok) return stringCount;
const imageBase = parseSegmentImageBase(segments.value);
const observedSegments = segments?.ok ? segments.value : undefined;
const imageBase =
observedSegments === undefined
? undefined
: parseSegmentImageBase(observedSegments);
return ok({
document: document.value,
...(imageBase === undefined ? {} : { image_base: imageBase }),
segments: segments.value.map(
({ name, start, end, image_base, address_space }) => ({
name,
start,
end,
length: addressDistance(start, end),
...(image_base === undefined ? {} : { image_base }),
...(address_space === undefined ? {} : { address_space }),
}),
),
segment_count: segments.value.length,
...(observedSegments === undefined
? {}
: {
segments: observedSegments.map(
({ name, start, end, image_base, address_space }) => ({
name,
start,
end,
length: addressDistance(start, end),
...(image_base === undefined ? {} : { image_base }),
...(address_space === undefined ? {} : { address_space }),
}),
),
segment_count: observedSegments.length,
}),
procedure_count: procedureCount.value,
string_count: stringCount.value,
});
+6
View File
@@ -10,6 +10,8 @@ import {
type MipsElfRelocationSection,
} from "./MipsElfSharedLoad.js";
const SHT_GNU_ATTRIBUTES = 0x6ffffff5;
/** Observed ABI and load declarations, retaining unavailable source coverage. */
export interface MipsElfInspection {
readonly abiFlags: MipsAbiFlags | null;
@@ -151,6 +153,10 @@ export const readMipsElfMetadata = async (
checkCancelled();
const position = i * table.entrySize;
const type = u32(contents.value, position + table.typeOffset);
if (table.optional && type === SHT_GNU_ATTRIBUTES)
return err(
"MIPS ELF declares GNU ABI attributes that REA does not inspect; refusing provider selection until the declared ABI is parsed and reconciled",
);
if (metadata.type === 3 && table.optional && (type === 4 || type === 9))
relocationSections.push({
type,
+20 -18
View File
@@ -70,24 +70,26 @@ export const probeWindowsCapabilities = async (
};
};
const systemDependencies = (): WindowsCapabilityDependencies => ({
platform: process.platform,
architecture: process.arch,
probeSymlinkCreation,
probeNoFollowOpen: () =>
systemNoFollowOpenCapability(process.platform, constants.O_NOFOLLOW),
probePrivateAcl: () =>
windowsNativeCapabilities(process.platform).private_runtime_dacl,
nativeCapabilities: () => windowsNativeCapabilities(process.platform),
probeUnixDomainSocket: () =>
process.platform === "win32"
? {
available: false,
reason: "Node path-based IPC uses named pipes on Windows",
}
: { available: true, reason: null },
probePty: probeProcessCaptureCapability,
});
const systemDependencies = (): WindowsCapabilityDependencies => {
const security = windowsNativeCapabilities(process.platform);
return {
platform: process.platform,
architecture: process.arch,
probeSymlinkCreation,
probeNoFollowOpen: () =>
systemNoFollowOpenCapability(process.platform, constants.O_NOFOLLOW),
probePrivateAcl: () => security.private_runtime_dacl,
nativeCapabilities: () => security,
probeUnixDomainSocket: () =>
process.platform === "win32"
? {
available: false,
reason: "Node path-based IPC uses named pipes on Windows",
}
: { available: true, reason: null },
probePty: probeProcessCaptureCapability,
};
};
/** Report pathname no-follow support without treating it as Windows authority. */
export const systemNoFollowOpenCapability = (
@@ -13,6 +13,7 @@ import {
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok } from "../../domain/result.js";
@@ -63,6 +64,46 @@ it("rejects malformed provider capability declarations", () => {
).toThrow(/Invalid analysis provider ID: auto/u);
});
it("uses one owner readiness snapshot for candidate availability and capabilities", () => {
const base = candidate("alpha").provider;
let observed = 0;
let capabilityReads = 0;
const provider: AnalysisProviderCandidate = {
...base,
inspectAvailability: () => {
throw new Error("availability must come from the owner snapshot");
},
capabilities: () => {
capabilityReads += 1;
return [capability(base.identity())];
},
inspectReadinessSnapshot: () => {
observed += 1;
return {
availability: {
status: "unavailable",
code: "runtime_unavailable",
reason: "native inspection failed",
diagnostics: { failure_stage: "self-test" },
},
capabilities: [capability(base.identity())],
};
},
};
const [status] = new AnalysisProviderRegistry([provider]).candidates();
expect(observed).toBe(1);
expect(capabilityReads).toBe(1);
expect(status).toMatchObject({
availability: {
status: "unavailable",
code: "runtime_unavailable",
diagnostics: { failure_stage: "self-test" },
},
capabilities: [{ operation: "address_name" }],
});
});
it("selects the sole available provider and honors explicit selection precedence", async () => {
const alpha = candidate("alpha");
const beta = candidate("beta", { available: false });
@@ -104,7 +145,7 @@ it("selects the sole available provider and honors explicit selection precedence
});
});
describe("analysis provider registry: binding failures and cancellation", () => {
describe("analysis provider registry: selection failures", () => {
it("returns actionable unknown, unavailable, and unsupported failures", async () => {
const unavailable = candidate("alpha", { available: false });
const registry = new AnalysisProviderRegistry([unavailable.provider]);
@@ -207,7 +248,9 @@ describe("analysis provider registry: binding failures and cancellation", () =>
});
}
});
});
describe("analysis provider registry: profile and cleanup failures", () => {
it("does not bind a profile with unresolved version or adapter failure", async () => {
const unresolved = candidate("unresolved", { profile: "missing" });
const failed = candidate("failed", { profile: "error" });
@@ -240,6 +283,36 @@ describe("analysis provider registry: binding failures and cancellation", () =>
});
});
it("aborts automatic selection when a provider reports unconfirmed cleanup", async () => {
const ghidra = candidate("ghidra");
const alternate = candidate("hopper");
const cleanupFailure = new ProviderCleanupError(
"ghidra",
["/opt/ghidra/data/languages/x86.ldefs"],
{ reason: "descriptor close failed" },
{ operation: "resolve_analysis_profile" },
);
const provider: AnalysisProviderCandidate = {
...ghidra.provider,
resolveAnalysisProfile: () => Promise.resolve(err(cleanupFailure)),
};
const selected = await new AnalysisProviderRegistry([
alternate.provider,
provider,
]).select(DATABASE_TARGET);
expect(selected).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
providerId: "ghidra",
cleanupIncomplete: true,
},
});
expect(alternate.created).toEqual([]);
});
it("cancels a pending profile probe even when the adapter ignores its signal", async () => {
const alpha = candidate("alpha");
let observedSignal: AbortSignal | undefined;
@@ -306,6 +306,10 @@ export class AnalysisProviderRegistry {
throw new TypeError("Cancelled candidate evaluation disappeared");
return result.value;
});
const cleanupFailure = evaluations.find(
({ profileError }) => profileError?.cleanupIncomplete === true,
)?.profileError;
if (cleanupFailure !== undefined) return err(cleanupFailure);
const statuses = evaluations.map(({ status }) => status);
const usable = evaluations.filter(
(
@@ -334,23 +338,29 @@ export class AnalysisProviderRegistry {
const candidateStatus = (
provider: AnalysisProviderCandidate,
target: BinaryTarget | undefined,
): AnalysisProviderCandidateStatus => ({
provider: structuredClone(provider.identity()),
availability: cloneAvailability(provider.inspectAvailability()),
targetSupport:
target === undefined
? {
status: "unknown",
code: null,
reason: "No target is open.",
diagnostics: {},
}
: cloneTargetSupport(provider.inspectTargetSupport(target)),
selected: false,
capabilities: provider
.capabilities()
.map((capability) => structuredClone(capability)),
});
): AnalysisProviderCandidateStatus => {
const readiness = provider.inspectReadinessSnapshot?.() ?? {
availability: provider.inspectAvailability(),
capabilities: provider.capabilities(),
};
return {
provider: structuredClone(provider.identity()),
availability: cloneAvailability(readiness.availability),
targetSupport:
target === undefined
? {
status: "unknown",
code: null,
reason: "No target is open.",
diagnostics: {},
}
: cloneTargetSupport(provider.inspectTargetSupport(target)),
selected: false,
capabilities: readiness.capabilities.map((capability) =>
structuredClone(capability),
),
};
};
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
+6 -4
View File
@@ -125,7 +125,7 @@ export const resolveGhidraAnalysisProfile = async (
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
call_relationship_coverage: "function-body-call-coverage-v5",
function_dossier_coverage:
"observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
@@ -134,7 +134,8 @@ export const resolveGhidraAnalysisProfile = async (
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
@@ -222,7 +223,7 @@ export const resolveGhidraAnalysisProfile = async (
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
call_relationship_coverage: "function-body-call-coverage-v5",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
@@ -230,7 +231,8 @@ export const resolveGhidraAnalysisProfile = async (
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
+46 -17
View File
@@ -58,6 +58,7 @@ import {
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js";
export type GhidraTargetSnapshot =
| { readonly kind: "captured"; readonly bytes: Buffer }
@@ -119,7 +120,7 @@ export class GhidraClient {
#closePromise: Promise<void> | undefined;
readonly #failure = bindGhidraSessionFailure(
() => this.#diagnostics(),
(value) => this.#redactAuthentication(value),
(value, incomplete) => this.#redactAuthentication(value, incomplete),
);
readonly #wire: GhidraWire;
readonly #requestQueue: GhidraRequestQueue;
@@ -662,25 +663,47 @@ export class GhidraClient {
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
});
const connected = await this.#connect(endpoint, deadline);
if (!connected.ok) {
const failure = connected.error;
return this.#startupFailure(failure);
}
const completed = await completeGhidraStartupHandshake({
deadline,
request: (method, params, requestOptions) =>
this.#wire.request(method, params, requestOptions),
parseSessionInfo: (value) => this.#parseSessionInfo(value),
failure: this.#failure,
startupTimeoutMs: this.#options.startupTimeoutMs,
});
const completed = await this.#connectAndHandshake(endpoint, deadline);
if (!completed.ok) return this.#startupFailure(completed.error);
await this.#lineage.observe(this.#launch);
this.#requestQueue.reopen();
return completed;
}
async #connectAndHandshake(
endpoint: GhidraEndpoint,
deadline: ProviderStartupDeadline,
): Promise<GhidraStartResult> {
// Retry only a dropped loopback transport, never identity or protocol failures.
for (let attempt = 0; ; attempt += 1) {
const connected = await this.#connect(endpoint, deadline);
if (!connected.ok) return connected;
const completed = await completeGhidraStartupHandshake({
deadline,
request: (method, params, requestOptions) =>
this.#wire.request(method, params, requestOptions),
parseSessionInfo: (value) => this.#parseSessionInfo(value),
failure: this.#failure,
startupTimeoutMs: this.#options.startupTimeoutMs,
});
if (
completed.ok ||
completed.error.kind !== "process" ||
endpoint.transport !== "authenticated-loopback-tcp" ||
attempt >= 2 ||
deadline.signal.aborted ||
this.#closing ||
this.#process?.observedExit() !== undefined
)
return completed;
this.#detachSocket?.();
this.#detachSocket = undefined;
this.#socket?.destroy();
this.#socket = undefined;
this.#responseBuffer.reset();
}
}
async #connect(
endpoint: GhidraEndpoint,
deadline: ProviderStartupDeadline,
@@ -880,9 +903,15 @@ export class GhidraClient {
);
}
#redactAuthentication(value: string): string {
for (const token of this.#authenticationTokens)
value = value.replaceAll(token, "[REDACTED]");
#redactAuthentication(value: string, incomplete = false): string {
for (const token of this.#authenticationTokens) {
const bytes = Buffer.byteLength(value);
value = redactCapturedTransportCredential(
{ text: value, bytes, observedBytes: bytes + (incomplete ? 1 : 0) },
token,
"[REDACTED]",
);
}
return value;
}
+111 -11
View File
@@ -4,7 +4,7 @@ import { runDoctor, type DoctorHost } from "../application/Doctor.js";
import { createDoctorHostFixture } from "../application/Doctor.fixture.js";
import { providerRegistrationEnvironment } from "../application/SetupRegistrationEnvironment.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import * as windowsAuthority from "../process/WindowsAuthority.js";
import * as nativeLoader from "../windows/WindowsNativeLoader.js";
import {
inspectGhidraInstallation,
type GhidraInstallationHost,
@@ -180,13 +180,11 @@ describe("Ghidra doctor integration", () => {
describe("Windows Ghidra doctor authority", () => {
beforeEach(() => {
vi.spyOn(windowsAuthority, "hasWindowsNativeAuthority").mockReturnValue(
false,
);
vi.spyOn(
windowsAuthority,
"windowsNativeAuthorityUnavailableReason",
).mockReturnValue(WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON);
vi.spyOn(nativeLoader, "systemWindowsNativeAuthority").mockReturnValue({
available: false,
failureStage: "artifact",
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
});
afterEach(() => vi.restoreAllMocks());
it("distinguishes a valid Windows installation from unavailable native authority", async () => {
@@ -228,9 +226,9 @@ describe("Windows Ghidra doctor authority", () => {
result.checks.find(({ name }) => name === "ghidra-native_authority"),
).toMatchObject({
ok: false,
classification: "unsupported_host",
classification: "missing_dependency",
detail: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
remediation: expect.stringContaining("cannot enable"),
remediation: expect.stringContaining("matching Windows x64 REA package"),
});
});
@@ -252,7 +250,109 @@ describe("Windows Ghidra doctor authority", () => {
expect(result.scope_checks).toContainEqual(
expect.objectContaining({
name: "ghidra-native_authority",
classification: "unsupported_host",
classification: "missing_dependency",
detail: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
}),
);
});
});
describe("Windows native doctor failure diagnostics", () => {
beforeEach(() => {
vi.spyOn(nativeLoader, "systemWindowsNativeAuthority").mockReturnValue({
available: false,
failureStage: "artifact",
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
});
afterEach(() => vi.restoreAllMocks());
it.each([
["host", "unsupported_host", "unsupported_host", "Windows x64 host"],
[
"artifact",
"runtime_missing",
"missing_dependency",
"bundled native addon",
],
[
"self-test",
"runtime_unavailable",
"runtime_failure",
"preserved native self-test error",
],
[
"inspection",
"runtime_unavailable",
"runtime_failure",
"every required security control",
],
] as const)(
"projects %s failure without blaming Ghidra",
async (failureStage, code, classification, remediation) => {
const reason =
"Windows native failure: remove_owned_directory: directory is not empty (145)";
const load = vi.mocked(nativeLoader.systemWindowsNativeAuthority);
load.mockReturnValue({ available: false, failureStage, reason });
const result = await runDoctor(
undefined,
{
...doctorHost(inspection({ installDir: INSTALL, platform: "win32" })),
platform: "win32",
linuxDistribution: () => Promise.resolve(undefined),
},
{ providers: ["ghidra"] },
);
expect(load).toHaveBeenCalledTimes(1);
expect(result.healthy).toBe(false);
expect(result.providerInspections?.[0]).toMatchObject({
available: false,
registrationEnvironment: { GHIDRA_INSTALL_DIR: INSTALL },
checks: expect.arrayContaining([
expect.objectContaining({
name: "native_authority",
ok: false,
code,
detail: reason,
}),
]),
});
expect(result.scope_checks).toContainEqual(
expect.objectContaining({
name: "ghidra-native_authority",
classification,
detail: reason,
remediation: expect.stringContaining(remediation),
}),
);
},
);
it("keeps one failure observation even when the next query would differ", () => {
const load = vi.mocked(nativeLoader.systemWindowsNativeAuthority);
load.mockReturnValueOnce({
available: false,
failureStage: "self-test",
retryable: true,
reason: "cleanup failed (145)",
});
const ghidra = inspection({ installDir: INSTALL, platform: "win32" });
const failed = projectGhidraDoctorInspection(ghidra);
expect(load).toHaveBeenCalledTimes(1);
expect(failed.checks).toContainEqual(
expect.objectContaining({
name: "native_authority",
ok: false,
code: "runtime_unavailable",
detail: "cleanup failed (145)",
}),
);
const later = projectGhidraDoctorInspection(ghidra);
expect(load).toHaveBeenCalledTimes(2);
expect(later.checks).toContainEqual(
expect.objectContaining({
name: "native_authority",
code: "runtime_missing",
detail: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
}),
);
+17 -16
View File
@@ -2,10 +2,7 @@ import type {
DoctorProviderCheck,
DoctorProviderInspection,
} from "../application/Doctor.js";
import {
hasWindowsNativeAuthority,
windowsNativeAuthorityUnavailableReason,
} from "../process/WindowsAuthority.js";
import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js";
import {
inspectGhidraInstallation,
type GhidraInstallationCheck,
@@ -16,10 +13,11 @@ import {
export const projectGhidraDoctorInspection = (
inspection: GhidraInstallationInspection,
): DoctorProviderInspection => {
const windowsAuthorityUnavailable =
inspection.status === "available" &&
inspection.platform === "win32" &&
!hasWindowsNativeAuthority(inspection.platform);
const readiness =
inspection.status === "available" && inspection.platform === "win32"
? inspectWindowsNativeReadiness(inspection.platform)
: undefined;
const windowsAuthorityUnavailable = readiness?.available === false;
return {
id: "ghidra",
configured: inspection.installDir !== null,
@@ -37,19 +35,22 @@ export const projectGhidraDoctorInspection = (
...selectedChecks(inspection).map((check) =>
projectCheck(check, inspection.installDir !== null),
),
...(inspection.status === "available" && inspection.platform === "win32"
...(readiness !== undefined
? [
{
name: "native_authority",
ok: !windowsAuthorityUnavailable,
code: windowsAuthorityUnavailable ? "unsupported_host" : null,
detail: windowsAuthorityUnavailable
? windowsNativeAuthorityUnavailableReason(inspection.platform)
code: !readiness.available ? readiness.code : null,
detail: !readiness.available
? readiness.reason
: "Verified Windows native Job Object, private DACL, and reparse-safe handle controls; local NTFS only.",
remediation: windowsAuthorityUnavailable
? "Use the matching Windows x64 REA package with its bundled native addon and a writable local NTFS temporary directory. The native failure above identifies the failed constraint; Ghidra installation or registration changes cannot enable a missing native backend."
: null,
classification: "unsupported_host" as const,
remediation: !readiness.available ? readiness.remediation : null,
classification:
!readiness.available && readiness.code === "runtime_unavailable"
? ("runtime_failure" as const)
: !readiness.available && readiness.code === "runtime_missing"
? ("missing_dependency" as const)
: ("unsupported_host" as const),
},
]
: []),
+145
View File
@@ -43,7 +43,152 @@ const parseFunctionValueFixture = (
return parsed.ok ? { ok: true as const, value: parsed.value.value } : parsed;
};
describe("Ghidra unresolved call locations", () => {
it("rejects a noncanonical unresolved call site rather than retaining unverifiable location evidence", () => {
expect(
parseGhidraFunctionResult("analyze_function", {
value: {
...functionDossierSchema.parse(ghidraFunctionDossier()),
unresolved_calls: [
{ address: "not-an-address", reason: "No call destination." },
],
},
limitations: [],
relationship_coverage: {
status: "partial",
limitations: ["An unresolved call remains."],
},
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
});
describe("Ghidra function-analysis result values", () => {
it("preserves unresolved Swift jump omissions", () => {
const dossier = {
...functionDossierSchema.parse(ghidraFunctionDossier()),
swift_string_literals: [],
swift_string_literal_omissions: [
{
source_address: "0x401004",
representation: "small",
address: null,
byte_length: 5,
reason: "unresolved_jump",
registers: ["x0", "x1"],
words: ["0x5000000000000005", "0x0000000000000000"],
},
],
};
expect(
parseGhidraFunctionResult("analyze_function", {
value: dossier,
limitations: [],
}),
).toMatchObject({
ok: true,
value: {
value: {
swift_string_literal_omissions:
dossier.swift_string_literal_omissions,
},
},
});
});
it("retains unavailable pseudocode instead of treating its empty representation as observed source", () => {
const availability = {
pseudocode: {
available: false,
reason: "Ghidra does not decompile external functions.",
},
comments: { available: true },
};
const parsed = parseGhidraFunctionResult("analyze_function", {
value: {
...functionDossierSchema.parse(ghidraFunctionDossier()),
pseudocode: "",
facet_availability: availability,
},
limitations: [],
});
expect(parsed).toMatchObject({
ok: true,
value: { value: { pseudocode: "", facet_availability: availability } },
});
});
it.each([
{ status: "complete", diagnostics: ["Resolved static references only."] },
{
status: "partial",
limitations: ["Only the first instruction range was examined."],
},
{ status: "unknown", reason: "Relationship inventory was unavailable." },
{
status: "partial",
limitations: ["Missing callers."],
limited_directions: ["callers"],
},
{
status: "unknown",
reason: "Missing callees.",
limited_directions: ["callees"],
},
])("preserves observed $status relationship coverage", (coverage) => {
const parsed = parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
relationship_coverage: coverage,
});
expect(parsed).toMatchObject({
ok: true,
value: { relationshipCoverage: coverage },
});
});
it("preserves omitted relationship coverage for legacy observations", () => {
const parsed = parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
});
if (!parsed.ok) throw parsed.error;
expect(parsed.value).not.toHaveProperty("relationshipCoverage");
});
});
describe("Ghidra relationship metadata validation", () => {
it.each([
{ status: "partial", limitations: [] },
{ status: "unknown", reason: "" },
{ status: "complete", limitations: ["Truncated."] },
{ status: "partial", limitations: ["Truncated."], limited_directions: [] },
{
status: "partial",
limitations: ["Truncated."],
limited_directions: ["callers", "callers"],
},
{ status: "unknown", reason: "Missing.", limited_directions: ["incoming"] },
{ status: "complete", limited_directions: ["callers"] },
])("rejects malformed relationship coverage %j", (coverage) => {
expect(
parseGhidraFunctionResult("analyze_function", {
value: ghidraFunctionDossier(),
limitations: [],
relationship_coverage: coverage,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it("rejects relationship metadata on unrelated function operations", () => {
expect(
parseGhidraFunctionResult("procedure_assembly", {
value: "ret",
limitations: [],
relationship_coverage: { status: "complete" },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
const selection = {
file_offset: 0x210,
+16 -1
View File
@@ -27,6 +27,10 @@ import {
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
relationshipCoverageSchema,
type RelationshipCoverage,
} from "../domain/relationshipCoverage.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import {
@@ -307,6 +311,7 @@ const ghidraFunctionDossier = functionDossierSchema
...value.comments.map(({ address }) => address),
...value.callers.map(({ address }) => address),
...value.callees.map(({ address }) => address),
...(value.unresolved_calls ?? []).map(({ address }) => address),
...value.incoming_references.flatMap(referenceAddresses),
...value.outgoing_references.flatMap(referenceAddresses),
...value.referenced_strings.flatMap(({ address, source_address }) => [
@@ -419,6 +424,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
const functionObservationEnvelope = z.strictObject({
value: z.unknown(),
limitations: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema.exactOptional(),
selection: z
.strictObject({
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
@@ -432,6 +438,7 @@ export interface ParsedGhidraFunctionResult {
readonly value: JsonValue;
readonly limitations: readonly string[];
readonly locations?: readonly EvidenceLocation[];
readonly relationshipCoverage?: RelationshipCoverage;
}
/** Require exact, bounded Java-bridge function output before creating Evidence. */
@@ -442,6 +449,7 @@ export const parseGhidraFunctionResult = (
let observation: {
readonly value: unknown;
readonly limitations: readonly string[];
readonly relationship_coverage?: RelationshipCoverage;
readonly selection?: z.infer<
typeof functionObservationEnvelope
>["selection"];
@@ -450,13 +458,17 @@ export const parseGhidraFunctionResult = (
const envelope = functionObservationEnvelope.safeParse(value);
if (
!envelope.success ||
(operation !== "analyze_function" &&
operation !== "procedure_callers" &&
operation !== "procedure_callees" &&
envelope.data.relationship_coverage !== undefined) ||
(operation !== "procedure_pseudo_code" &&
envelope.data.selection !== undefined)
)
return err(
new AnalysisOutputError(
operation,
"Ghidra bridge omitted the function observation envelope",
"Ghidra bridge returned an invalid function observation envelope for this operation",
envelope.success ? undefined : { cause: envelope.error },
),
);
@@ -469,6 +481,9 @@ export const parseGhidraFunctionResult = (
? ok({
value: jsonValueSchema.parse(parsed.data),
limitations: observation.limitations,
...(observation.relationship_coverage === undefined
? {}
: { relationshipCoverage: observation.relationship_coverage }),
...(observation.selection === undefined
? {}
: {
+38 -9
View File
@@ -252,7 +252,9 @@ export interface GhidraJavaObservation {
readonly version: string;
readonly major: number;
readonly home: string;
readonly bits: number;
readonly bits: number | null;
/** Present when Java reported a bitness value that could not be parsed exactly. */
readonly unparsedBits?: string;
readonly runtime: "jdk" | "jre";
readonly vmName?: string;
/**
@@ -803,12 +805,23 @@ const javaCheck = (
ghidraJavaMajorAccepted(observation.major, requirement) &&
observation.bits === 64 &&
observation.runtime === "jdk";
const bitness =
observation === undefined
? undefined
: observation.bits === null
? `bitness unknown${observation.unparsedBits === undefined ? "" : ` (sun.arch.data.model = ${JSON.stringify(observation.unparsedBits)})`}`
: `${String(observation.bits)}-bit`;
const knownMismatch =
observation !== undefined &&
(!ghidraJavaMajorAccepted(observation.major, requirement) ||
(observation.bits !== null && observation.bits !== 64) ||
observation.runtime !== "jdk");
const detail =
observation === undefined
? requirement.unresolved === null
? command
: `${command}; ${requirement.unresolved}`
: `${observation.version}; ${String(observation.bits)}-bit; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}; accepted ${range}`;
: `${observation.version}; ${bitness}; ${observation.runtime === "jdk" ? "JDK" : "runtime only"}; ${observation.home}; accepted ${range}`;
return installationCheck({
name: "java",
passed: supported,
@@ -817,7 +830,9 @@ const javaCheck = (
? "version_unresolved"
: observation === undefined
? "runtime_missing"
: "unsupported_version",
: knownMismatch
? "unsupported_version"
: "version_unresolved",
detail,
remediation: javaRemediation(requirement, configuredHome, range),
});
@@ -905,11 +920,22 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
const vmName = javaProperty(output, "java.vm.name");
const osArch = javaProperty(output, "os.arch");
const osName = javaProperty(output, "os.name");
const bits = Number.parseInt(
javaProperty(output, "sun.arch.data.model") ?? "0",
10,
);
if (version === null || home === null) return undefined;
const reportedBits = javaProperty(output, "sun.arch.data.model");
const parsedBits =
reportedBits !== null && /^\d+$/.test(reportedBits)
? Number(reportedBits)
: null;
const bits =
parsedBits !== null && Number.isSafeInteger(parsedBits)
? parsedBits
: null;
if (
version === null ||
version.length === 0 ||
home === null ||
home.length === 0
)
return undefined;
return {
version,
major: javaMajor(version),
@@ -918,6 +944,9 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
...(osArch === null ? {} : { osArch }),
...(osName === null ? {} : { osName }),
bits,
...(reportedBits === null || bits !== null
? {}
: { unparsedBits: reportedBits }),
runtime: executablePath(
(process.platform === "win32" ? win32 : posix).join(
home,
@@ -944,7 +973,7 @@ const executablePath = (path: string): boolean => {
const javaProperty = (output: string, name: string): string | null =>
new RegExp(
`^\\s*${name.replaceAll(".", "\\.")}\\s*=\\s*(.+?)\\s*$`,
`^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`,
"mu",
).exec(output)?.[1] ?? null;
+49 -23
View File
@@ -1,6 +1,39 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { dirname, join } from "node:path";
import { execFileOutput } from "../process/ExecFileOutput.js";
import {
execFileOutput,
execFileOutputFailure,
type ExecFileOutputFailure,
} from "../process/ExecFileOutput.js";
export const GHIDRA_LAUNCH_SUPPORT_CAPTURE_BYTES = 64 * 1024;
type LaunchSupportPhase = "-envvars" | "-vmargs";
type LaunchSupportOutcome = Omit<
ExecFileOutputFailure,
"killed" | "outputTruncated"
> & {
readonly killed: boolean | null;
readonly outputTruncated: boolean | null;
};
export class GhidraLaunchSupportError extends Error {
readonly outcome: LaunchSupportOutcome | undefined;
constructor(phase: LaunchSupportPhase, cause: unknown) {
super(`Ghidra LaunchSupport ${phase} failed`, { cause });
this.name = "GhidraLaunchSupportError";
const outcome = execFileOutputFailure(cause);
this.outcome =
outcome === undefined
? undefined
: {
...outcome,
killed: outcome.code === "ABORT_ERR" ? null : outcome.killed,
outputTruncated:
outcome.code === "ABORT_ERR" ? null : outcome.outputTruncated,
};
}
}
/** JVM option environments remove quotes, including quotes inside an option. */
const jvmEnvironmentArguments = (value: string): readonly string[] => {
@@ -85,17 +118,20 @@ export const ghidraJavaLaunch = async (options: {
const environment = {
...snapshotEnvironment(options.environment, options.platform),
};
const configuredEnvironment = await execFileOutput(
command,
[...supportArguments, "-envvars"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
const supportOutput = async (phase: LaunchSupportPhase) => {
try {
return await execFileOutput(command, [...supportArguments, phase], {
env: environment,
maxBuffer: GHIDRA_LAUNCH_SUPPORT_CAPTURE_BYTES,
// Wait for the helper's exit on abort so no probe outlives its owner.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
});
} catch (cause: unknown) {
throw new GhidraLaunchSupportError(phase, cause);
}
};
const configuredEnvironment = await supportOutput("-envvars");
options.onStderr("-envvars", configuredEnvironment.stderr);
for (const line of configuredEnvironment.stdout.split(/\r?\n/u)) {
if (line === "") continue;
@@ -121,17 +157,7 @@ export const ghidraJavaLaunch = async (options: {
] as const) {
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
}
const configuredArguments = await execFileOutput(
command,
[...supportArguments, "-vmargs"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
const configuredArguments = await supportOutput("-vmargs");
options.onStderr("-vmargs", configuredArguments.stderr);
const vmArguments = configuredArguments.stdout
.split(/\r?\n/u)
+92 -3
View File
@@ -1,9 +1,12 @@
import { readdir, readFile } from "node:fs/promises";
import { open, readdir } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { DOMParser, onWarningStopParsing } from "@xmldom/xmldom";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
/** One language definition and the compiler specs it declares. */
export interface GhidraLanguageDefinition {
@@ -16,6 +19,62 @@ const MAX_LDEFS_BYTES = 4 * 1024 * 1024;
// Processor modules ship under Processors; installed extensions may add more.
const LANGUAGE_ROOTS = ["Ghidra/Processors", "Ghidra/Extensions"] as const;
interface RetainedLanguageFile {
readonly path: string;
readonly owner: OwnedFileHandle<FileHandle>;
}
/** Language definition handles whose close is unconfirmed but retryable. */
const pendingLanguageFileCleanup = new Set<RetainedLanguageFile>();
/**
* Cleanup of one or more language definition handles is unconfirmed. Any
* read failure that preceded it is kept as the primary failure.
*/
export class GhidraLanguageCatalogCleanupError extends Error {
constructor(
readonly paths: readonly string[],
readonly causes: readonly unknown[],
readonly readFailure?: unknown,
) {
super(
`Language definition cleanup is unconfirmed: ${paths.join(", ")}`,
readFailure === undefined ? undefined : { cause: readFailure },
);
}
}
/** Close one owner; retain it only while a later close can still succeed. */
const closeLanguageFile = async (
file: RetainedLanguageFile,
): Promise<unknown> => {
try {
await file.owner.close();
pendingLanguageFileCleanup.delete(file);
return undefined;
} catch (cause: unknown) {
// Descriptor invalidation does not prove that this owner confirmed close.
// Keep even sticky failures in the lifecycle scope so later reads surface
// the unresolved cleanup instead of silently abandoning ownership.
pendingLanguageFileCleanup.add(file);
return cause;
}
};
/** Retry every retained owner before reporting the failures together. */
const retryLanguageFileCleanup = async (): Promise<void> => {
const failed: { readonly path: string; readonly cause: unknown }[] = [];
for (const file of pendingLanguageFileCleanup) {
const cause = await closeLanguageFile(file);
if (cause !== undefined) failed.push({ path: file.path, cause });
}
if (failed.length > 0)
throw new GhidraLanguageCatalogCleanupError(
failed.map(({ path }) => path),
failed.map(({ cause }) => cause),
);
};
/** Parse the `<language>` elements of one `.ldefs` document. */
export const parseGhidraLanguageDefinitions = (
text: string,
@@ -59,10 +118,40 @@ const languageDirectories = async (installDir: string): Promise<string[]> => {
return directories;
};
/** Read at most the catalog limit; undefined marks an oversized file. */
const readLanguageDefinitionFile = async (
path: string,
): Promise<Buffer | undefined> => {
await retryLanguageFileCleanup();
const file = { path, owner: new OwnedFileHandle(await open(path, "r")) };
let read:
| { readonly ok: true; readonly bytes: Buffer | undefined }
| { readonly ok: false; readonly cause: unknown };
try {
read = {
ok: true,
bytes: await readBoundedFileBytes(file.owner.handle, MAX_LDEFS_BYTES),
};
} catch (cause: unknown) {
read = { ok: false, cause };
}
const cleanup = await closeLanguageFile(file);
if (cleanup !== undefined)
throw new GhidraLanguageCatalogCleanupError(
[path],
[cleanup],
read.ok ? undefined : read.cause,
);
if (!read.ok) throw read.cause;
return read.bytes;
};
/** Read every language definition shipped by one Ghidra installation. */
export const readGhidraLanguageCatalog = async (
installDir: string,
): Promise<readonly GhidraLanguageDefinition[]> => {
// Surface retained cleanup even when this catalog has no definitions to read.
await retryLanguageFileCleanup();
const definitions: GhidraLanguageDefinition[] = [];
for (const directory of await languageDirectories(installDir)) {
let entries: string[];
@@ -73,8 +162,8 @@ export const readGhidraLanguageCatalog = async (
}
for (const entry of entries.sort()) {
if (!entry.endsWith(".ldefs")) continue;
const bytes = await readFile(join(directory, entry));
if (bytes.length > MAX_LDEFS_BYTES) continue;
const bytes = await readLanguageDefinitionFile(join(directory, entry));
if (bytes === undefined) continue;
// XML 1.0 section 4.3.3: a leading UTF-8 BOM is an encoding signature,
// not document content; TextDecoder consumes it, Buffer#toString does not.
definitions.push(
+19 -13
View File
@@ -56,7 +56,8 @@ const launchOutcomeOf = (error: Error): Record<string, unknown> | undefined => {
string,
unknown
>;
const outcome = cause["launch_outcome"] as Record<string, unknown>;
const nested = cause["cause"] as Record<string, unknown> | undefined;
const outcome = nested?.["command_outcome"] as Record<string, unknown>;
return outcome === undefined ? undefined : outcome;
};
@@ -78,12 +79,16 @@ describe("Ghidra launch failure diagnostics", () => {
launched.error instanceof GhidraLaunchError
? launched.error.capturedOutput
: undefined;
expect(captured).toBeDefined();
expect(captured?.exit_code).toBe(3);
expect(captured?.signal).toBeNull();
expect(captured?.stdout).toBe("");
expect(captured?.stderr).toContain("jvm probe exploded");
expect(captured?.truncated).toBe(false);
expect(captured).toBeUndefined();
const outcome = launchOutcomeOf(launched.error);
expect(outcome).toMatchObject({
killed: false,
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
stdout: "",
});
expect(outcome?.["stderr"]).toContain("jvm probe exploded");
} finally {
await rm(javaHome, { recursive: true, force: true });
await rm(runtimeRoot, { recursive: true, force: true });
@@ -103,10 +108,11 @@ describe("Ghidra launch failure diagnostics", () => {
if (launched.ok) throw new Error("expected the launch to fail");
const outcome = launchOutcomeOf(launched.error);
expect(outcome).toBeDefined();
expect(outcome?.["exit_code"]).toBe(3);
expect(outcome?.["signal"]).toBeNull();
expect(outcome?.["killed"]).toBe(false);
expect(outcome?.["outputTruncated"]).toBe(false);
expect(outcome?.["stderrTruncated"]).toBe(false);
expect(outcome?.["stderr"]).toContain("jvm probe exploded");
expect(outcome?.["truncated"]).toBe(false);
expect(outcome?.["outputTruncated"]).toBe(false);
} finally {
await rm(javaHome, { recursive: true, force: true });
await rm(runtimeRoot, { recursive: true, force: true });
@@ -126,9 +132,9 @@ describe("Ghidra launch failure diagnostics", () => {
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("expected the launch to fail");
const outcome = launchOutcomeOf(launched.error);
expect(outcome?.["truncated"]).toBe(true);
expect(outcome?.["exit_code"]).toBe(1);
expect(outcome?.["stderr_bytes"]).toBe(oversized.length);
expect(outcome?.["stderrTruncated"]).toBe(true);
expect(outcome?.["outputTruncated"]).toBe(false);
expect(outcome?.["stderrBytes"]).toBe(oversized.length);
expect((outcome?.["stderr"] as string).length).toBeLessThanOrEqual(
EXPECTED_OUTPUT_BOUND_BYTES,
);
+5
View File
@@ -137,6 +137,11 @@ export const ghidraMipsUnsupportedReason = (
if (load.symbolRelativeCount !== 0)
return "Symbol-bearing R_MIPS_REL32 is unsupported: Ghidra normalizes MIPS16 function addresses and can lose the source function-pointer mode tag. Symbol-index-zero REL32 is verified separately.";
}
if ((metadata.limitations?.length ?? 0) > 0)
return [
"MIPS ABI declaration inspection is incomplete; unsupported attributes or conflicting declaration sources may remain unknown.",
...(metadata.limitations ?? []),
].join(" ");
const abi = metadata.abiFlags;
if (shared && abi === null && (metadata.limitations?.length ?? 0) === 0)
return null; // Inspected absence leaves FP semantics unknown, never inferred.
+245 -9
View File
@@ -8,7 +8,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import * as nativeLoader from "../windows/WindowsNativeLoader.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
import {
@@ -30,13 +32,12 @@ import { err, ok } from "../domain/result.js";
import type { Result } from "../domain/result.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
import { silentLogger } from "../logger.js";
import * as WindowsNativeLoader from "../windows/WindowsNativeLoader.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
afterEach(() => vi.restoreAllMocks());
const installationHost = (
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
@@ -360,18 +361,20 @@ describe("Ghidra platform support", () => {
availabilityCode: "unsupported_host",
});
});
});
describe("Ghidra Windows platform support", () => {
it("keeps Windows P0 unavailable until native isolation authority exists", () => {
const native = vi
.spyOn(WindowsNativeLoader, "systemWindowsNativeAuthority")
.spyOn(nativeLoader, "systemWindowsNativeAuthority")
.mockReturnValue({
available: false,
failureStage: "artifact",
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
onTestFinished(() => native.mockRestore());
const ghidra = provider(installationHost("win32"));
const nativeApplication = peTarget("x86_64");
for (const architecture of ["x86", "x86_64"] as const)
expect(ghidra.inspectTargetSupport(peTarget(architecture))).toMatchObject(
{
@@ -386,11 +389,15 @@ describe("Ghidra platform support", () => {
);
expect(ghidra.inspectAvailability()).toMatchObject({
status: "unavailable",
code: "unsupported_host",
code: "runtime_missing",
reason: expect.stringContaining(
"Windows native controls are unavailable",
),
diagnostics: {
windows_native_failure: {
stage: "artifact",
remediation: expect.any(String),
},
windows_security: {
job_object_process_ownership: expect.objectContaining({
available: false,
@@ -412,7 +419,7 @@ describe("Ghidra platform support", () => {
expect.objectContaining({
operation: "list_procedures",
available: false,
availabilityCode: "unsupported_host",
availabilityCode: "runtime_missing",
limitations: expect.arrayContaining([
expect.stringContaining(
"matching packaged Windows x64 native addon",
@@ -454,6 +461,125 @@ describe("Ghidra platform support", () => {
});
});
describe("Windows Ghidra native failure diagnostics", () => {
it.each([
["artifact", "runtime_missing"],
["self-test", "runtime_unavailable"],
["inspection", "runtime_unavailable"],
] as const)(
"keeps Windows P0 unavailable after a %s failure",
async (failureStage, code) => {
const load = vi
.spyOn(nativeLoader, "systemWindowsNativeAuthority")
.mockReturnValue({
available: false,
failureStage,
reason: "Windows native controls are unavailable: missing manifest",
});
const ghidra = provider(installationHost("win32"));
const nativeApplication = peTarget("x86_64");
for (const architecture of ["x86", "x86_64"] as const)
expect(
ghidra.inspectTargetSupport(peTarget(architecture)),
).toMatchObject({
status: "supported",
diagnostics: {
host_platform: "win32",
architecture,
executable_role: "application",
managed: false,
},
});
expect(ghidra.inspectAvailability()).toMatchObject({
status: "unavailable",
code,
reason: expect.stringContaining(
"Windows native controls are unavailable",
),
diagnostics: {
windows_native_failure: {
stage: failureStage,
remediation: expect.any(String),
},
windows_security: {
job_object_process_ownership: expect.objectContaining({
available: false,
proof: "not-proven",
}),
private_runtime_dacl: expect.objectContaining({
available: false,
proof: "not-proven",
}),
reparse_safe_path_admission: expect.objectContaining({
available: false,
proof: "not-proven",
}),
},
},
});
expect(load).toHaveBeenCalledTimes(1);
expect(ghidra.capabilities()).toEqual(
expect.arrayContaining([
expect.objectContaining({
operation: "list_procedures",
available: false,
availabilityCode: code,
limitations: expect.arrayContaining([
expect.stringContaining(
"matching packaged Windows x64 native addon",
),
expect.stringContaining(
"private DACLs, and Job Object ownership automatically",
),
]),
}),
]),
);
expect(
ghidra.inspectTargetSupport({
...nativeApplication,
executableRole: "non-executable",
}),
).toMatchObject({
status: "unsupported",
code: "target_role_unsupported",
});
expect(
ghidra.inspectTargetSupport({ ...nativeApplication, managed: true }),
).toMatchObject({
status: "unsupported",
code: "managed_target_unsupported",
});
expect(
ghidra.inspectTargetSupport(executableTarget("elf", "x86_64")),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
});
expect(
ghidra.inspectTargetSupport(executableTarget("pe", "arm64")),
).toMatchObject({
status: "unsupported",
code: "architecture_unsupported",
});
const client = ghidra.createClient(nativeApplication);
const health = await client.execute("health", {});
if (health.ok)
throw new Error(
"Failed native admission must not create a ready client",
);
expect(projectAnalysisError(health.error)).toMatchObject({
code: "capability_unavailable",
details: {
reason: "Windows native controls are unavailable: missing manifest",
},
});
expect(load).toHaveBeenCalledTimes(3);
},
);
});
describe("Ghidra Windows PE fixture admission", () => {
it("admits generated PE32 and PE32+ DLLs and applications while preserving native-only Windows admission", async () => {
const root = fileURLToPath(new URL("../../", import.meta.url));
@@ -633,7 +759,7 @@ describe("Ghidra client projection", () => {
expect(resolved.value.profile.parameters).toMatchObject({
language_id: "auto-from-header",
compiler_spec_id: "auto-default",
call_relationship_coverage: "resolved-call-reference-diagnostics-v1",
call_relationship_coverage: "function-body-call-coverage-v5",
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
});
@@ -870,7 +996,16 @@ describe("Ghidra relationship coverage", () => {
Promise.resolve(
ok(
operation === "procedure_callees"
? { value: ["0x2000"], limitations: [] }
? {
value: ["0x2000"],
limitations: [],
relationship_coverage: {
status: "complete",
diagnostics: [
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.",
],
},
}
: { value: [], limitations: [] },
),
),
@@ -2228,3 +2363,104 @@ describe("Ghidra disabled analyzer options", () => {
}
});
});
describe("Ghidra queries after the owned provider is lost", () => {
const lostProviderSession = async (
state: "exited" | "unreachable" | "idle",
failureKind: "process" | "protocol" | "remote" = "process",
answeredBeforeLoss = true,
) => {
let calls = 0;
const ghidra = provider(installationHost(), () => ({
start: async () =>
err(new GhidraSessionError("start", "startup is not exercised")),
callTool: async () => {
calls += 1;
if (answeredBeforeLoss && calls === 1) return ok(null);
return err(
new GhidraSessionError(
failureKind,
"Ghidra bridge socket closed",
{ failure_kind: failureKind },
failureKind === "remote" ? { remoteCode: "internal" } : {},
),
);
},
close: async () => ok(null),
operationHealthSnapshot: () =>
state === "idle"
? undefined
: {
state,
stage: null,
retryAction: "restart_provider",
exitCode: state === "exited" ? 137 : null,
requests: [],
},
}));
const target = executableTarget("elf", "x86_64");
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected Ghidra analysis profile");
const client = ghidra.createClient(target, profile.value.profile);
if (answeredBeforeLoss) await client.execute("list_documents", {});
return client;
};
it("tells the caller to reopen the target when Ghidra exited", async () => {
const client = await lostProviderSession("exited");
const result = await client.execute("list_documents", {});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "execution_failure",
retryable: false,
details: { provider_id: "ghidra", operation: "list_documents" },
});
expect(projected.message).toContain("exited with code 137");
expect(projected.message).toContain("Call open_binary");
expect(projected.remediation.action).toContain("Call open_binary");
expect(projected.remediation.action).not.toContain("rea doctor");
});
it("tells the caller to reopen the target when the bridge became unreachable", async () => {
const client = await lostProviderSession("unreachable", "protocol");
const result = await client.execute("list_procedures", {});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected.message).toContain("became unreachable");
expect(projected.remediation.action).toContain("Call open_binary");
});
it("keeps installation guidance when Ghidra dies before it ever answered", async () => {
const client = await lostProviderSession("exited", "process", false);
const result = await client.execute("list_documents", {});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected.message).not.toContain("Call open_binary");
expect(projected.remediation.action).toContain("rea doctor");
});
it("keeps the generic failure when the provider is not known to be lost", async () => {
const client = await lostProviderSession("idle");
const result = await client.execute("list_documents", {});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error).remediation.action).toContain(
"rea doctor",
);
});
it("does not reinterpret a bridge-reported failure as a lost provider", async () => {
const client = await lostProviderSession("exited", "remote");
const result = await client.execute("list_documents", {});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error).message).not.toContain(
"Call open_binary",
);
});
});
+53 -10
View File
@@ -4,6 +4,7 @@ import type {
AnalysisClientContext,
AnalysisProfileResolutionOptions,
AnalysisProviderCandidate,
AnalysisProviderReadinessSnapshot,
CapabilityDescriptor,
ProviderAvailability,
ProviderIdentity,
@@ -25,6 +26,7 @@ import {
} from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino";
import { GhidraClient } from "./GhidraClient.js";
import {
@@ -38,6 +40,7 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js";
import {
admitGhidraLanguageOverride,
GhidraLanguageCatalogCleanupError,
readGhidraLanguageCatalog,
} from "./GhidraLanguageCatalog.js";
import {
@@ -55,8 +58,8 @@ import {
type GhidraProviderClientFactory,
} from "./GhidraProviderClient.js";
import {
windowsNativeAuthorityUnavailableReason,
hasWindowsNativeAuthority,
inspectWindowsNativeReadiness,
type WindowsNativeReadiness,
windowsNativeCapabilities,
} from "../process/WindowsAuthority.js";
@@ -101,22 +104,43 @@ export class GhidraProvider implements AnalysisProviderCandidate {
}
inspectAvailability(): ProviderAvailability {
return this.inspectReadinessSnapshot().availability;
}
inspectReadinessSnapshot(): AnalysisProviderReadinessSnapshot {
const installation = this.#inspectInstallation();
const readiness =
installation.platform === "win32"
? inspectWindowsNativeReadiness(installation.platform)
: undefined;
return {
availability: this.#availability(installation, readiness),
capabilities:
readiness === undefined
? CAPABILITIES
: windowsP0Capabilities(readiness),
};
}
#availability(
installation: GhidraInstallationInspection,
readiness: WindowsNativeReadiness | undefined,
): ProviderAvailability {
const diagnostics = ghidraInstallationDiagnostics(installation);
if (
installation.status === "available" &&
installation.platform === "win32" &&
!hasWindowsNativeAuthority(installation.platform)
)
if (installation.status === "available" && readiness?.available === false)
return {
status: "unavailable",
code: "unsupported_host",
reason: windowsNativeAuthorityUnavailableReason(installation.platform),
code: readiness.code,
reason: readiness.reason,
diagnostics: {
...diagnostics,
windows_security: jsonObjectSchema.parse(
windowsNativeCapabilities(installation.platform),
windowsNativeCapabilities(installation.platform, readiness),
),
windows_native_failure: {
stage: readiness.failureStage,
remediation: readiness.remediation,
},
},
};
return installation.status === "available"
@@ -217,6 +241,22 @@ export class GhidraProvider implements AnalysisProviderCandidate {
try {
catalog = await readGhidraLanguageCatalog(installation.installDir);
} catch (cause: unknown) {
if (cause instanceof GhidraLanguageCatalogCleanupError)
return err(
new ProviderCleanupError(
"ghidra",
cause.paths,
{
reason: cause.message,
paths: [...cause.paths],
close_failures: cause.causes.map(providerFailureText),
...(cause.readFailure === undefined
? {}
: { read_failure: providerFailureText(cause.readFailure) }),
},
{ operation: "resolve_analysis_profile", cause },
),
);
return err(
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
cause,
@@ -302,6 +342,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
}
}
const providerFailureText = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const inspectWindowsP0TargetSupport = (
target: BinaryTarget,
diagnostics: Readonly<Record<string, JsonValue>>,
+8 -24
View File
@@ -2,10 +2,9 @@ import type {
CapabilityDescriptor,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import {
hasWindowsNativeAuthority,
windowsNativeAuthorityUnavailableReason,
inspectWindowsNativeReadiness,
type WindowsNativeReadiness,
} from "../process/WindowsAuthority.js";
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
@@ -44,22 +43,6 @@ const stringInventoryLimitation =
const searchLimitation =
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
const resolvedCallReferenceLimitation =
"Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals.";
/** Relationship-only caveats belong to each observed caller/callee result. */
export const relationshipCoverageFor = (
operation: string,
): RelationshipCoverage | undefined =>
operation === "procedure_callers" ||
operation === "procedure_callees" ||
operation === "analyze_function"
? {
status: "complete",
diagnostics: [resolvedCallReferenceLimitation],
}
: undefined;
/** Limitation text for one admitted operation, including the common base. */
export const limitationsFor = (operation: string): readonly string[] => {
const common = [
@@ -214,8 +197,9 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
);
/** Capabilities advertised for the experimental Windows x64 P0 boundary. */
export const windowsP0Capabilities = (): readonly CapabilityDescriptor[] => {
const available = hasWindowsNativeAuthority("win32");
export const windowsP0Capabilities = (
readiness: WindowsNativeReadiness = inspectWindowsNativeReadiness("win32"),
): readonly CapabilityDescriptor[] => {
return Object.freeze(
CAPABILITIES.map((capability): CapabilityDescriptor => {
const limitations = Object.freeze([
@@ -230,13 +214,13 @@ export const windowsP0Capabilities = (): readonly CapabilityDescriptor[] => {
availabilityCode: "unsupported_host",
limitations,
});
return available
return readiness.available
? Object.freeze({ ...capability, limitations })
: Object.freeze({
...capability,
available: false,
reason: windowsNativeAuthorityUnavailableReason("win32"),
availabilityCode: "unsupported_host",
reason: readiness.reason,
availabilityCode: readiness.code,
limitations,
});
}),
+59 -11
View File
@@ -34,6 +34,11 @@ import {
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import {
providerRetryAction,
type ProviderOperationHealth,
} from "../domain/providerOperationHealth.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -86,7 +91,6 @@ import {
healthLimitations,
windowsP0Limitations,
limitationsFor,
relationshipCoverageFor,
} from "./GhidraProviderCapabilities.js";
import type { GhidraSessionError } from "./GhidraSessionError.js";
import {
@@ -99,10 +103,7 @@ import {
validateGhidraExtensionProfile,
ghidraExtensionLimitations,
} from "./extensions/GhidraExtensions.js";
import {
windowsNativeAuthorityUnavailableReason,
hasWindowsNativeAuthority,
} from "../process/WindowsAuthority.js";
import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
/** Production seam for exercising provider projection without a real process. */
@@ -652,6 +653,11 @@ export const createGhidraProviderClient = (input: {
...(context === undefined ? {} : { runId: context.runId }),
logger: logger.child({ layer: "ghidra-bridge" }),
});
// Startup failures retain installation guidance; only a previously running
// provider can be described as lost and reopened.
let providerStarted = false;
const lostProviderHealth = (): ProviderOperationHealth | undefined =>
providerStarted ? client.operationHealthSnapshot?.() : undefined;
const checkSessionObservations = async (
operation: AnalysisOperation,
info: GhidraSessionInfo,
@@ -733,8 +739,8 @@ export const createGhidraProviderClient = (input: {
rawResult: JsonValue,
observationLimitations: readonly string[] = [],
locations: readonly EvidenceLocation[] = [],
relationshipCoverage?: RelationshipCoverage,
) => {
const relationshipCoverage = relationshipCoverageFor(operation);
return createAnalysisExecution(normalized, committedProfile.provider, {
rawResult,
locations,
@@ -757,6 +763,7 @@ export const createGhidraProviderClient = (input: {
operation,
called.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const parsed = parseGhidraInventoryResult(operation, called.value);
@@ -780,12 +787,14 @@ export const createGhidraProviderClient = (input: {
);
if (operation === "health") {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
@@ -808,12 +817,14 @@ export const createGhidraProviderClient = (input: {
softFloat
) {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
@@ -825,6 +836,7 @@ export const createGhidraProviderClient = (input: {
? {}
: { progress: options.progress }),
});
if (called.ok) providerStarted = true;
if (operation === "list_documents" || operation === "list_procedures")
return inventoryExecution(operation, called);
if (!called.ok)
@@ -833,17 +845,20 @@ export const createGhidraProviderClient = (input: {
operation,
called.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
let normalized: JsonValue;
let observationLimitations: readonly string[] = [];
let observationLocations: readonly EvidenceLocation[] = [];
let relationshipCoverage: RelationshipCoverage | undefined;
if (isGhidraFunctionOperation(operation)) {
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) return result;
normalized = result.value.value;
observationLimitations = result.value.limitations;
observationLocations = result.value.locations ?? [];
relationshipCoverage = result.value.relationshipCoverage;
} else {
const result = parseGhidraInventoryResult(operation, called.value);
if (!result.ok) return result;
@@ -863,6 +878,7 @@ export const createGhidraProviderClient = (input: {
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const derived = await nativeAotOwner.derive(
@@ -925,6 +941,7 @@ export const createGhidraProviderClient = (input: {
operation,
failure,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
nativeAotResult: derived,
signal: options?.signal,
@@ -939,6 +956,7 @@ export const createGhidraProviderClient = (input: {
called.value,
observationLimitations,
observationLocations,
relationshipCoverage,
),
);
},
@@ -997,15 +1015,16 @@ const ghidraClientPrerequisites = (
diagnostics: ghidraInstallationDiagnostics(installation),
}),
);
if (
installation.platform === "win32" &&
!hasWindowsNativeAuthority(installation.platform)
)
const readiness =
installation.platform === "win32"
? inspectWindowsNativeReadiness(installation.platform)
: undefined;
if (readiness?.available === false)
return err(
new AnalysisCapabilityUnavailableError(
"ghidra",
"health",
windowsNativeAuthorityUnavailableReason(installation.platform),
readiness.reason,
),
);
if (profile === undefined || profile.provider.id !== "ghidra")
@@ -1027,11 +1046,13 @@ const projectSessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
startupTimeoutMs: number,
health?: ProviderOperationHealth,
): AnalysisError => {
const primary = projectPrimarySessionError(
operation,
failure,
startupTimeoutMs,
health,
);
return failure.cleanupFailure === undefined
? primary
@@ -1046,6 +1067,7 @@ const projectPrimarySessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
startupTimeoutMs: number,
health?: ProviderOperationHealth,
): AnalysisError => {
const context = { cause: failure, diagnostics: failure.diagnostics };
if (failure.cause instanceof AnalysisAccessDeniedError)
@@ -1112,5 +1134,31 @@ const projectPrimarySessionError = (
failure.message,
context,
);
const exited = exitedProviderMessage(operation, failure, health);
if (exited !== undefined)
return new ProviderAdapterError("ghidra", operation, {
...context,
userMessage: exited,
});
return new ProviderAdapterError("ghidra", operation, context);
};
/** A provider already observed as running can be restarted by reopening. */
const exitedProviderMessage = (
operation: AnalysisOperation,
failure: GhidraSessionError,
health: ProviderOperationHealth | undefined,
): string | undefined => {
if (failure.kind !== "process" && failure.kind !== "protocol")
return undefined;
if (
health === undefined ||
providerRetryAction(health.state) !== "restart_provider"
)
return undefined;
const observed =
health.state === "exited"
? `exited${health.exitCode === null ? "" : ` with code ${String(health.exitCode)}`}`
: "became unreachable";
return `The Ghidra provider for this session ${observed} before ${operation} could complete. Call open_binary for the same target to start a new Ghidra session, then retry; session-only annotations from the previous provider are not retained.`;
};
+43 -10
View File
@@ -2,6 +2,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
import {
GhidraLaunchError,
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
@@ -11,6 +12,7 @@ const MAX_CAUSE_DEPTH = 3;
const MAX_CAUSE_MESSAGE_BYTES = 2 * 1024;
const MAX_CAUSE_FIELD_BYTES = 1024;
const MAX_COMPILER_STDERR_BYTES = 8 * 1024;
const MAX_COMMAND_OUTPUT_BYTES = 8 * 1024;
/** Stable failure phase for one private Ghidra headless session. */
export type GhidraSessionFailureKind =
@@ -59,7 +61,7 @@ const createGhidraSessionError = (failure: {
readonly cause?: unknown;
readonly timeoutMs?: number;
readonly remoteCode?: string;
readonly redact: (value: string) => string;
readonly redact: (value: string, incomplete?: boolean) => string;
}): GhidraSessionError =>
new GhidraSessionError(
failure.kind,
@@ -90,7 +92,7 @@ const createGhidraSessionError = (failure: {
export const bindGhidraSessionFailure =
(
diagnostics: () => Readonly<Record<string, JsonValue>>,
redact: (value: string) => string = (value) => value,
redact: (value: string, incomplete?: boolean) => string = (value) => value,
) =>
(
kind: GhidraSessionFailureKind,
@@ -114,7 +116,7 @@ export const bindGhidraSessionFailure =
const describeCause = (
cause: unknown,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
depth = 0,
): JsonValue => {
if (cause instanceof Error) {
@@ -137,7 +139,7 @@ const describeCause = (
const describeErrorCause = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
depth: number,
): JsonValue => {
const message = boundedUtf8Text(
@@ -152,15 +154,45 @@ const describeErrorCause = (
...(message.truncated ? { message_truncated: true } : {}),
...causeCode(cause, redact),
...causeSignal(cause, redact),
...causeCommandOutcome(cause, redact),
...causeCompilerOutcome(cause, redact),
...causeLaunchOutcome(cause, redact),
...nestedErrorCause(cause, redact, depth),
};
};
const causeCommandOutcome = (
cause: Error,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
if (!(cause instanceof GhidraLaunchSupportError)) return {};
const outcome = cause.outcome;
if (outcome === undefined) return {};
const stdout = boundedUtf8Text(
redact(outcome.stdout, outcome.outputTruncated !== false),
MAX_COMMAND_OUTPUT_BYTES,
);
const stderr = boundedUtf8Text(
redact(outcome.stderr, outcome.outputTruncated !== false),
MAX_COMMAND_OUTPUT_BYTES,
);
return {
command_outcome: {
stdout: stdout.text,
stderr: stderr.text,
stdoutBytes: outcome.stdoutBytes ?? Buffer.byteLength(outcome.stdout),
stderrBytes: outcome.stderrBytes ?? Buffer.byteLength(outcome.stderr),
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
outputTruncated: outcome.outputTruncated,
killed: outcome.killed,
},
};
};
const causeCode = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
const code = "code" in cause ? cause.code : undefined;
if (typeof code === "string") {
@@ -176,7 +208,7 @@ const causeCode = (
const causeSignal = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
const signal = "signal" in cause ? cause.signal : undefined;
if (typeof signal === "string") {
@@ -192,7 +224,7 @@ const causeSignal = (
const causeCompilerOutcome = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
if (!(cause instanceof DarwinProcessOwnershipInspectionError)) return {};
const outcome = cause.compilerOutcome;
@@ -218,7 +250,7 @@ const causeCompilerOutcome = (
const boundedCompilerField = (
value: string | number | null,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
): { readonly value: string | number | null; readonly truncated: boolean } => {
if (typeof value !== "string") return { value, truncated: false };
const bounded = boundedUtf8Text(redact(value), MAX_CAUSE_FIELD_BYTES);
@@ -228,9 +260,10 @@ const boundedCompilerField = (
/** Retain a launch failure's launcher command outcome in its cause envelope. */
const causeLaunchOutcome = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
if (!(cause instanceof GhidraLaunchError)) return {};
if (cause.cause instanceof GhidraLaunchSupportError) return {};
const launchSupport =
cause.launchSupport.length === 0
? {}
@@ -273,7 +306,7 @@ const causeLaunchOutcome = (
const nestedErrorCause = (
cause: Error,
redact: (value: string) => string,
redact: (value: string, incomplete?: boolean) => string,
depth: number,
): Record<string, JsonValue> => {
if (!("cause" in cause)) return {};
+121 -37
View File
@@ -11,6 +11,7 @@ import { silentLogger } from "../logger.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
const TARGET: BinaryTarget = {
path: "/tmp/ghidra-cache-fixture",
@@ -23,7 +24,7 @@ const TARGET: BinaryTarget = {
// Exercise the real provider descriptors, profile, input/output projection and
// session cache; only the installed Ghidra process is replaced at its seam.
const fixture = (version = "12.1.4") => {
const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => {
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/opt/ghidra" });
if (!config.ok) throw config.error;
const calls: string[] = [];
@@ -113,7 +114,13 @@ const fixture = (version = "12.1.4") => {
return ok(
operation === "procedure_pseudo_code" ||
operation === "analyze_function"
? { value, limitations: [] }
? {
value,
limitations: [],
...(operation === "analyze_function" && coverage !== undefined
? { relationship_coverage: coverage }
: {}),
}
: value,
);
},
@@ -133,42 +140,119 @@ const open = async (session: ReturnType<typeof fixture>["session"]) => {
expect((await session.openResolvedTarget(preview.value)).ok).toBe(true);
};
describe("Ghidra snapshot cache", () => {
it("refuses snapshots whose profile predates typed incoming references", async () => {
const initial = fixture();
await open(initial.session);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const profile = snapshot.value.binding.analysis_profile;
expect(profile.parameters.instruction_references).toBe(
"exact-address-incoming-v1",
);
const legacy = {
...snapshot.value,
binding: {
...snapshot.value.binding,
analysis_profile: createAnalysisProfile(
profile.provider,
Object.fromEntries(
Object.entries(profile.parameters).filter(
([key]) => key !== "instruction_references",
),
),
),
},
};
const replay = fixture();
const imported = replay.session.importAnalysisSnapshot(legacy);
if (!imported.ok) throw imported.error;
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
expect(replay.calls).toEqual([]);
await replay.session.close();
});
describe("Ghidra relationship coverage replay", () => {
it.each([
undefined,
{ status: "complete", diagnostics: ["Resolved static references only."] },
{ status: "partial", limitations: ["Instruction coverage was truncated."] },
{ status: "unknown", reason: "Relationship inventory was unavailable." },
{
status: "partial",
limitations: ["Missing callers."],
limited_directions: ["callers"],
},
] satisfies (RelationshipCoverage | undefined)[])(
"preserves relationship coverage %j through live execution, cache and snapshot replay",
async (coverage) => {
const initial = fixture("12.1.4", coverage);
await open(initial.session);
const parameters = { procedure: "0x1000" };
for (let i = 0; i < 2; i++) {
const execution = await initial.session.execute(
"analyze_function",
parameters,
);
if (!execution.ok) throw execution.error;
expect(execution.value.relationshipCoverage).toEqual(coverage);
}
expect(initial.calls).toEqual(["analyze_function"]);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const replay = fixture();
expect(replay.session.importAnalysisSnapshot(snapshot.value).ok).toBe(
true,
);
await open(replay.session);
const execution = await replay.session.execute(
"analyze_function",
parameters,
);
if (!execution.ok) throw execution.error;
expect(execution.value.relationshipCoverage ?? undefined).toEqual(
coverage,
);
expect(replay.calls).toEqual([]);
await replay.session.close();
},
);
});
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "instruction_references",
current: "exact-address-incoming-v1",
legacy: undefined,
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "resolved-call-reference-diagnostics-v1",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "decoded-call-target-coverage-v2",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "decoded-call-target-coverage-v3",
},
{
parameter: "call_relationship_coverage",
current: "function-body-call-coverage-v5",
legacy: "function-body-call-coverage-v4",
},
])(
"refuses snapshots with outdated $parameter semantics",
async ({ parameter, current, legacy: prior }) => {
const initial = fixture();
await open(initial.session);
const snapshot = initial.session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
await initial.session.close();
const profile = snapshot.value.binding.analysis_profile;
expect(profile.parameters[parameter]).toBe(current);
const legacy = {
...snapshot.value,
binding: {
...snapshot.value.binding,
analysis_profile: createAnalysisProfile(profile.provider, {
...Object.fromEntries(
Object.entries(profile.parameters).filter(
([key]) => key !== parameter,
),
),
...(prior === undefined ? {} : { [parameter]: prior }),
}),
},
};
const replay = fixture();
const imported = replay.session.importAnalysisSnapshot(legacy);
if (!imported.ok) throw imported.error;
expect(await replay.session.previewTarget(TARGET)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
expect(replay.calls).toEqual([]);
await replay.session.close();
},
);
});
describe("Ghidra snapshot cache", () => {
it.each([
{ operation: "analyze_function", parameters: { procedure: "0x1000" } },
{
+2 -2
View File
@@ -55,7 +55,7 @@ const failureForPrimary = (
if (cause instanceof GoBinaryResourceFailure)
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
cause.boundary === "search" ? "cpu" : "memory",
`Selected artifact ${path}: ${cause.message}`,
{ boundary: cause.boundary, maximum_bytes: cause.maximum_bytes },
{ cause },
@@ -194,7 +194,7 @@ export class GoBinaryProvider implements GoBinaryPort {
limitations: [
"Embedded build metadata is observed file content, not proof of compiler identity, dependency authenticity or runtime behavior.",
"Only Go build information is inspected; function names, source mappings, type metadata and decompiled code are not recovered.",
"Resource guards bound stable artifact reads to 256 MiB, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete metadata payloads to 8 MiB; these are not operating-system memory or CPU limits.",
"Resource guards bound stable artifact reads and cumulative header searches to 256 MiB each, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete metadata payloads to 8 MiB; these are not operating-system memory or CPU limits.",
...(info !== null &&
(info.go_version === null || info.module_text === null)
? [
+8
View File
@@ -114,6 +114,14 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: `Hopper cannot directly analyze ${target.kind} targets.`,
diagnostics,
};
if (target.kind === "database" && this.platform === "darwin")
return {
status: "unsupported",
code: "target_kind_unsupported",
reason:
"This Hopper macOS launcher cannot start REA's analysis bridge when opening a saved Hopper database. Open an executable target instead; saved-database analysis is unavailable until Hopper exposes a database launch route that runs the bridge script.",
diagnostics,
};
if (target.kind === "database")
return {
status: "supported",
+22 -3
View File
@@ -46,7 +46,7 @@ describe("Hopper provider discovery", () => {
});
it("separates target kind, format, and architecture support", () => {
const hopper = provider(process.execPath);
const hopper = provider(process.execPath, "linux");
expect(hopper.inspectTargetSupport(databaseTarget())).toMatchObject({
status: "supported",
});
@@ -59,12 +59,31 @@ describe("Hopper provider discovery", () => {
diagnostics: { target_kind: "archive", target_format: "asar" },
});
});
it("fails closed for saved Hopper databases on macOS without a bridge launch route", () => {
expect(
provider(process.execPath, "darwin").inspectTargetSupport(
databaseTarget(),
),
).toMatchObject({
status: "unsupported",
code: "target_kind_unsupported",
reason: expect.stringContaining("cannot start REA's analysis bridge"),
diagnostics: {
target_kind: "database",
target_format: "analysis-database",
},
});
});
});
const provider = (launcherPath: string): HopperProvider => {
const provider = (
launcherPath: string,
platform: NodeJS.Platform = process.platform,
): HopperProvider => {
const config = parseConfig({ HOPPER_LAUNCHER_PATH: launcherPath });
if (!config.ok) throw config.error;
return new HopperProvider(config.value, silentLogger, {});
return new HopperProvider(config.value, silentLogger, {}, platform);
};
const databaseTarget = (): BinaryTarget => ({
+21 -26
View File
@@ -148,7 +148,7 @@ const found = (query: string, fn: typeof function_) => ({
});
describe("IDA containment capabilities", () => {
it("declares both operations for the headless profile only", () => {
it("declares document identity in both profiles and address resolution only in headless", () => {
const declared = (mode: "attached" | "headless", native = false) =>
idaCapabilities(mode, false, native)
.map(({ operation }) => operation)
@@ -172,35 +172,30 @@ describe("IDA containment capabilities", () => {
"resolve_containing_procedure",
"list_documents",
]);
expect(declared("attached")).toEqual([]);
expect(declared("attached", true)).toEqual([]);
expect(declared("attached")).toEqual(["list_documents"]);
expect(declared("attached", true)).toEqual(["list_documents"]);
expect(
idaCapabilities("attached", false).map(({ operation }) => operation),
"attached profiles do not even declare them",
).not.toContain("list_documents");
"attached profiles declare the bound document but not address resolution",
).toContain("list_documents");
});
it.each(["resolve_containing_procedure", "list_documents"] as const)(
"refuses %s on the legacy attached profile without calling upstream",
async (operation) => {
const { client, producer } = await session({}, "attached");
try {
const result = await client.execute(
operation,
operation === "list_documents" ? {} : { address: "0x1000" },
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(producer.calls.some(({ name }) => name === "lookup_funcs")).toBe(
false,
);
} finally {
expect((await client.close()).ok).toBe(true);
}
},
);
it("refuses address resolution on the legacy attached profile", async () => {
const { client, producer } = await session({}, "attached");
try {
const result = await client.execute("resolve_containing_procedure", {
address: "0x1000",
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(producer.calls.some(({ name }) => name === "lookup_funcs")).toBe(
false,
);
} finally {
expect((await client.close()).ok).toBe(true);
}
});
});
describe("headless IDA containing procedure", () => {
+563
View File
@@ -0,0 +1,563 @@
import { describe, expect, it } from "vitest";
import {
createIdaDiagnosticRedactor,
IDA_DIAGNOSTIC_SOURCE_BYTES,
} from "./IdaDiagnosticRedactor.js";
describe("IDA diagnostic credential redaction", () => {
it.each([false, true])(
"redacts literal credentials once regardless of header order (reversed: %s)",
(reversed) => {
const entries = [
["Authorization", "Bearer fixture"],
["proxy-AUTHORIZATION", "bAsIc fixture-long.$[token]"],
["cOoKiE", "session=fixture-long.$[token]; marker=redacted"],
["X-Evidence", "caller-selected-evidence"],
];
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: Object.fromEntries(reversed ? entries.reverse() : entries),
mode: "attached",
timeoutMs: 1000,
}).text;
expect(
redact(
"Bearer fixture / fixture / bAsIc fixture-long.$[token] / fixture-long.$[token] / session=fixture-long.$[token]; marker=redacted /caller/input?token=visible API_KEY=visible caller-selected-evidence",
),
).toBe(
"␟ / ␟ / ␟ / ␟ / ␟ /caller/input?token=visible API_KEY=visible caller-selected-evidence",
);
},
);
it("redacts configured and HTTP-normalized header values", () => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: {
aUtHoRiZaTiOn: "\r\n \tBearer fixture-secret \t\r\n",
"pRoXy-AuThOrIzAtIoN": "\tBasic proxy-secret ",
CoOkIe: " session=cookie-secret\t",
},
mode: "attached",
timeoutMs: 1000,
}).text;
expect(
redact(
"raw=\r\n \tBearer fixture-secret \t\r\n; wire=Bearer fixture-secret; token=fixture-secret; proxy=Basic proxy-secret; bare=proxy-secret; cookie=session=cookie-secret",
),
).toBe(
"raw=[redacted transport credential]; wire=[redacted transport credential]; token=[redacted transport credential]; proxy=[redacted transport credential]; bare=[redacted transport credential]; cookie=[redacted transport credential]",
);
});
it("does not treat empty HTTP authentication as diagnostic content", () => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: {
Authorization: "\r\n \t \r\n",
"Proxy-Authorization": "",
Cookie: "\t",
},
mode: "attached",
timeoutMs: 1000,
}).text;
const message = "Failed at /caller/input \t with ordinary whitespace";
expect(redact(message)).toBe(message);
});
it("does not reprocess the replacement marker as another credential", () => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer alpha", Cookie: "redacted" },
mode: "attached",
timeoutMs: 1000,
}).text;
expect(redact("alpha redacted")).toBe("␟ ␟");
});
});
const credentialCases: readonly {
readonly name: string;
readonly headers: Readonly<Record<string, string>>;
readonly message: string;
readonly expected: string;
}[] = [
{
name: "duplicate cookie names, quoted values, and equals padding",
headers: {
Cookie:
'session=first-secret; session=second-secret; signed="padded=value=="',
},
message: "first-secret / second-secret / padded=value== / session signed",
expected:
"[redacted transport credential] / [redacted transport credential] / [redacted transport credential] / session signed",
},
{
name: "empty cookie and auth-param values",
headers: { Cookie: 'a=; b=""', Authorization: 'Custom empty=""' },
message: 'Ordinary spaces and "" remain; a b empty are names',
expected: 'Ordinary spaces and "" remain; a b empty are names',
},
{
name: "opaque token68 and explicit one-character credentials",
headers: { Authorization: "Negotiate opaque+/==", Cookie: "a=Q" },
message: "opaque+/== Q Negotiate a",
expected:
"[redacted transport credential] [redacted transport credential] Negotiate a",
},
{
name: "auth-param values retain quoted commas and decode quoted pairs",
headers: {
Authorization: String.raw`Digest first="nonce,part", response="quote\"part", opaque="back\\part", tail=last-secret`,
},
message: String.raw`nonce,part / quote"part / quote\"part / back\part / back\\part / last-secret / Digest first response opaque tail`,
expected:
"[redacted transport credential] / [redacted transport credential] / [redacted transport credential] / [redacted transport credential] / [redacted transport credential] / [redacted transport credential] / Digest first response opaque tail",
},
{
name: "complete parameters before a malformed quoted member",
headers: {
Authorization:
'Digest first=known-secret, broken="unterminated, second=visible',
},
message: "known-secret visible broken second",
expected: "[redacted transport credential] visible broken second",
},
{
name: "unclosed quotes do not create parameters from their contents",
headers: { Authorization: 'Digest broken="unterminated, second=visible' },
message: "unterminated visible broken second",
expected: "unterminated visible broken second",
},
{
name: "malformed cookie members do not hide valid later pairs",
headers: {
Cookie: 'broken; =not-a-value; malformed="unclosed; session=valid-secret',
},
message: "valid-secret broken not-a-value unclosed session",
expected:
"[redacted transport credential] broken not-a-value unclosed session",
},
{
name: "cookie octets remain opaque without URL decoding",
headers: { Cookie: "session=encoded%2Fcredential" },
message: "encoded%2Fcredential / encoded/credential",
expected: "[redacted transport credential] / encoded/credential",
},
];
it.each(credentialCases)(
"parses $name at the configured credential boundary",
({ headers, message, expected }) => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers,
mode: "attached",
timeoutMs: 1000,
}).text;
expect(redact(message)).toBe(expected);
},
);
it("redacts full configured malformed headers without guessing their internal values", () => {
const header = 'Digest broken="unterminated, second=visible';
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: header },
mode: "attached",
timeoutMs: 1000,
});
expect(redactor.text(header)).toBe("[redacted transport credential]");
expect(redactor.json(JSON.stringify(header))).toBe(
'"[redacted transport credential]"',
);
});
const marker = "[redacted transport credential]";
const redactorFor = (header: string, name = "Authorization") =>
createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { [name]: header },
mode: "attached",
timeoutMs: 1000,
});
describe("IDA Basic authentication payload components", () => {
it.each(["aUtHoRiZaTiOn", "pRoXy-AuThOrIzAtIoN"])(
"protects decoded Basic pairs and passwords while preserving user names for %s",
(name) => {
const pair = "fixture-user:fixture-password:tail";
const encoded = Buffer.from(pair, "utf8").toString("base64");
const redactor = redactorFor("bAsIc " + encoded, name);
const reason =
"pair=" +
pair +
"; user=fixture-user; password=fixture-password:tail; wire=" +
encoded;
const safe =
"pair=" +
marker +
"; user=fixture-user; password=" +
marker +
"; wire=" +
marker;
expect(redactor.text(reason + "; path=/caller/input?token=visible")).toBe(
safe + "; path=/caller/input?token=visible",
);
expect(
redactor.json(JSON.stringify({ reason, path: "/caller/input" })),
).toBe(JSON.stringify({ reason: safe, path: "/caller/input" }));
},
);
it.each([
[
":fixture-password",
"pair=:fixture-password; password=fixture-password; empty values leave spaces",
"pair=" + marker + "; password=" + marker + "; empty values leave spaces",
],
[
"fixture-user:",
"pair=fixture-user:; user=fixture-user; empty values leave spaces",
"pair=" + marker + "; user=fixture-user; empty values leave spaces",
],
[
":",
"pair=:; empty values leave spaces",
"pair=" + marker + "; empty values leave spaces",
],
])(
"protects a decoded pair with empty components: %s",
(pair, reason, safe) => {
const encoded = Buffer.from(pair, "utf8").toString("base64");
expect(redactorFor("Basic " + encoded).text(reason)).toBe(safe);
},
);
});
describe("IDA Basic authentication charset and invalid data", () => {
it("retains both strict UTF-8 and exact octet-compatible interpretations when charset is unknown", () => {
const pair = "tést-user:päss£word";
const bytes = Buffer.from(pair, "utf8");
const octets = bytes.toString("latin1");
const redactor = redactorFor("Basic " + bytes.toString("base64"));
const reason =
"utf8=" +
pair +
"; octets=" +
octets +
"; user=tést-user; password=päss£word; octet-user=tést-user; octet-password=päss£word; caller=/caller/input";
expect(redactor.text(reason)).toBe(
"utf8=" +
marker +
"; octets=" +
marker +
"; user=tést-user; password=" +
marker +
"; octet-user=tést-user; octet-password=" +
marker +
"; caller=/caller/input",
);
});
it("preserves exact Latin-1 octets without inventing lossy UTF-8 replacement credentials", () => {
const bytes = Buffer.from("latin-user:pässword", "latin1");
const redactor = redactorFor("Basic " + bytes.toString("base64"));
expect(
redactor.text(
"latin-user:pässword / latin-user / pässword / p\ufffdssword /caller/input",
),
).toBe(
marker + " / latin-user / " + marker + " / p\ufffdssword /caller/input",
);
});
it("preserves a UTF-8 BOM as credential data rather than dropping it", () => {
const pair = "\ufefffixture-user:fixture-password";
const encoded = Buffer.from(pair, "utf8").toString("base64");
expect(
redactorFor("Basic " + encoded).text(pair + " / \ufefffixture-user"),
).toBe(marker + " / \ufefffixture-user");
});
it("keeps decoded Basic values opaque without URL decoding", () => {
const encoded = Buffer.from("fixture-user:p%2Fsecret", "utf8").toString(
"base64",
);
expect(redactorFor("Basic " + encoded).text("p%2Fsecret / p/secret")).toBe(
marker + " / p/secret",
);
});
const canonical = Buffer.from(
"fixture-user:fixture-password",
"utf8",
).toString("base64");
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const nonzeroPadBits = canonical.replace(
/(.)(=)$/u,
(_match, symbol: string, padding: string) =>
(alphabet[alphabet.indexOf(symbol) + 1] ?? "!") + padding,
);
it.each([
{ name: "unpadded", payload: canonical.slice(0, -1) },
{ name: "at most two padding characters", payload: canonical + "=" },
])(
"accepts the supported normalized Basic Base64 spelling: $name",
({ payload }) => {
const redactor = redactorFor("Basic " + payload);
expect(
redactor.text(
"fixture-user:fixture-password / fixture-password / fixture-user",
),
).toBe(marker + " / " + marker + " / fixture-user");
},
);
it.each([
{
name: "non-alphabet character",
payload: canonical.slice(0, 4) + "!" + canonical.slice(4),
},
{
name: "embedded whitespace",
payload: canonical.slice(0, 4) + " " + canonical.slice(4),
},
{ name: "more than two pad characters", payload: canonical + "==" },
{ name: "nonzero pad bits", payload: nonzeroPadBits },
{
name: "missing colon separator",
payload: Buffer.from("fixture-user", "utf8").toString("base64"),
},
{
name: "ASCII control in user-pass",
payload: Buffer.from("fixture-user:fixture-password\n", "utf8").toString(
"base64",
),
},
{
name: "auth-param-shaped malformed Basic data",
payload: "credential=fixture-password",
},
])("retains only opaque configured Basic data for $name", ({ payload }) => {
const header = "Basic " + payload;
const redactor = redactorFor(header);
expect(redactor.text(header + " / " + payload)).toBe(
marker + " / " + marker,
);
expect(redactor.text("fixture-user / fixture-password /caller/input")).toBe(
"fixture-user / fixture-password /caller/input",
);
});
it("does not decode another scheme's base64-shaped opaque payload", () => {
const encoded = Buffer.from(
"fixture-user:fixture-password",
"utf8",
).toString("base64");
const redactor = redactorFor("Custom " + encoded);
expect(redactor.text(encoded + " / fixture-user / fixture-password")).toBe(
marker + " / fixture-user / fixture-password",
);
});
});
it("redacts protocol-decoded Basic credentials and password without treating ordinary names as secrets", () => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: {
Authorization: `Basic ${Buffer.from("user:secret").toString("base64")}`,
},
});
expect(redactor.text("user:secret / secret / user")).toBe(
"[redacted transport credential] / [redacted transport credential] / user",
);
expect(redactor.json('"\\u0073ecret"')).toBe(
'"[redacted transport credential]"',
);
});
it.each(["text", "json"] as const)(
"bounds short-secret diagnostic expansion before %s redaction",
(format) => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: { Cookie: "a=Q" },
});
const result = redactor[format]("Q".repeat(2 * 1024 * 1024));
expect(result).toBe("[IDA diagnostic omitted: exceeds 65536 UTF-8 bytes]");
},
);
it.each(["text", "json"] as const)(
"applies the UTF-8 source-byte budget before %s processing even without credentials",
(format) => {
const atBudget = "£".repeat(IDA_DIAGNOSTIC_SOURCE_BYTES / 2);
const overBudget = atBudget + "£";
const configuredHeaders: readonly Readonly<Record<string, string>>[] = [
{},
{ Cookie: "a=Q" },
];
for (const headers of configuredHeaders) {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers,
});
expect(redactor[format](atBudget)).toBe(atBudget);
expect(redactor[format](overBudget)).toBe(
"[IDA diagnostic omitted: exceeds 65536 UTF-8 bytes]",
);
}
},
);
it.each(["dXNlcjpzZWNyZXQ=", "dXNlcjpzZWNyZXQ", "dXNlcjpzZWNyZXQ=="])(
"redacts accepted canonical Basic spellings for %s",
(payload) => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: { Authorization: `Basic ${payload}` },
});
const marker = "[redacted transport credential]";
expect(
redactor.text(
"dXNlcjpzZWNyZXQ= / dXNlcjpzZWNyZXQ / user:secret / secret",
),
).toBe(`${marker} / ${marker} / ${marker} / ${marker}`);
},
);
describe("overlapping IDA credential source spans", () => {
it.each([false, true])(
"unions all overlapping plain credentials before writing (reversed: %s)",
(reversed) => {
const entries = [
["Authorization", "Custom X]"],
["Cookie", "a=]SECRET"],
];
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: Object.fromEntries(reversed ? entries.reverse() : entries),
mode: "attached",
timeoutMs: 1000,
});
const result = redactor.text(
"Rejected X]SECRET at /caller/input?cursor=visible",
);
expect(result).toBe("Rejected ␟ at /caller/input?cursor=visible");
expect(result).not.toContain("]SECRET");
},
);
it("unions a chain of different overlapping credentials", () => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Custom abc", Cookie: "a=cde; b=def" },
mode: "attached",
timeoutMs: 1000,
});
expect(redactor.text("prefix abcdefg suffix")).toBe(
"prefix " + marker + "g suffix",
);
});
it("merges dense same-credential overlaps without retaining a per-match collection", () => {
const redactor = redactorFor("Custom AA");
const result = redactor.text("A".repeat(IDA_DIAGNOSTIC_SOURCE_BYTES));
expect(result.length).toBe(marker.length);
expect(result).toBe(marker);
});
it("retains adjacent disjoint credential occurrences", () => {
expect(redactorFor("Custom AA").text("AA-AA")).toBe(marker + "-" + marker);
});
});
describe("credential-safe redaction markers", () => {
it.each([
{ secret: "]SECRET", source: "XSECRET", expected: "␟SECRET" },
{ secret: "SECRET[", source: "SECRETX", expected: "SECRET␟" },
{
secret: "a[redacted transport credential]b",
source: "aXb",
expected: "a␟b",
},
{ secret: "redacted", source: "redacted", expected: "␟" },
])(
"does not create $secret from replacement text or boundaries",
({ secret, source, expected }) => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: { Authorization: "Custom " + secret, Cookie: "a=X" },
});
expect(redactor.text(source)).toBe(expected);
expect(
redactor.json(
JSON.stringify({ reason: source, context: "/caller/input" }),
),
).toBe(JSON.stringify({ reason: expected, context: "/caller/input" }));
expect(redactor.json('"' + source + '",x')).toBe('"' + expected + '",x');
},
);
it("chooses a different graphic scalar when a decoded Basic credential contains the first marker", () => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: {
Authorization:
"Basic " + Buffer.from("user:␟", "utf8").toString("base64"),
Cookie: "a=]SECRET",
},
});
expect(redactor.text("␟ at /caller/input")).toBe("␠ at /caller/input");
expect(redactor.json(String.raw`{"reason":"␟","context":"kept"}`)).toBe(
'{"reason":"␠","context":"kept"}',
);
});
});
describe("unclassified diagnostic blocks and message ownership", () => {
it.each<Readonly<Record<string, string>>>([{}, { Cookie: "a=Q" }])(
"budgets the entire source block list before redaction: %s",
(headers) => {
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers,
});
const first = "£".repeat(IDA_DIAGNOSTIC_SOURCE_BYTES / 4);
expect(redactor.blocks([first, first])).toBe(
"[IDA diagnostic omitted: exceeds 65536 UTF-8 bytes]",
);
expect(redactor.blocks([first, first.slice(1)])).toBe(
first + "\n" + first.slice(1),
);
},
);
it("keeps complete quoted plain credentials while preserving explicit JSON semantics", () => {
const redactor = redactorFor(String.raw`Custom folder\tmp`);
const source = String.raw`"folder\tmp at /caller/input"`;
expect(redactor.blocks([source])).toBe(
'"[redacted transport credential] at /caller/input"',
);
expect(redactor.json(source)).toBe(source);
});
it("sanitizes the omission notice as diagnostic text", () => {
const redactor = redactorFor("Custom omitted");
expect(
redactor.blocks(["Q".repeat(IDA_DIAGNOSTIC_SOURCE_BYTES + 1)]),
).toContain("[redacted transport credential]");
expect(
redactor.text("Q".repeat(IDA_DIAGNOSTIC_SOURCE_BYTES + 1)),
).not.toContain("omitted");
});
});
+243
View File
@@ -0,0 +1,243 @@
import {
redactIdaJsonDiagnostic,
redactIdaUnclassifiedDiagnostic,
} from "./IdaJsonDiagnostic.js";
import {
IDA_REDACTION_MARKER,
joinIdaDiagnosticParts,
writeIdaDiagnostic,
type IdaDiagnosticMatch,
} from "./IdaDiagnosticWriter.js";
import type { IdaConfiguration } from "./IdaConfiguration.js";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
// Diagnostic text is bounded before decoding or replacement expansion.
export const IDA_DIAGNOSTIC_SOURCE_BYTES = 64 * 1024;
const omittedDiagnostic = "[IDA diagnostic omitted: exceeds 65536 UTF-8 bytes]";
const boundedDiagnostic =
(redact: (message: string) => string) =>
(message: string): string =>
Buffer.byteLength(message, "utf8") > IDA_DIAGNOSTIC_SOURCE_BYTES
? redact(omittedDiagnostic)
: redact(message);
/** RFC 7617 user-pass candidates; the configured request carries no charset. */
const basicPayloads = (payload: string): readonly string[] => {
if (!/^[A-Za-z\d+/]+={0,2}$/u.test(payload)) return [];
const octets = Buffer.from(payload, "base64");
const canonical = octets.toString("base64");
// Match the supported padded/unpadded policy without accepting Buffer's
// permissive alphabet or discarding nonzero pad bits.
if (
canonical.replace(/=+$/u, "") !== payload.replace(/=+$/u, "") ||
octets.indexOf(0x3a) < 0
)
return [];
// Basic user-id and password exclude ASCII control characters.
if (octets.some((value) => value < 0x20 || value === 0x7f)) return [];
const candidates = [octets.toString("latin1")];
try {
candidates.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(octets),
);
} catch {
// Invalid UTF-8 has no decoded Unicode candidate. Exact Latin-1 preserves
// each octet; do not invent a replacement-character credential.
}
// The default charset is unknown. Retain both supported interpretations
// rather than selecting one or normalizing their configured source bytes.
return [
canonical,
canonical.replace(/=+$/u, ""),
...candidates.flatMap((pair) => {
const separator = pair.indexOf(":");
// The complete pair and password are credentials; a bare user-id is not.
return [pair, pair.slice(separator + 1)].filter(
(value) => value.length > 0,
);
}),
];
};
/** RFC 9110 credentials: any scheme, followed by opaque data or auth-params. */
const authorizationPayloads = (header: string): readonly string[] => {
const credentials = header.match(
/^([!#$%&'*+.^_`|~\dA-Za-z-]+)[ \t]+([\s\S]+)$/u,
);
const payload = credentials?.[2];
if (payload === undefined) return [];
if (credentials?.[1]?.toLowerCase() === "basic")
return [payload, ...basicPayloads(payload)];
const values = [payload];
// A sticky parse cannot reinterpret commas inside an incomplete quoted value.
// Keep complete parameters preceding malformed input; the opaque payload above
// remains a known credential even when the parameter grammar is not satisfied.
const parameter =
/[ \t,]*[!#$%&'*+.^_`|~\dA-Za-z-]+[ \t]*=[ \t]*("(?:[^"\\\r\n]|\\[^\r\n])*"|[!#$%&'*+.^_`|~\dA-Za-z-]+)[ \t]*(?:,|$)/uy;
let cursor = 0;
while (cursor < payload.length) {
parameter.lastIndex = cursor;
const match = parameter.exec(payload);
if (match === null) break;
cursor = parameter.lastIndex;
const value = match[1] ?? "";
if (!value.startsWith('"')) {
values.push(value);
continue;
}
const quoted = value.slice(1, -1);
if (quoted.length > 0)
values.push(value, quoted, quoted.replace(/\\([^\r\n])/gu, "$1"));
}
return values;
};
/** RFC 6265 cookie-pairs: retain duplicates and '=' inside values. */
const cookiePayloads = (header: string): readonly string[] =>
header.split(";").flatMap((pair) => {
const value = pair.match(
/^[ \t]*[!#$%&'*+.^_`|~\dA-Za-z-]+[ \t]*=[ \t]*(.*?)[ \t]*$/u,
)?.[1];
if (value === undefined) return [];
const payload =
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
// Cookie quotes wrap cookie-octets; they do not introduce escape syntax.
if (!/^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]+$/u.test(payload))
return [];
return [value, payload];
});
/** Choose one marker for this credential owner, including replacement boundaries. */
const redactionMarker = (secrets: readonly string[]): string => {
// Crossing either edge of the default marker requires a square bracket.
// A credential wholly inside the marker must also be excluded.
if (
secrets.every(
(secret) =>
!secret.includes("[") &&
!secret.includes("]") &&
!IDA_REDACTION_MARKER.includes(secret),
)
)
return IDA_REDACTION_MARKER;
const characters = new Set<string>();
for (const secret of secrets)
for (const character of secret) characters.add(character);
// A graphic scalar absent from every credential cannot contain or connect
// a credential, and can be written verbatim inside a JSON string.
const graphic = /[\p{L}\p{N}\p{P}\p{S}]/u;
for (let point = 0x241f; point <= 0x10ffff; point++) {
if (point >= 0xd800 && point <= 0xdfff) continue;
const character = String.fromCodePoint(point);
if (graphic.test(character) && !characters.has(character)) return character;
}
throw new AnalysisProtocolError(
"Configured IDA credentials leave no available diagnostic redaction character.",
);
};
/** Own configured credential interpretation for all IDA error diagnostics. */
export const createIdaDiagnosticRedactor = (
config?: IdaConfiguration,
): Readonly<{
text(message: string): string;
json(message: string): string;
blocks(messages: readonly string[]): string;
message(prefix: string, ownedBody: string): string;
}> => {
const headers =
config !== undefined && "headers" in config
? Object.entries(config.headers)
: [];
const values = headers.flatMap(([name, value]) => {
if (!/^(?:authorization|proxy-authorization|cookie)$/iu.test(name))
return [];
// Fetch normalizes surrounding HTTP whitespace before sending headers.
const normalized = value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/gu, "");
if (normalized.length === 0) return [];
return [
value,
normalized,
...(name.toLowerCase() === "cookie"
? cookiePayloads(normalized)
: authorizationPayloads(normalized)),
];
});
const secrets = [...new Set(values.filter((value) => value.length > 0))].sort(
(left, right) => right.length - left.length,
);
const replacement =
secrets.length === 0 ? IDA_REDACTION_MARKER : redactionMarker(secrets);
const alternatives = secrets
.map((secret) => secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
.join("|");
// Look ahead at each source position; a global consuming match loses overlap.
// Longest-first alternatives cover every shorter match at the same position.
const pattern =
alternatives.length === 0
? undefined
: new RegExp("(?=(" + alternatives + "))", "gu");
const matches = function* (value: string): Iterable<IdaDiagnosticMatch> {
if (pattern === undefined) return;
let pending: IdaDiagnosticMatch | undefined;
for (const match of value.matchAll(pattern)) {
const credential = match[1];
if (credential === undefined || credential.length === 0)
throw new AnalysisProtocolError(
"IDA diagnostic matching omitted its credential span.",
);
const span = { start: match.index, end: match.index + credential.length };
if (pending !== undefined && span.start < pending.end) {
pending = {
start: pending.start,
end: Math.max(pending.end, span.end),
};
} else {
if (pending !== undefined) yield pending;
pending = span;
}
}
if (pending !== undefined) yield pending;
};
const text = boundedDiagnostic((message) =>
writeIdaDiagnostic(message, matches(message), replacement),
);
const json = boundedDiagnostic((message) =>
secrets.length === 0
? message
: redactIdaJsonDiagnostic(message, matches, replacement),
);
return {
text,
json,
blocks(messages) {
let bytes = 0;
let sourceUnits = 0;
for (let index = 0; index < messages.length; index++) {
const value = messages[index]!;
bytes += Buffer.byteLength(value, "utf8") + (index === 0 ? 0 : 1);
sourceUnits += value.length + (index === 0 ? 0 : 1);
if (bytes > IDA_DIAGNOSTIC_SOURCE_BYTES) return text(omittedDiagnostic);
}
function* parts(): Generator<string> {
for (let index = 0; index < messages.length; index++) {
if (index > 0) yield "\n";
const value = messages[index]!;
yield secrets.length === 0 || value.length === 0
? value
: redactIdaUnclassifiedDiagnostic(value, matches, replacement);
}
}
return joinIdaDiagnosticParts(parts(), sourceUnits);
},
message(prefix, ownedBody) {
// Normalized wire headers and supported decoded candidates have no LF.
// Preserve the already-owned body without inspecting its replacement text.
return joinIdaDiagnosticParts(
[text(prefix), "\n", ownedBody],
prefix.length + 1 + ownedBody.length,
);
},
};
};
+85
View File
@@ -0,0 +1,85 @@
import { constants } from "node:buffer";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { bufferedJsonParts } from "../domain/jsonSerialization.js";
export const IDA_REDACTION_MARKER = "[redacted transport credential]";
export type IdaDiagnosticMatch = Readonly<{ start: number; end: number }>;
type DiagnosticRepresentation = "redacted-output" | "json-character-run";
/** An owned diagnostic cannot be represented as one runtime string. */
export class IdaDiagnosticResourceError extends AnalysisResourceConstraintError {
constructor(
inputCodeUnits: number,
representedCodeUnits: number,
representation: DiagnosticRepresentation = "redacted-output",
) {
super(
"ida_diagnostic_redaction",
"memory",
representation === "redacted-output"
? "Redacted IDA diagnostics exceed the runtime's maximum string length."
: "Temporary IDA diagnostic JSON character decoding exceeds the runtime's maximum string length.",
{
boundary: "ida-diagnostic-redaction",
representation,
input_code_units: inputCodeUnits,
...(representation === "redacted-output"
? { output_code_units_at_least: representedCodeUnits }
: { json_decoding_code_units_at_least: representedCodeUnits }),
max_string_code_units: constants.MAX_STRING_LENGTH,
},
);
}
}
/** Account for the complete representation before allocating its final string. */
export const joinIdaDiagnosticParts = (
parts: Iterable<string>,
inputCodeUnits: number,
): string => {
function* checkedParts(): Generator<string> {
let outputCodeUnits = 0;
for (const part of parts) {
outputCodeUnits += part.length;
if (outputCodeUnits > constants.MAX_STRING_LENGTH)
throw new IdaDiagnosticResourceError(inputCodeUnits, outputCodeUnits);
yield part;
}
}
return [...bufferedJsonParts(checkedParts())].join("");
};
/** Write ordered source spans without retaining a per-match collection. */
export const writeIdaDiagnostic = (
text: string,
matches: Iterable<IdaDiagnosticMatch>,
replacement: string = IDA_REDACTION_MARKER,
): string => {
function* parts(): Generator<string> {
let cursor = 0;
let pending: IdaDiagnosticMatch | undefined;
for (const match of matches) {
if (pending !== undefined && match.start < pending.end) {
pending = {
start: pending.start,
end: Math.max(pending.end, match.end),
};
continue;
}
if (pending !== undefined) {
yield text.slice(cursor, pending.start);
yield replacement;
cursor = pending.end;
}
pending = match;
}
if (pending !== undefined) {
yield text.slice(cursor, pending.start);
yield replacement;
cursor = pending.end;
}
yield text.slice(cursor);
}
return joinIdaDiagnosticParts(parts(), text.length);
};
+8 -5
View File
@@ -52,13 +52,16 @@ const inspectPrivateWorkspace = (): DoctorProviderCheck => {
return {
name: "private_workspace",
ok: privacy.available,
code: privacy.available ? null : "unsupported_host",
code: privacy.available ? null : privacy.code,
detail: privacy.available
? `Private workspace authority is available (${privacy.proof}).`
: privacy.reason,
remediation: privacy.available
? null
: "Use the matching Windows x64 REA package and a writable local NTFS temporary directory.",
classification: "unsupported_host",
remediation: privacy.available ? null : privacy.remediation,
classification:
!privacy.available && privacy.code === "runtime_missing"
? "missing_dependency"
: !privacy.available && privacy.code === "runtime_unavailable"
? "runtime_failure"
: "unsupported_host",
};
};
+337
View File
@@ -0,0 +1,337 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
IdaDiagnosticResourceError,
writeIdaDiagnostic,
} from "./IdaDiagnosticWriter.js";
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
import { redactIdaJsonDiagnostic } from "./IdaJsonDiagnostic.js";
const runtime = vi.hoisted(() => ({ limit: undefined as number | undefined }));
vi.mock("node:buffer", async (importOriginal) => {
const original = await importOriginal<typeof import("node:buffer")>();
return {
...original,
constants: {
...original.constants,
get MAX_STRING_LENGTH() {
return runtime.limit ?? original.constants.MAX_STRING_LENGTH;
},
},
};
});
afterEach(() => {
runtime.limit = undefined;
});
const marker = "[redacted transport credential]";
const forCredential = (credential: string) =>
createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: `Custom ${credential}` },
mode: "attached",
timeoutMs: 1000,
});
describe("IDA JSON diagnostic source preservation", () => {
it("matches equivalent JSON encodings without enumerating credential spellings", () => {
const redactor = forCredential("<s/>");
const encodings = [
["<", String.raw`\u003c`, String.raw`\u003C`],
["s", String.raw`\u0073`],
["/", String.raw`\/`, String.raw`\u002f`, String.raw`\u002F`],
[">", String.raw`\u003e`, String.raw`\u003E`],
].reduce<string[]>(
(prefixes, units) =>
prefixes.flatMap((prefix) => units.map((unit) => prefix + unit)),
[""],
);
for (const encoded of encodings)
expect(redactor.json(`{"reason":"${encoded}"}`)).toBe(
`{"reason":"${marker}"}`,
);
});
it("preserves unrelated spelling inside a matching string", () => {
const source = String.raw`{"reason":"\u003cpublic\u003e\/asset \u003csecret\u003e \ud83d\uDE00"}`;
expect(forCredential("<secret>").json(source)).toBe(
String.raw`{"reason":"\u003cpublic\u003e\/asset ` +
marker +
String.raw` \ud83d\uDE00"}`,
);
});
it("retains duplicate fields, whitespace and exact numeric lexemes", () => {
const source =
'{\n "\\u003csecret\\u003e": 9007199254740993, "n": -0, "n": 1.00e+03, "value": "<secret>"\n}';
expect(forCredential("<secret>").json(source)).toBe(
`{\n "${marker}": 9007199254740993, "n": -0, "n": 1.00e+03, "value": "${marker}"\n}`,
);
});
it.each([
{ credential: "a😀b", encoded: String.raw`a\ud83d\uDE00b` },
{ credential: "a\ud800b", encoded: String.raw`a\uD800b` },
{ credential: "a\nb", encoded: String.raw`a\nb` },
{ credential: 'a"b', encoded: String.raw`a\u0022b` },
])("maps UTF-16 source spans for $encoded", ({ credential, encoded }) => {
expect(forCredential(credential).json(`"before ${encoded} after"`)).toBe(
`"before ${marker} after"`,
);
});
it.each([
{
source: String.raw`{broken: "\u003csecret\u003e",`,
expected: `{broken: "${marker}",`,
},
{
source: String.raw`{"x":"\u003csecret\u003e`,
expected: `{"x":"${marker}`,
},
{
source: String.raw`{"x":"\u003csecret\u003e\q"}`,
expected: `{"x":"${marker}\\q"}`,
},
])(
"preserves malformed source around known complete characters: $source",
({ source, expected }) => {
expect(forCredential("<secret>").json(source)).toBe(expected);
},
);
it("does not join decoded fragments across invalid escapes", () => {
const source = String.raw`{"x":"<sec\qret>"}`;
expect(forCredential("<secret>").json(source)).toBe(source);
});
it("retains literal matches crossing invalid JSON units", () => {
expect(
forCredential(String.raw`foo\qbar`).json(String.raw`{"x":"foo\qbar"}`),
).toBe(`{"x":"${marker}"}`);
});
it.each(
["b", "f", "n", "r", "t", "/", '"', "\\", "u0061"].map(
(escape) => "folder\\" + escape + "tmp",
),
)(
"retains literal credentials in ambiguous malformed fragments: %s",
(credential) => {
const redactor = forCredential(credential);
for (const [before, after] of [
["Rejected ", ""],
['Rejected "', '"'],
['{"reason":"Rejected ', ""],
['{"reason":"Rejected ', String.raw`\q"}`],
])
expect(redactor.json(before + credential + after)).toBe(
before + marker + after,
);
expect(redactor.json('"' + credential + '"')).toBe(
'"' + credential + '"',
);
expect(redactor.json(JSON.stringify("Rejected " + credential))).toBe(
JSON.stringify("Rejected " + marker),
);
},
);
it("uses decoded values instead of matching unrelated escape syntax", () => {
const source = String.raw`{"x":"\u003cpublic\u003e","y":"u003c"}`;
expect(forCredential("u003c").json(source)).toBe(
String.raw`{"x":"\u003cpublic\u003e","y":"` + marker + '"}',
);
});
it("retains literal opaque matches spanning quoted fragments in plain bodies", () => {
const credential = 'prefix"quoted"suffix';
expect(
forCredential(credential).json(
`Auth Custom ${credential}; payload ${credential}; path /caller/input`,
),
).toBe(`Auth ${marker}; payload ${marker}; path /caller/input`);
});
it("replaces adjacent matches once without rescanning the marker", () => {
expect(
forCredential("redacted").json(String.raw`"redacted\u0072edacted"`),
).toBe('"␟␟"');
});
it("handles unquoted JSON fragments while retaining ordinary text semantics", () => {
const source = String.raw`\u003csecret\u003e`;
const redactor = forCredential("<secret>");
expect(redactor.text(source)).toBe(source);
expect(redactor.json(source)).toBe(marker);
const invalid = String.raw`<sec\qret>`;
expect(redactor.json(invalid)).toBe(invalid);
const unrelated = String.raw`"\u003cpublic\u003e"`;
expect(forCredential("u003c").json(unrelated)).toBe(unrelated);
expect(forCredential("u003c").json(unrelated.slice(1, -1))).toBe(
"\\" + marker + String.raw`public\u003e`,
);
});
it("leaves ordinary text diagnostics and credential-free configurations unchanged", () => {
const source = String.raw`{"x":"\u003csecret\u003e"}`;
expect(forCredential("<secret>").text(source)).toBe(source);
const redactor = createIdaDiagnosticRedactor({
headers: {},
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
});
expect(redactor.json(source)).toBe(source);
});
});
describe("IDA diagnostic output resource bounds", () => {
it.each(["text", "json"] as const)(
"bounds %s expansion using the runtime string limit",
(format) => {
runtime.limit = 64;
const redactor = forCredential("Q");
const prefix = format === "json" ? '"' : "";
const suffix = prefix;
expect(redactor[format](prefix + "QQ" + suffix)).toBe(
prefix + marker + marker + suffix,
);
let failure: unknown;
try {
redactor[format](prefix + "QQQ" + suffix);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(IdaDiagnosticResourceError);
if (!(failure instanceof IdaDiagnosticResourceError))
throw new Error("Expected an owned diagnostic resource failure");
expect(failure.reportedLimits).toMatchObject({
boundary: "ida-diagnostic-redaction",
max_string_code_units: 64,
});
expect(projectAnalysisError(failure)).toMatchObject({
code: "resource_constraint",
details: { resource: "memory" },
});
},
);
it("bounds temporary JSON character decoding before wrapping an oversized run", () => {
runtime.limit = 64;
const redactor = forCredential("Z");
expect(redactor.json("Q".repeat(62))).toBe("Q".repeat(62));
let failure: unknown;
try {
redactor.json("Q".repeat(63));
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(IdaDiagnosticResourceError);
if (!(failure instanceof IdaDiagnosticResourceError))
throw new Error("Expected a temporary decoding resource failure");
expect(failure.message).toContain("Temporary");
expect(failure.reportedLimits).toEqual({
boundary: "ida-diagnostic-redaction",
representation: "json-character-run",
input_code_units: 63,
json_decoding_code_units_at_least: 65,
max_string_code_units: 64,
});
});
it("closes the first match stream if priming the second stream fails", () => {
runtime.limit = 64;
const source = "Q".repeat(63);
let closed = false;
function* matches() {
try {
yield { start: 0, end: 1 };
} finally {
closed = true;
}
}
expect(() => redactIdaJsonDiagnostic(source, matches)).toThrow(
IdaDiagnosticResourceError,
);
expect(closed).toBe(true);
});
it("stops consuming a lazy match stream before oversized output is allocated", () => {
runtime.limit = 32;
let consumed = 0;
function* matches() {
for (let index = 0; index < 10000; index++) {
consumed++;
yield { start: index, end: index + 1 };
}
}
expect(() => writeIdaDiagnostic("Q".repeat(10000), matches())).toThrow(
IdaDiagnosticResourceError,
);
expect(consumed).toBeLessThan(10);
});
});
describe("overlapping raw and decoded JSON credential spans", () => {
const overlappingMarker = "␟";
const overlappingRedactor = () =>
createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Custom X]", Cookie: "a=]SECRET" },
mode: "attached",
timeoutMs: 1000,
});
it.each([
String.raw`{"reason":"X]SECRET","n":1.00e+03}`,
String.raw`{"reason":"X\u005dSECRET","n":1.00e+03}`,
])("unions decoded overlaps before monotone source mapping: %s", (source) => {
expect(overlappingRedactor().json(source)).toBe(
'{"reason":"' + overlappingMarker + '","n":1.00e+03}',
);
});
it.each(["X]SECRET", String.raw`X\u005dSECRET`])(
"unions raw/recovered overlaps in a malformed document: %s",
(source) => {
expect(
overlappingRedactor().json("Rejected " + source + " at /caller/input"),
).toBe("Rejected " + overlappingMarker + " at /caller/input");
},
);
it("maps a merged overlap containing an escaped surrogate pair", () => {
const encoded = Buffer.from("fixture-user:😀X]", "utf8").toString("base64");
const redactor = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Basic " + encoded, Cookie: "a=]SECRET" },
mode: "attached",
timeoutMs: 1000,
});
expect(
redactor.json(
String.raw`{"reason":"\ud83d\ude00X\u005dSECRET","context":"kept"}`,
),
).toBe('{"reason":"' + overlappingMarker + '","context":"kept"}');
});
it("unions dense escaped decoded overlaps lazily", () => {
const source = '"' + String.raw`\u0041`.repeat(4096) + '"';
const result = forCredential("AA").json(source);
expect(result.length).toBe(marker.length + 2);
expect(result).toBe('"' + marker + '"');
});
});
it("bounds the final multi-block diagnostic representation", () => {
runtime.limit = 64;
const redactor = forCredential("Q");
expect(() => redactor.blocks(["QQ", "QQ"])).toThrow(
IdaDiagnosticResourceError,
);
});
it("bounds context plus the already-owned diagnostic body before joining", () => {
runtime.limit = 64;
const redactor = forCredential("Q");
const body = redactor.text("QQ");
expect(() => redactor.message("ctx", body)).toThrow(
IdaDiagnosticResourceError,
);
});
+216
View File
@@ -0,0 +1,216 @@
import { constants } from "node:buffer";
import { createScanner, type ScanError, type SyntaxKind } from "jsonc-parser";
import {
IDA_REDACTION_MARKER,
IdaDiagnosticResourceError,
writeIdaDiagnostic,
type IdaDiagnosticMatch,
} from "./IdaDiagnosticWriter.js";
// jsonc-parser declares ambient const enums, which cannot be imported as values
// under verbatimModuleSyntax. Keep its scanner codes local and type-checked.
const JSON_STRING_LITERAL = 10 satisfies SyntaxKind;
const JSON_EOF = 17 satisfies SyntaxKind;
const JSON_NO_ERROR = 0 satisfies ScanError;
/** Matches are non-overlapping and ordered by their UTF-16 source offsets. */
type Matches = (value: string) => Iterable<IdaDiagnosticMatch>;
// Complete JSON character runs remain meaningful even in an unfinished or
// malformed string. Invalid units separate runs instead of joining their text.
const JSON_CHARACTER_RUN =
/(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\dA-Fa-f]{4}))+/gu;
function* stringMatches(
encoded: string,
decoded: string,
offset: number,
matches: Matches,
): Generator<IdaDiagnosticMatch> {
// Match offsets and JSON escape widths are both UTF-16 units. No source bytes
// outside a matched value need to be normalized or serialized again.
let sourceCursor = 0;
let decodedCursor = 0;
const sourceOffset = (boundary: number): number => {
while (decodedCursor < boundary) {
sourceCursor +=
encoded[sourceCursor] === "\\"
? encoded[sourceCursor + 1] === "u"
? 6
: 2
: 1;
decodedCursor++;
}
return offset + sourceCursor;
};
for (const match of matches(decoded))
yield {
start: sourceOffset(match.start),
end: sourceOffset(match.end),
};
}
function* characterRunMatches(
encoded: string,
offset: number,
matches: Matches,
inputCodeUnits: number,
): Generator<IdaDiagnosticMatch> {
for (const run of encoded.matchAll(JSON_CHARACTER_RUN)) {
// The run grammar admits only complete JSON characters. JSON.parse owns
// their decoded meaning; malformed units remain verbatim between runs.
const decodingCodeUnits = run[0].length + 2;
if (decodingCodeUnits > constants.MAX_STRING_LENGTH)
throw new IdaDiagnosticResourceError(
inputCodeUnits,
decodingCodeUnits,
"json-character-run",
);
const decoded: unknown = JSON.parse('"' + run[0] + '"');
if (typeof decoded !== "string")
throw new TypeError("A JSON character run did not decode to a string.");
yield* stringMatches(run[0], decoded, offset + run.index, matches);
}
}
function* decodedMatches(
text: string,
matches: Matches,
): Generator<IdaDiagnosticMatch> {
let scannedEnd = 0;
const scanner = createScanner(text);
while (scanner.scan() !== JSON_EOF) {
if (scanner.getToken() !== JSON_STRING_LITERAL) continue;
const start = scanner.getTokenOffset();
const end = start + scanner.getTokenLength();
if (scannedEnd < start)
yield* characterRunMatches(
text.slice(scannedEnd, start),
scannedEnd,
matches,
text.length,
);
if (scanner.getTokenError() === JSON_NO_ERROR)
yield* stringMatches(
text.slice(start + 1, end - 1),
scanner.getTokenValue(),
start + 1,
matches,
);
else
yield* characterRunMatches(
text.slice(start + 1, end),
start + 1,
matches,
text.length,
);
scannedEnd = end;
}
yield* characterRunMatches(
text.slice(scannedEnd),
scannedEnd,
matches,
text.length,
);
}
function* literalMatches(
text: string,
matches: Matches,
validDocument: boolean,
): Generator<IdaDiagnosticMatch> {
if (!validDocument) {
// Recovery cannot establish a unique meaning for a malformed document.
yield* matches(text);
return;
}
const scanner = createScanner(text);
let complete: IdaDiagnosticMatch | undefined;
let ended = false;
for (const match of matches(text)) {
while (!ended && (complete === undefined || complete.end <= match.start)) {
complete = undefined;
while (scanner.scan() !== JSON_EOF) {
if (scanner.getToken() !== JSON_STRING_LITERAL) continue;
const start = scanner.getTokenOffset();
complete = { start, end: start + scanner.getTokenLength() };
break;
}
if (complete === undefined) ended = true;
}
// Only a complete string in a valid document owns decoded-only semantics.
// Raw matches crossing tokens remain visible to the credential boundary.
if (
complete === undefined ||
match.start < complete.start ||
match.end > complete.end
)
yield match;
}
}
function* mergeMatches(
left: Iterable<IdaDiagnosticMatch>,
right: Iterable<IdaDiagnosticMatch>,
): Generator<IdaDiagnosticMatch> {
const leftIterator = left[Symbol.iterator]();
const rightIterator = right[Symbol.iterator]();
try {
let leftMatch = leftIterator.next();
let rightMatch = rightIterator.next();
while (!leftMatch.done || !rightMatch.done) {
if (
rightMatch.done ||
(!leftMatch.done && leftMatch.value.start <= rightMatch.value.start)
) {
if (leftMatch.done) break;
yield leftMatch.value;
leftMatch = leftIterator.next();
} else {
yield rightMatch.value;
rightMatch = rightIterator.next();
}
}
} finally {
try {
leftIterator.return?.();
} finally {
rightIterator.return?.();
}
}
}
/** Redact complete or malformed JSON diagnostics without rewriting unrelated source. */
export const redactIdaJsonDiagnostic = (
text: string,
matches: Matches,
replacement: string = IDA_REDACTION_MARKER,
): string => {
let validDocument = false;
try {
JSON.parse(text);
validDocument = true;
} catch {
// Keep both raw and decoded interpretations when the document is incomplete.
}
return writeIdaDiagnostic(
text,
mergeMatches(
literalMatches(text, matches, validDocument),
decodedMatches(text, matches),
),
replacement,
);
};
/** Untyped producer text retains both literal and JSON character interpretations. */
export const redactIdaUnclassifiedDiagnostic = (
text: string,
matches: Matches,
replacement: string = IDA_REDACTION_MARKER,
): string =>
writeIdaDiagnostic(
text,
mergeMatches(matches(text), decodedMatches(text, matches)),
replacement,
);
+264 -22
View File
@@ -1,31 +1,68 @@
import { describe, expect, it } from "vitest";
import { inspect } from "node:util";
import { SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createIdaMcpConnection,
decodeIdaToolResult,
redactIdaTransportFailure,
} from "./IdaMcpConnection.js";
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
describe("upstream IDA tool result decoding", () => {
it("redacts configured transport authentication without redacting caller evidence", () => {
const error = redactIdaTransportFailure(
new Error(
"Invalid Authorization value Bearer fixture-secret at /caller/input",
),
{
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-secret" },
mode: "attached",
timeoutMs: 1000,
},
);
expect(error.message).not.toContain("fixture-secret");
expect(error.message).toContain("/caller/input");
it("leaves successful legacy, structured, and native evidence unchanged", () => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-secret" },
mode: "attached",
timeoutMs: 1000,
});
const evidence = "Caller-selected fixture-secret evidence";
expect(
decodeIdaToolResult({
content: [
{ type: "text", text: "Caller-selected fixture-secret evidence" },
],
}),
).toBe("Caller-selected fixture-secret evidence");
decodeIdaToolResult(
{ content: [{ type: "text", text: evidence }] },
undefined,
redact,
),
).toBe(evidence);
expect(
decodeIdaToolResult(
{ content: [], structuredContent: { evidence } },
undefined,
redact,
),
).toEqual({ evidence });
expect(
decodeIdaToolResult(
{ content: [{ type: "text", text: JSON.stringify({ evidence }) }] },
"native",
redact,
),
).toEqual({ evidence });
});
it("redacts credentials in truncated-output diagnostics before constructing the error", () => {
const redact = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-secret" },
mode: "attached",
timeoutMs: 1000,
});
expect(() =>
decodeIdaToolResult(
{
content: [],
_meta: {
ida_mcp: {
output_truncated: true,
download_hint:
"Download /caller/output?cursor=kept using Bearer fixture-secret",
},
},
},
undefined,
redact,
),
).toThrow(
"Download /caller/output?cursor=kept using [redacted transport credential]",
);
});
it("decodes scalar wrappers and legacy text without dropping structured dictionaries", () => {
expect(
@@ -79,3 +116,208 @@ describe("upstream IDA tool result decoding", () => {
).toThrow("Download the full upstream output");
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const failingHttpConnection = (
credential: string,
response: Response | Error,
) => {
// Exercise the real SDK consumer with an explicitly simulated Fetch boundary.
vi.stubGlobal(
"fetch",
response instanceof Error
? vi.fn().mockRejectedValue(response)
: vi.fn().mockResolvedValue(response),
);
return createIdaMcpConnection(
{
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Custom " + credential },
mode: "attached",
timeoutMs: 1000,
},
{},
);
};
const inspectRetainedError = (error: unknown): string =>
inspect(error, { depth: null, maxStringLength: null, breakLength: Infinity });
describe("retained IDA diagnostic errors", () => {
it.each([
{
credential: "fixture-secret",
body: '{"reason":"fixture-secret at /caller/input",',
forbidden: "fixture-secret",
},
{
credential: "<token>",
body: String.raw`["\u003ctoken\u003e",x]`,
forbidden: "u003ctoken",
},
])(
"retains only sanitized malformed HTTP JSON: $credential",
async ({ credential, body, forbidden }) => {
const connection = failingHttpConnection(
credential,
new Response(body, { headers: { "Content-Type": "application/json" } }),
);
const failure: unknown = await connection
.connect()
.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error))
throw new Error("Expected a malformed HTTP JSON failure");
expect(failure.message).toContain("[redacted transport credential]");
expect(inspectRetainedError(failure)).not.toContain(forbidden);
expect(inspectRetainedError(failure)).not.toContain(credential);
await connection.close();
},
);
it("does not rescan an owned malformed JSON replacement marker", async () => {
const connection = failingHttpConnection(
"redacted",
new Response("Rejected redacted", {
headers: { "Content-Type": "application/json" },
}),
);
const failure: unknown = await connection
.connect()
.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error))
throw new Error("Expected a malformed HTTP JSON failure");
expect(failure.message).toBe(
"IDA MCP returned malformed HTTP JSON. Response body: \nRejected ␟",
);
expect(inspectRetainedError(failure)).not.toContain("[[redacted");
await connection.close();
});
it("replaces an unknown network error graph with a safe owned reason", async () => {
const upstream = Object.assign(
new Error("fixture-secret refused at /caller/input", {
cause: new Error("nested fixture-secret"),
}),
{ headers: { Authorization: "Custom fixture-secret" } },
);
const connection = failingHttpConnection("fixture-secret", upstream);
const failure: unknown = await connection
.connect()
.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error))
throw new Error("Expected a network failure");
expect(failure.message).toContain("refused at /caller/input");
expect(failure.cause).not.toBe(upstream);
expect(inspectRetainedError(failure)).not.toContain("fixture-secret");
await connection.close();
});
it("retains meaningful HTTP status without unsafe SDK metadata", async () => {
const connection = failingHttpConnection(
"fixture-secret",
new Response("unavailable fixture-secret at /caller/input", {
status: 500,
statusText: "fixture-secret",
headers: { "Content-Type": "text/plain" },
}),
);
const failure: unknown = await connection
.connect()
.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error))
throw new Error("Expected an HTTP failure");
expect(failure.cause).toMatchObject({ data: { status: 500 } });
expect(failure.message).toContain(
"unavailable [redacted transport credential] at /caller/input",
);
expect(inspectRetainedError(failure)).not.toContain("fixture-secret");
await connection.close();
});
});
it.each([
{
code: SdkErrorCode.ClientHttpNotImplemented,
message: "Changed SDK context: fixture-secret",
},
{
code: SdkErrorCode.ClientHttpFailedToOpenStream,
message: "Error POSTing to endpoint: fixture-secret",
},
])(
"does not infer owned body spans from an unknown SDK shape: $code / $message",
async ({ code, message }) => {
const connection = failingHttpConnection(
"fixture-secret",
new SdkHttpError(code, message, { status: 500, text: "fixture-secret" }),
);
try {
const failure = await connection
.connect()
.catch((cause: unknown) => cause);
if (!(failure instanceof Error)) throw new Error("Expected SDK failure");
expect(inspectRetainedError(failure)).not.toContain("fixture-secret");
expect(failure.message).toContain("[redacted transport credential]");
expect(failure.cause).toMatchObject({ code, data: { status: 500 } });
} finally {
await connection.close();
}
},
);
it("redacts JSON character candidates in unclassified SDK errors", async () => {
const connection = failingHttpConnection(
'q"uote',
new Error(String.raw`network context {"error":"q\"uote"} at /caller/input`),
);
try {
const failure = await connection.connect().catch((cause: unknown) => cause);
if (!(failure instanceof Error)) throw new Error("Expected SDK failure");
expect(failure.message).toContain(
'{"error":"[redacted transport credential]"} at /caller/input',
);
expect(inspectRetainedError(failure)).not.toContain(String.raw`q\"uote`);
} finally {
await connection.close();
}
});
it("owns unclassified truncated-output hints separately from their context", () => {
const diagnostic = createIdaDiagnosticRedactor({
url: "http://127.0.0.1:12345/mcp",
mode: "attached",
timeoutMs: 1000,
headers: { Authorization: 'Custom q"uote' },
});
let failure: unknown;
try {
decodeIdaToolResult(
{
content: [],
_meta: {
ida_mcp: {
output_truncated: true,
download_hint: String.raw`{"error":"q\"uote","path":"/caller/output"}`,
},
},
},
undefined,
diagnostic,
);
} catch (cause: unknown) {
failure = cause;
}
if (!(failure instanceof Error))
throw new Error("Expected truncation failure");
expect(failure.message).toContain(
'{"error":"[redacted transport credential]","path":"/caller/output"}',
);
expect(inspectRetainedError(failure)).not.toContain(String.raw`q\"uote`);
});
+277 -45
View File
@@ -1,8 +1,16 @@
import {
createIdaDiagnosticRedactor,
IDA_DIAGNOSTIC_SOURCE_BYTES,
} from "./IdaDiagnosticRedactor.js";
import { IdaDiagnosticResourceError } from "./IdaDiagnosticWriter.js";
import { ChildProcess } from "node:child_process";
import { Socket } from "node:net";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
Client,
ProtocolError,
SdkErrorCode,
SdkHttpError,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import {
@@ -12,7 +20,11 @@ import {
import { z } from "zod";
import type { IdaConfiguration } from "./IdaConfiguration.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import {
AnalysisProtocolError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
/** Injectable MCP boundary used by the provider and transport conformance tests. */
@@ -53,14 +65,14 @@ const toolResultSchema = z.object({
.optional(),
});
const IDA_STDIO_MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
const parseIdaJsonText = (text: string): JsonValue => {
let value: unknown;
try {
value = JSON.parse(text);
} catch (cause: unknown) {
throw new AnalysisProtocolError("IDA MCP returned malformed JSON text.", {
cause,
});
} catch {
throw new AnalysisProtocolError("IDA MCP returned malformed JSON text.");
}
return jsonValueSchema.parse(value);
};
@@ -69,18 +81,30 @@ const parseIdaJsonText = (text: string): JsonValue => {
export const decodeIdaToolResult = (
input: unknown,
protocol?: "native",
diagnostic: IdaDiagnosticRedactor = createIdaDiagnosticRedactor(),
): JsonValue => {
const result = toolResultSchema.parse(input);
const blocks = result.content.flatMap((block) =>
block.type === "text" && block.text !== undefined ? [block.text] : [],
);
if (result.isError === true)
if (result.isError === true) {
const reason = diagnostic.blocks(blocks);
throw new AnalysisProtocolError(
`IDA MCP tool failed: ${blocks.join("\n")}`,
diagnostic.message("IDA MCP tool failed: ", reason),
{
diagnostics: { reason },
},
);
}
if (result._meta?.ida_mcp?.output_truncated === true)
throw new AnalysisProtocolError(
`IDA MCP truncated its output; the preview cannot establish a complete observation. ${result._meta.ida_mcp.download_hint ?? "Use the upstream output download workflow to retrieve the full result."}`,
diagnostic.message(
"IDA MCP truncated its output; the preview cannot establish a complete observation. ",
diagnostic.blocks([
result._meta.ida_mcp.download_hint ??
"Use the upstream output download workflow to retrieve the full result.",
]),
),
);
// Native MCP retains the complete JSON text while structuredContent may
// contain a shortened preview. Parse that producer-owned representation once.
@@ -113,59 +137,264 @@ export const decodeIdaToolResult = (
return values[0] ?? null;
};
/** Redact configured transport authentication values from SDK diagnostics only. */
export const redactIdaTransportFailure = (
cause: unknown,
config: IdaConfiguration,
): Error => {
let message =
cause instanceof Error
? cause.message
: "Unknown IDA MCP transport failure";
if ("headers" in config) {
for (const [name, value] of Object.entries(config.headers)) {
if (!/^(?:authorization|proxy-authorization|cookie)$/iu.test(name))
continue;
const credential = value.match(/^(?:Bearer|Basic)\s+([\s\S]+)$/iu)?.[1];
for (const secret of [value, credential])
if (secret !== undefined && secret.length > 0)
message = message
.split(secret)
.join("[redacted transport credential]");
}
// Retain only producer-sanitized JSON diagnostics, never a native parser cause.
class IdaHttpJsonError extends Error {
constructor(text: string, redactor: IdaDiagnosticRedactor) {
super(
redactor.message(
"IDA MCP returned malformed HTTP JSON. Response body: ",
text,
),
);
}
return new Error(message, { cause });
}
type IdaDiagnosticRedactor = ReturnType<typeof createIdaDiagnosticRedactor>;
type IdaContentType = Readonly<{
essence: string;
subtype: string;
charset: string | undefined;
parameters: ReadonlyMap<string, string>;
}>;
const httpQuotedOctet = (code: number): boolean =>
code === 9 ||
code === 32 ||
(code >= 33 && code <= 126) ||
(code >= 128 && code <= 255);
/** Consume one complete RFC 9110 media type; never discard a malformed parameter. */
const parseIdaContentType = (
text: string,
): Result<IdaContentType, "malformed" | "ambiguous"> => {
let cursor = 0;
const token = new RegExp("[!#$%&'*+.^_`|~0-9A-Za-z-]+", "y");
const readToken = (): string | undefined => {
token.lastIndex = cursor;
const match = token.exec(text);
if (match === null) return undefined;
cursor = token.lastIndex;
return match[0];
};
const readOws = (): void => {
while (text[cursor] === " " || text[cursor] === "\t") cursor++;
};
const readQuoted = (): string | undefined => {
cursor++;
let value = "";
while (cursor < text.length) {
let code = text.charCodeAt(cursor++);
if (code === 34) return value;
if (code === 92) {
code = text.charCodeAt(cursor++);
if (!httpQuotedOctet(code)) return undefined;
} else if (!httpQuotedOctet(code)) return undefined;
value += String.fromCharCode(code);
}
return undefined;
};
readOws();
const type = readToken()?.toLowerCase();
if (type === undefined || text[cursor++] !== "/") return err("malformed");
const subtype = readToken()?.toLowerCase();
if (subtype === undefined) return err("malformed");
const parameters = new Map<string, string>();
let ambiguous = false;
while (true) {
readOws();
if (cursor === text.length) break;
if (text[cursor++] !== ";") return err("malformed");
readOws();
// RFC 9110 parameters admit an empty item after a semicolon.
if (cursor === text.length || text[cursor] === ";") continue;
const name = readToken()?.toLowerCase();
if (name === undefined || text[cursor++] !== "=") return err("malformed");
const value = text[cursor] === '"' ? readQuoted() : readToken();
if (value === undefined) return err("malformed");
// Repetition is ambiguous for this owner, even if both values agree.
// Keep parsing so syntax errors anywhere in the field cannot be discarded.
if (parameters.has(name)) ambiguous = true;
else parameters.set(name, value);
}
return ambiguous
? err("ambiguous")
: ok({
essence: type + "/" + subtype,
subtype,
charset: parameters.get("charset"),
parameters,
});
};
const httpErrorBody = (
response: Response,
text: string,
redactor: IdaDiagnosticRedactor,
): string => {
const omitted = redactor.text(
"IDA MCP response body omitted: unsupported or unknown format or character encoding.",
);
const contentType = response.headers.get("content-type");
if (contentType === null) return omitted;
const mediaType = parseIdaContentType(contentType);
if (!mediaType.ok) return omitted;
const charset = mediaType.value.charset?.toLowerCase();
if (
charset !== undefined &&
!["utf-8", "utf8", "us-ascii", "ascii"].includes(charset)
)
return omitted;
if (
mediaType.value.subtype === "json" ||
mediaType.value.subtype.endsWith("+json")
)
return redactor.json(text);
if (mediaType.value.essence === "text/plain") return redactor.text(text);
return omitted;
};
/** Keep error-body materialization within the diagnostic source-byte budget. */
const readBoundedResponseText = async (
response: Response,
): Promise<string | undefined> => {
if (response.body === null) return "";
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return Buffer.concat(chunks, total).toString("utf8");
total += value.byteLength;
if (total > IDA_DIAGNOSTIC_SOURCE_BYTES) {
await reader.cancel().catch(() => undefined);
return undefined;
}
chunks.push(Buffer.from(value));
}
} finally {
reader.releaseLock();
}
};
const diagnosticFetch =
(redactor: IdaDiagnosticRedactor): typeof fetch =>
async (input, init) => {
const response = await fetch(input, init);
const readText = response.text.bind(response);
let safeBody: string | undefined;
if (!response.ok) {
// Normalize before the SDK can swallow a body-consumer failure or drop
// its format metadata. Both consumers use this one owned diagnostic.
const sourceBody = await readBoundedResponseText(response);
safeBody =
sourceBody === undefined
? redactor.text(
`IDA MCP response body omitted: exceeds ${IDA_DIAGNOSTIC_SOURCE_BYTES} UTF-8 bytes.`,
)
: httpErrorBody(response, sourceBody, redactor);
const body = safeBody;
Object.defineProperty(response, "text", {
value: async (): Promise<string> => body,
});
}
// Successful JSON remains lazy, and SSE keeps its original body stream.
Object.defineProperty(response, "json", {
value: async (): Promise<unknown> => {
const text = safeBody ?? (await readText());
try {
const value: unknown = JSON.parse(text);
return value;
} catch {
// Parsing failure owns diagnostics even when the HTTP status succeeded.
throw new IdaHttpJsonError(
safeBody ?? httpErrorBody(response, text, redactor),
redactor,
);
}
},
});
return response;
};
const retainedTransportCause = (cause: unknown, message: string): Error => {
// Do not clone arbitrary upstream fields, stacks, data or nested causes.
if (cause instanceof SdkHttpError)
return new SdkHttpError(cause.code, message, {
status: cause.data.status,
statusText: "",
});
if (cause instanceof ProtocolError)
return new ProtocolError(cause.code, message);
return new Error(message);
};
/** Create a standard SDK connection to an existing upstream registration. */
export const createIdaMcpConnection = (
config: IdaConfiguration,
environment: Readonly<NodeJS.ProcessEnv>,
): IdaMcpConnection => {
const client = new Client({ name: "rea-ida-provider", version: "1" });
const redactor = createIdaDiagnosticRedactor(config);
let stderr = Buffer.alloc(0);
let stderrBytes = 0;
const transportFailure = (cause: unknown): never => {
const failure = redactIdaTransportFailure(cause, config);
if (stderrBytes === 0) throw failure;
const transportFailure = (
cause: unknown,
operation = "ida_mcp_response",
): never => {
const bufferLimit =
cause instanceof Error
? /ReadBuffer exceeded maximum size of (\d+) bytes/u.exec(cause.message)
: null;
if (cause instanceof IdaDiagnosticResourceError) throw cause;
let failure: Error;
if (cause instanceof IdaHttpJsonError)
failure = new Error(cause.message, { cause });
else {
const message =
cause instanceof Error
? cause.message
: "Unknown IDA MCP transport failure";
const body = cause instanceof SdkHttpError ? cause.data.text : undefined;
// Only the pinned SDK POST producer places this owned body at a known
// span. Equal substrings elsewhere remain untrusted SDK context.
const prefix = "Error POSTing to endpoint: ";
const ownedPostBody =
cause instanceof SdkHttpError &&
cause.code === SdkErrorCode.ClientHttpNotImplemented &&
typeof body === "string" &&
message === prefix + body;
// Normalized wire headers and supported decoded candidates have no LF.
// Separating owners prevents their concatenation from spelling a credential.
const safeMessage = ownedPostBody
? redactor.message(prefix, body)
: redactor.blocks([message]);
failure = new Error(safeMessage, {
cause: retainedTransportCause(cause, safeMessage),
});
}
let start = 0;
// The byte tail may start inside a UTF-8 code point. Retain whole units.
if (stderrBytes > stderr.length)
while (start < stderr.length && (stderr[start]! & 0xc0) === 0x80) start++;
const diagnostic = redactIdaTransportFailure(
new Error(stderr.subarray(start).toString("utf8")),
config,
).message;
const capturedOutput = {
stdout: "",
stderr: redactor.blocks([stderr.subarray(start).toString("utf8")]),
truncated: stderrBytes > stderr.length - start,
stdout_bytes: 0,
stderr_bytes: stderrBytes,
};
if (bufferLimit !== null)
throw new AnalysisResourceConstraintError(
operation,
"transport",
`Upstream IDA MCP reply exceeded the ${bufferLimit[1]}-byte transport limit; no partial Evidence was returned.`,
{ max_response_bytes: Number(bufferLimit[1]) },
{ cause, capturedOutput },
);
if (stderrBytes === 0) throw failure;
throw new ProviderAdapterError("ida", "transport", {
cause: failure,
diagnostics: { reason: failure.message },
capturedOutput: {
stdout: "",
stderr: diagnostic,
truncated: stderrBytes > stderr.length - start,
stdout_bytes: 0,
stderr_bytes: stderrBytes,
},
capturedOutput,
});
};
const transport =
@@ -188,9 +417,11 @@ export const createIdaMcpConnection = (
...snapshotEnvironment(config.env),
},
stderr: "pipe",
maxBufferSize: IDA_STDIO_MAX_MESSAGE_BYTES,
})
: new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: { headers: config.headers, redirect: "error" },
fetch: diagnosticFetch(redactor),
});
// Keep a bounded diagnostic tail for genuine failures; never persist ambient variables.
if (transport instanceof StdioClientTransport)
@@ -217,8 +448,9 @@ export const createIdaMcpConnection = (
return decodeIdaToolResult(
await client
.callTool({ name, arguments: args }, { timeout: config.timeoutMs })
.catch(transportFailure),
.catch((cause: unknown) => transportFailure(cause, name)),
config.protocol,
redactor,
);
},
async readResource(uri) {
+12 -1
View File
@@ -43,12 +43,23 @@ input.on('line', (line) => {
HOME: "/selected-home",
PATH: "/selected-path",
};
const registrationEnvironment: Record<string, string> = {
REA_REGISTRATION_CONTEXT: "registration",
};
if (process.platform === "win32") {
const systemRoot = process.env.SYSTEMROOT;
if (systemRoot === undefined || systemRoot.length === 0)
throw new Error(
"The Windows Node fixture requires its actual SYSTEMROOT.",
);
registrationEnvironment.SYSTEMROOT = systemRoot;
}
for (const selected of [environment, {}]) {
const connection = createIdaMcpConnection(
{
command: process.execPath,
args: [script],
env: { REA_REGISTRATION_CONTEXT: "registration" },
env: registrationEnvironment,
mode: "attached",
timeoutMs: 5000,
},
+191 -1
View File
@@ -1,5 +1,7 @@
import { rm } from "node:fs/promises";
import { rm, stat } from "node:fs/promises";
import { dirname } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { z } from "zod";
import {
createIdaTarget,
RecordingIdaMcp,
@@ -7,6 +9,7 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
import { decodeIdaToolResult } from "./IdaMcpConnection.js";
import type { AnalysisOperation } from "../application/AnalysisProvider.js";
import { IdaSessionClient } from "./IdaSessionClient.js";
@@ -17,6 +20,193 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("preserves a rejected headless decompile diagnostic with completed lookup evidence and releases its owned worker", async () => {
const { root, target } = await createIdaTarget();
roots.push(root);
const producer = new RecordingIdaMcp(target, "headless");
const reason =
"Invalid params: unexpected parameters: ['offset', 'max_lines']";
const config = {
url: "http://127.0.0.1:12345/mcp",
headers: {
Authorization: "Token custom-fixture-token",
Cookie: "session=cookie-fixture-secret",
},
mode: "headless" as const,
timeoutMs: 1000,
workspaceRoot: root,
};
const redactor = createIdaDiagnosticRedactor(config);
producer.beforeCall = async (name) => {
if (name === "decompile")
decodeIdaToolResult(
{
isError: true,
content: [
{
type: "text",
text: reason + " (custom-fixture-token / cookie-fixture-secret)",
},
],
},
undefined,
redactor,
);
};
const safeReason =
reason +
" ([redacted transport credential] / [redacted transport credential])";
const client = new IdaSessionClient(config, target, producer);
try {
const result = await client.execute("procedure_pseudo_code", {
procedure: "main",
});
if (result.ok) throw new Error("Expected the upstream rejection");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
details: {
diagnostics: { reason: safeReason },
partial_observation: {
provider: { id: "ida" },
operation: "procedure_pseudo_code",
normalized_result: {
status: "partial",
observations: [expect.objectContaining({ tool: "lookup_funcs" })],
target_binding: {
status: "not_verified",
reason: "IDA MCP tool failed: \n" + safeReason,
},
},
},
},
});
expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain(
"custom-fixture-token",
);
expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain(
"cookie-fixture-secret",
);
expect(
producer.calls.find(({ name }) => name === "decompile"),
).toMatchObject({
args: { addr: "0x1000", offset: 0, max_lines: 100 },
});
} finally {
const database = producer.database;
expect((await client.close()).ok).toBe(true);
expect(producer.calls.filter(({ name }) => name === "idb_close")).toEqual([
{ name: "idb_close", args: { database, save: false } },
]);
expect(producer.closes).toBe(1);
await expect(stat(dirname(producer.inputPath))).rejects.toMatchObject({
code: "ENOENT",
});
}
});
describe("headless IDA tool diagnostics in partial evidence", () => {
const callerPath = "/caller/input?token=visible";
const marker = "[redacted transport credential]";
it.each([
{
name: "a decoded Basic password matching the tool failure prefix",
password: "failed",
upstream: "Rejected decompile request for " + callerPath,
safeReason: "Rejected decompile request for " + callerPath,
forbiddenInBinding: ["failed"],
},
{
name: "a JSON-encoded tool explanation",
password: "<secret>",
upstream: String.raw`{"reason":"\u003csecret\u003e","user":"user","path":"/caller/input?token=visible"}`,
safeReason:
'{"reason":"' +
marker +
'","user":"user","path":"/caller/input?token=visible"}',
forbiddenInBinding: ["<secret>", String.raw`\u003csecret\u003e`],
},
])(
"redacts $name before retaining the public target-binding reason",
async ({ password, upstream, safeReason, forbiddenInBinding }) => {
const { root, target } = await createIdaTarget();
roots.push(root);
const producer = new RecordingIdaMcp(target, "headless");
const config = {
url: "http://127.0.0.1:12345/mcp",
headers: {
Authorization:
"Basic " +
Buffer.from("user:" + password, "utf8").toString("base64"),
},
mode: "headless" as const,
timeoutMs: 1000,
workspaceRoot: root,
};
const redactor = createIdaDiagnosticRedactor(config);
producer.beforeCall = async (name) => {
if (name === "decompile")
decodeIdaToolResult(
{ isError: true, content: [{ type: "text", text: upstream }] },
undefined,
redactor,
);
};
const client = new IdaSessionClient(config, target, producer);
try {
const result = await client.execute("procedure_pseudo_code", {
procedure: "main",
});
if (result.ok) throw new Error("Expected the producer's tool error");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "unreadable_output",
details: {
partial_observation: {
provider: { id: "ida" },
operation: "procedure_pseudo_code",
normalized_result: {
status: "partial",
observations: [
expect.objectContaining({ tool: "lookup_funcs" }),
],
target_binding: {
status: "not_verified",
reason: expect.any(String),
},
},
},
},
});
const details = z
.object({
diagnostics: z.object({ reason: z.string() }),
partial_observation: z.object({
normalized_result: z.object({
target_binding: z.object({ reason: z.string() }),
}),
}),
})
.parse(projected.details);
const bindingReason =
details.partial_observation.normalized_result.target_binding.reason;
for (const credential of forbiddenInBinding)
expect.soft(bindingReason).not.toContain(credential);
expect.soft(bindingReason).toContain(marker);
expect(bindingReason).toContain(callerPath);
expect.soft(details.diagnostics.reason).toBe(safeReason);
} finally {
expect((await client.close()).ok).toBe(true);
expect(
producer.calls.filter(({ name }) => name === "idb_close"),
).toHaveLength(1);
await expect(stat(dirname(producer.inputPath))).rejects.toMatchObject({
code: "ENOENT",
});
}
},
);
});
describe("headless IDA partial operation evidence", () => {
it("retains completed rows when post-operation target identity changes", async () => {
const { root, target } = await createIdaTarget();
+5 -7
View File
@@ -311,14 +311,12 @@ export class IdaOperationRunner {
const current: number = offset;
if (this.protocol === "legacy") {
const page = legacyPageSchema.parse(
await this.call("list_strings", { offset, count: 100 }),
);
result.push(
...page.data.map((item) => {
const s = legacyStringSchema.parse(item);
return { address: s.address, value: s.string };
}),
await this.call("list_strings", { offset, count: 0 }),
);
for (const item of page.data) {
const s = legacyStringSchema.parse(item);
result.push({ address: s.address, value: s.string });
}
offset = page.next_offset;
} else {
const page = (
+5 -1
View File
@@ -68,13 +68,17 @@ export class IdaProvider implements AnalysisProviderCandidate {
if (!privacy.available)
return {
status: "unavailable",
code: "unsupported_host",
code: privacy.code,
reason: privacy.reason,
diagnostics: {
configured: true,
mode: "headless",
live_connection_probed: false,
private_workspace_available: false,
windows_native_failure: {
stage: privacy.failureStage,
remediation: privacy.remediation,
},
},
};
}
-1
View File
@@ -32,7 +32,6 @@ export type IdaOperation = (typeof IDA_OPERATIONS)[number];
/** Operations only the modern headless profile implements. */
const HEADLESS_ONLY_OPERATIONS: ReadonlySet<IdaOperation> = new Set([
"resolve_containing_procedure",
"list_documents",
]);
/** Whether the configured profile admits an operation. */
+58 -3
View File
@@ -1,6 +1,6 @@
import { readFile, readdir, rm } from "node:fs/promises";
import { sep } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import {
createIdaTarget,
@@ -11,11 +11,34 @@ import { functionDossierSchema } from "../domain/hopperValues.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
const roots: string[] = [];
const windowsRuntimes = new Set<WindowsPrivateRuntime>();
beforeEach(() => {
if (process.platform !== "win32") return;
const create = WindowsPrivateRuntime.create;
vi.spyOn(WindowsPrivateRuntime, "create").mockImplementation(
(parent, prefix) => {
const runtime = create(parent, prefix);
windowsRuntimes.add(runtime);
return runtime;
},
);
});
afterEach(async () => {
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
try {
// These pure producer fixtures spawn no IDA worker. After the retention
// assertions, release only the actual native owners this test created.
for (const runtime of windowsRuntimes) {
await runtime.close();
windowsRuntimes.delete(runtime);
}
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
} finally {
vi.restoreAllMocks();
}
});
/** A failed close whose diagnostic names exactly these unverified sessions. */
const reportsSessions = (...sessions: readonly object[]) => ({
@@ -1495,6 +1518,38 @@ it("binds retained transport diagnostics to the requested operation and recovery
await client.close();
});
describe("attached IDA legacy string inventory", () => {
it("fetches legacy strings and literal searches in single upstream calls", async () => {
const { producer, client } = await fixture();
const all = Array.from({ length: 250 }, (_, i) => ({
address: `0x${(0x3000 + i).toString(16)}`,
length: 5,
string: `hello-${i}`,
}));
producer.overrideCall = (name, args) => {
if (name !== "list_strings") return undefined;
const offset = Number(args.offset ?? 0);
const count = Number(args.count ?? 100);
const data = count === 0 ? all : all.slice(offset, offset + count);
return {
data,
next_offset:
count === 0 || offset + count >= all.length ? null : offset + count,
};
};
const listed = await client.execute("list_strings", {});
expect(listed.ok && listed.value.result).toHaveLength(250);
expect(
producer.calls.filter(({ name }) => name === "list_strings")[0]?.args
.count,
).toBe(0);
expect(
producer.calls.filter(({ name }) => name === "list_strings"),
).toHaveLength(1);
await client.close();
});
});
it("retains transport diagnostics on a cancellation regardless of stderr", async () => {
const { producer, client } = await fixture("attached");
await client.execute("health", {});
+9
View File
@@ -5,6 +5,7 @@ import { parseJebMcpEndpoint } from "./JebMcpEndpoint.js";
describe("JEB MCP endpoint", () => {
it("rejects an explicit empty query or fragment instead of dropping it", () => {
expect(parseJebMcpEndpoint("http://127.0.0.1:8425/mcp").ok).toBe(true);
expect(parseJebMcpEndpoint("http://127.0.0.1:8425/mcp/").ok).toBe(true);
for (const value of [
"http://127.0.0.1:8425/mcp?",
"http://127.0.0.1:8425/mcp#",
@@ -12,4 +13,12 @@ describe("JEB MCP endpoint", () => {
])
expect(parseJebMcpEndpoint(value).ok, value).toBe(false);
});
it("accepts only the declared MCP route", () => {
expect(parseJebMcpEndpoint("http://127.0.0.1:8425/mcp").ok).toBe(true);
for (const path of ["/mcp/extra", "/not-mcp", "/"])
expect(parseJebMcpEndpoint(`http://127.0.0.1:8425${path}`).ok, path).toBe(
false,
);
});
});
+6
View File
@@ -47,6 +47,12 @@ export const parseJebMcpEndpoint = (
"Embedding credentials in the JEB MCP endpoint is not supported; configure authentication with the engine",
}),
);
if (url.pathname !== "/mcp" && url.pathname !== "/mcp/")
return err(
new AnalysisInputError("jeb_mcp_endpoint", {
cause: "JEB MCP endpoint must use the /mcp route",
}),
);
if (urlDeclaresQueryOrFragment(url))
return err(
new AnalysisInputError("jeb_mcp_endpoint", {
+43
View File
@@ -544,6 +544,49 @@ it("reports WABT output beyond the budget as a resource constraint with a bounde
signal: "SIGKILL",
});
});
it("does not attribute wasm-objdump output to a wasm2wat launch failure", async () => {
const selectedRoot = await createTestTempDirectory("rea-wabt-launch-");
const path = join(selectedRoot, "selected.wasm");
await writeFile(path, Buffer.alloc(76, 1));
for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"])
await writeFile(
join(selectedRoot, tool + (process.platform === "win32" ? ".exe" : "")),
"source-owned executable seam",
{ mode: 0o700 },
);
const dump = await readFile(
new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url),
"utf8",
);
const provider = new WabtArtifactProvider(
{ REA_WABT_BIN_DIRECTORY: selectedRoot },
(input) => {
const version = input.arguments[0] === "--version";
if (!version && input.command.includes("wasm2wat"))
throw Object.assign(new Error("spawn E2BIG"), { code: "E2BIG" });
const script = version
? 'process.stdout.write("1.0.42\\n")'
: input.command.includes("objdump")
? `process.stdout.write(${JSON.stringify(dump)})`
: "";
return spawnOwnedProviderProcess({
...input,
command: process.execPath,
arguments: ["-e", script],
expectedCommand: null,
});
},
);
const result = await provider.inspect({
path,
glue_paths: [],
candidate_paths: [],
});
if (result.ok) throw new Error("Expected the wasm2wat launch to fail");
expect(result.error.message).toContain("spawn E2BIG");
expect(result.error.capturedOutput).toBeUndefined();
});
it("bounds an oversized stderr to an excerpt", () => {
expect(
projectAnalysisError(
+1
View File
@@ -180,6 +180,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
args: readonly string[],
acceptNonZeroExit = false,
) => {
capturedOutput = undefined;
await verifySnapshot();
const snapshot = await runOwnedCommand(
{
+18 -12
View File
@@ -111,24 +111,28 @@ describe("packaged Windows native admission", () => {
});
it.each([
{ platform: "linux" as const },
{ architecture: "arm64" },
{ nodeApiVersion: "7" },
{ nodeApiVersion: undefined },
{ packageVersion: "2.0.0" },
])("rejects incompatible host metadata before loading: %j", (override) => {
const value = fixture();
expect(
loadWindowsNativeAuthority({ ...value.host, ...override }).available,
).toBe(false);
expect(value.loads()).toBe(0);
});
[{ platform: "linux" as const }, "host"],
[{ architecture: "arm64" }, "host"],
[{ nodeApiVersion: "7" }, "host"],
[{ nodeApiVersion: undefined }, "host"],
[{ packageVersion: "2.0.0" }, "artifact"],
] as const)(
"rejects incompatible host metadata before loading: %j",
(override, failureStage) => {
const value = fixture();
expect(
loadWindowsNativeAuthority({ ...value.host, ...override }),
).toMatchObject({ available: false, failureStage });
expect(value.loads()).toBe(0);
},
);
it("rejects tampering even when the original file was a valid PE image", () => {
const value = fixture();
value.bytes[127] = 1;
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
available: false,
failureStage: "artifact",
reason: expect.stringContaining("SHA-256"),
});
expect(value.loads()).toBe(0);
@@ -190,6 +194,7 @@ describe("packaged Windows native admission", () => {
}),
).toMatchObject({
available: false,
failureStage: "self-test",
retryable: true,
reason: expect.stringContaining("private DACL: access denied"),
});
@@ -202,6 +207,7 @@ describe("packaged Windows native admission", () => {
loadArtifact: () => ({ call: () => ({ privateDacl: true }) }),
});
expect(loaded.available).toBe(false);
expect(loaded).toMatchObject({ failureStage: "inspection" });
expect(loaded).not.toHaveProperty("retryable");
});
+19 -4
View File
@@ -71,6 +71,7 @@ export type WindowsNativeLoadResult =
| {
readonly available: false;
readonly reason: string;
readonly failureStage: "host" | "artifact" | "self-test" | "inspection";
/** Only a failed native invocation can recover on a later query. */
readonly retryable?: true;
};
@@ -93,15 +94,20 @@ export const loadWindowsNativeAuthority = (
if (host.platform !== "win32" || host.architecture !== "x64")
return {
available: false,
failureStage: "host",
reason: "REA Windows native controls require a Windows x64 host.",
};
const nodeApiVersion = Number(host.nodeApiVersion);
if (!Number.isSafeInteger(nodeApiVersion) || nodeApiVersion < 8)
return {
available: false,
failureStage: "host",
reason: "REA Windows native controls require Node-API 8 or newer.",
};
try {
const manifest = windowsNativeManifestSchema.parse(host.readManifest());
if (manifest.packageVersion !== host.packageVersion)
throw new Error("Windows native artifact belongs to another REA version");
const nodeApiVersion = Number(host.nodeApiVersion);
if (!Number.isSafeInteger(nodeApiVersion) || nodeApiVersion < 8)
throw new Error("Windows native artifact requires Node-API 8 or newer");
const artifact = host.readArtifact();
if (
createHash("sha256").update(artifact).digest("hex") !==
@@ -118,15 +124,24 @@ export const loadWindowsNativeAuthority = (
} catch (cause: unknown) {
return {
available: false,
failureStage: "self-test",
retryable: true,
reason: `REA Windows native self-test failed: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
const inspection = inspectionSchema.parse(observed);
const parsed = inspectionSchema.safeParse(observed);
if (!parsed.success)
return {
available: false,
failureStage: "inspection",
reason: `REA Windows native inspection is invalid: ${parsed.error.message}`,
};
const inspection = parsed.data;
return { available: true, authority: { call: binding.call, inspection } };
} catch (cause: unknown) {
return {
available: false,
failureStage: "artifact",
reason: `REA Windows native controls are unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
@@ -88,6 +88,109 @@ it.each([
},
);
it.each([
{ label: "omitted", property: "", shown: "bitness unknown" },
{
label: "empty",
property: "sun.arch.data.model =\n",
shown: 'bitness unknown (sun.arch.data.model = "")',
},
{
label: "malformed",
property: "sun.arch.data.model = 64junk\n",
shown: 'bitness unknown (sun.arch.data.model = "64junk")',
},
{
label: "not exactly representable",
property: "sun.arch.data.model = 9007199254740993\n",
shown: 'bitness unknown (sun.arch.data.model = "9007199254740993")',
},
])("keeps $label Java bitness unresolved", async ({ property, shown }) => {
const root = await createTestTempDirectory("rea-java-bitness-");
const configuredHome = join(root, "selected-jdk");
const reportedHome = join(root, "reported-jdk");
const installDir = join(root, "ghidra");
await mkdir(join(installDir, "Ghidra"), { recursive: true });
await mkdir(join(reportedHome, "bin"), { recursive: true });
await writeFile(
join(
reportedHome,
"bin",
process.platform === "win32" ? "javac.exe" : "javac",
),
"fixture",
{
mode: 0o755,
},
);
await writeFile(
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
mockSpawnSync.mockReturnValue({
stdout: "",
stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
status: 0,
});
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
expect(inspection.status).toBe("unavailable");
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
status: "failed",
code: "version_unresolved",
detail: expect.stringContaining(shown),
});
expect(
inspection.checks.find(({ name }) => name === "java")?.detail,
).not.toMatch(/(?:0-bit|9007199254740992-bit)/);
});
it("keeps a known 32-bit Java report classified as unsupported", async () => {
const root = await createTestTempDirectory("rea-java-bitness-known-");
const configuredHome = join(root, "selected-jdk");
const reportedHome = join(root, "reported-jdk");
const installDir = join(root, "ghidra");
await mkdir(join(installDir, "Ghidra"), { recursive: true });
await mkdir(join(reportedHome, "bin"), { recursive: true });
await writeFile(
join(
reportedHome,
"bin",
process.platform === "win32" ? "javac.exe" : "javac",
),
"fixture",
{
mode: 0o755,
},
);
await writeFile(
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
mockSpawnSync.mockReturnValue({
stdout: "",
stderr: ` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
status: 0,
});
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
status: "failed",
code: "unsupported_version",
detail: expect.stringContaining("32-bit"),
});
});
it("bounds the Java version probe and reports an over-budget probe as a failed Java check", async () => {
const root = await createTestTempDirectory("rea-java-probe-bound-");
const configuredHome = join(root, "selected-jdk");
@@ -0,0 +1,86 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const LIMIT = 4 * 1024 * 1024;
const OVERSIZED = "oversized.ldefs";
// Count bytes delivered from the oversized definition, whichever read API is used.
const { readBytes } = vi.hoisted(() => ({ readBytes: { value: 0 } }));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const tracked = (path: unknown) => String(path).endsWith(OVERSIZED);
return {
...actual,
readFile: async (...args: Parameters<typeof actual.readFile>) => {
const bytes = await actual.readFile(...args);
if (tracked(args[0])) readBytes.value += bytes.length;
return bytes;
},
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (!tracked(args[0])) return handle;
const read = handle.read.bind(handle) as (
...readArgs: unknown[]
) => Promise<{ bytesRead: number }>;
return Object.assign(handle, {
read: async (...readArgs: unknown[]) => {
const result = await read(...readArgs);
readBytes.value += result.bytesRead;
return result;
},
});
},
};
});
afterEach(() => {
readBytes.value = 0;
});
it("skips an oversized definition without buffering it beyond the catalog limit", async () => {
const root = await createTestTempDirectory("rea-ghidra-ldefs-bound-");
const module = (name: string) =>
join(root, "Ghidra/Processors", name, "data/languages");
await mkdir(module("huge"), { recursive: true });
await mkdir(module("x86"), { recursive: true });
await writeFile(
join(module("huge"), OVERSIZED),
Buffer.concat([
Buffer.from(
'<language_definitions><language id="huge:LE:32:default"/></language_definitions>',
),
Buffer.alloc(2 * LIMIT, 0x20),
]),
);
await writeFile(
join(module("x86"), "x86.ldefs"),
'<language_definitions><language id="x86:LE:32:default"/></language_definitions>',
);
expect(await readGhidraLanguageCatalog(root)).toEqual([
{ id: "x86:LE:32:default", compilerSpecIds: [] },
]);
expect(readBytes.value).toBeGreaterThan(0);
expect(readBytes.value).toBeLessThanOrEqual(LIMIT + 1);
});
it("keeps a definition at exactly the catalog limit", async () => {
const root = await createTestTempDirectory("rea-ghidra-ldefs-limit-");
const languages = join(root, "Ghidra/Processors/edge/data/languages");
await mkdir(languages, { recursive: true });
const document = Buffer.from(
'<language_definitions><language id="edge:LE:32:default"/></language_definitions>',
);
await writeFile(
join(languages, "edge.ldefs"),
Buffer.concat([document, Buffer.alloc(LIMIT - document.length, 0x20)]),
);
expect(await readGhidraLanguageCatalog(root)).toEqual([
{ id: "edge:LE:32:default", compilerSpecIds: [] },
]);
});
@@ -0,0 +1,203 @@
import { mkdir, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import {
GhidraLanguageCatalogCleanupError,
readGhidraLanguageCatalog,
} from "../../../src/ghidra/GhidraLanguageCatalog.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
interface FileControl {
closeFails: boolean;
/** Hold close() until every synchronized handle has reached it. */
synchronizedClose: boolean;
invalidateOnFailure: boolean;
readFails: boolean;
invalidated: boolean;
closeCalls: number;
}
// Per-file failure controls, keyed by definition file name, and a barrier
// that releases synchronized closes only after all of them are pending.
const { controls, barrier } = vi.hoisted(() => {
const state = { expected: 0, arrived: 0, release: () => {} };
const gate = new Promise<void>((resolve) => {
state.release = resolve;
});
return {
controls: new Map<string, FileControl>(),
barrier: {
state,
arrive: async () => {
state.arrived += 1;
if (state.arrived >= state.expected) state.release();
await gate;
},
},
};
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const real = await actual.open(...args);
const control = controls.get(basename(String(args[0])));
if (control === undefined) return real;
let released = false;
const release = async () => {
if (released) return;
released = true;
await real.close();
};
return {
get fd() {
return control.invalidated ? -1 : real.fd;
},
read: (...readArgs: Parameters<typeof real.read>) =>
control.readFails
? Promise.reject(new Error("definition read failed"))
: real.read(...readArgs),
close: async () => {
control.closeCalls += 1;
if (control.synchronizedClose && control.closeCalls === 1)
await barrier.arrive();
if (control.closeFails) {
if (control.invalidateOnFailure) {
await release();
control.invalidated = true;
}
throw new Error(`definition close failed: ${String(args[0])}`);
}
await release();
},
};
},
};
});
let preserveUnconfirmedOwner = false;
const control = (name: string, overrides: Partial<FileControl> = {}) => {
const value: FileControl = {
closeFails: false,
synchronizedClose: false,
invalidateOnFailure: false,
readFails: false,
invalidated: false,
closeCalls: 0,
...overrides,
};
controls.set(name, value);
return value;
};
const installation = async (name: string): Promise<string> => {
const root = await createTestTempDirectory("rea-ghidra-ldefs-cleanup-");
const languages = join(root, "Ghidra/Processors", name, "data/languages");
await mkdir(languages, { recursive: true });
await writeFile(
join(languages, `${name}.ldefs`),
`<language_definitions><language id="${name}:LE:32:default"/></language_definitions>`,
);
return root;
};
// Let every retained owner close, then drain them through a healthy read.
afterEach(async () => {
if (preserveUnconfirmedOwner) {
preserveUnconfirmedOwner = false;
controls.clear();
return;
}
for (const value of controls.values()) value.closeFails = false;
await readGhidraLanguageCatalog(await installation("drain"));
controls.clear();
});
it("retries a retained close before the next read", async () => {
const retained = control("flaky.ldefs", { closeFails: true });
await expect(
readGhidraLanguageCatalog(await installation("flaky")),
).rejects.toMatchObject({ paths: [expect.stringContaining("flaky.ldefs")] });
retained.closeFails = false;
expect(await readGhidraLanguageCatalog(await installation("x86"))).toEqual([
{ id: "x86:LE:32:default", compilerSpecIds: [] },
]);
expect(retained.closeCalls).toBe(2);
});
it("attempts every retained close and reports the remaining failures together", async () => {
const first = control("first.ldefs", {
closeFails: true,
synchronizedClose: true,
});
const second = control("second.ldefs", {
closeFails: true,
synchronizedClose: true,
});
barrier.state.expected = 2;
// Concurrent resolutions can each retain an owner.
const roots = [await installation("first"), await installation("second")];
const results = await Promise.allSettled(
roots.map((root) => readGhidraLanguageCatalog(root)),
);
expect(results.map(({ status }) => status)).toEqual(["rejected", "rejected"]);
second.closeFails = false;
const failure = await readGhidraLanguageCatalog(
await installation("x86"),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(GhidraLanguageCatalogCleanupError);
expect(failure).toMatchObject({
paths: [expect.stringContaining("first.ldefs")],
});
expect(second.closeCalls).toBe(2);
expect(first.closeCalls).toBe(2);
});
it("keeps the read failure when the close also fails", async () => {
control("damaged.ldefs", { readFails: true, closeFails: true });
const failure = await readGhidraLanguageCatalog(
await installation("damaged"),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(GhidraLanguageCatalogCleanupError);
expect(failure).toMatchObject({
readFailure: expect.objectContaining({ message: "definition read failed" }),
});
});
it("reports retained cleanup for a catalog without definitions", async () => {
control("pending.ldefs", { closeFails: true });
await expect(
readGhidraLanguageCatalog(await installation("pending")),
).rejects.toBeInstanceOf(GhidraLanguageCatalogCleanupError);
const empty = await createTestTempDirectory("rea-ghidra-ldefs-empty-");
await expect(readGhidraLanguageCatalog(empty)).rejects.toMatchObject({
paths: [expect.stringContaining("pending.ldefs")],
});
});
it("retains an invalidated descriptor owner and keeps later reads blocked", async () => {
const broken = control("broken.ldefs", {
closeFails: true,
invalidateOnFailure: true,
});
const first = await readGhidraLanguageCatalog(
await installation("broken"),
).catch((cause: unknown) => cause);
expect(first).toBeInstanceOf(GhidraLanguageCatalogCleanupError);
expect(first).toMatchObject({
paths: [expect.stringContaining("broken.ldefs")],
});
const later = await readGhidraLanguageCatalog(
await installation("x86"),
).catch((cause: unknown) => cause);
expect(later).toBeInstanceOf(GhidraLanguageCatalogCleanupError);
expect(later).toMatchObject({
paths: [expect.stringContaining("broken.ldefs")],
});
expect(broken.closeCalls).toBe(1);
preserveUnconfirmedOwner = true;
});
+25 -2
View File
@@ -156,7 +156,10 @@ for (const little of [true, false]) {
else bytes.writeUInt32BE(sectionOffset, 32);
const resolved = await resolveFixture(bytes);
assert.equal(resolved.mips.abiFlags?.fpAbi, 5);
assert.equal(ghidraMipsUnsupportedReason(resolved), null);
assert.match(
ghidraMipsUnsupportedReason(resolved) ?? "",
/ABI declaration inspection is incomplete/u,
);
assert.match(
resolved.mips.limitations?.join(" ") ?? "",
/section header table/u,
@@ -172,7 +175,7 @@ it("keeps declarations in uncaptured section headers unknown when program ABI da
assert.equal(resolved.mips.abiFlags, null);
assert.match(
ghidraMipsUnsupportedReason(resolved) ?? "",
/inspected ABI.*unavailable headers remain unknown/u,
/ABI declaration inspection is incomplete.*unavailable headers remain unknown/u,
);
});
for (const failure of ["conflicting", "outside", "duplicate"]) {
@@ -370,6 +373,26 @@ it("projects a malformed out-of-probe ABI record as a target error", async () =>
assert.deepEqual(await readFile(path), bytes);
});
for (const little of [true, false]) {
it(`refuses GNU ABI attributes by section type without reading attacker-controlled names or sizes (${little ? "LE" : "BE"})`, async () => {
const bytes = fixture(little);
const write16 = little
? (value: number, offset: number) => bytes.writeUInt16LE(value, offset)
: (value: number, offset: number) => bytes.writeUInt16BE(value, offset);
const write32 = little
? (value: number, offset: number) => bytes.writeUInt32LE(value, offset)
: (value: number, offset: number) => bytes.writeUInt32BE(value, offset);
write32(0xffffffff, 168); // Unknown name; the section type is authoritative.
write32(0x6ffffff5, 172); // SHT_GNU_ATTRIBUTES.
write32(0xfffffff0, 184); // Must not be read as a payload offset.
write32(0xffffffff, 188); // Must not control allocation.
write16(0, 50); // No section-name string table is required for detection.
const result = await inspect(bytes, little);
assert.ok(!result.ok);
assert.match(result.error, /GNU ABI attributes that REA does not inspect/u);
});
}
it("preserves cancellation instead of turning it into malformed target data", async () => {
const directory = await createTestTempDirectory("rea-mips-resolution-");
const path = join(directory, "fixture.elf");
+2 -1
View File
@@ -175,7 +175,8 @@ it("retains terminal-call qualifications across function views and snapshot repl
});
expect(evidence.limitations, name).toContain(limitation);
expect(evidence.analysis_profile?.parameters).toMatchObject({
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
});
const replay = await harness.session.execute(name, evidence.parameters);
if (!replay.ok) throw replay.error;
File diff suppressed because it is too large. Load diff
@@ -31,6 +31,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
const fixturePath = fileURLToPath(
new URL("../../../fixtures/fakeGhidra.mjs", import.meta.url),
);
const PROFILE_DIGEST = "a".repeat(64);
const PROVIDER_VERSION = "12.1.4";
const TARGET_SHA256 = createHash("sha256")
@@ -40,6 +41,8 @@ const HOST_TRANSPORT: GhidraTransportKind =
process.platform === "win32" ? "authenticated-loopback-tcp" : "unix-socket";
type FixtureMode =
| "reject_first_connection"
| "reject_all_connections"
| "success"
| "fragmented"
| "oversized_result"
@@ -1131,3 +1134,30 @@ describe.skipIf(process.platform === "win32")(
);
},
);
it("reconnects a dropped preauthentication loopback socket within the original startup deadline", async () => {
const launcher = new FixtureLauncher("reject_first_connection");
const client = clientFor(launcher, {
transport: "authenticated-loopback-tcp",
});
const started = await client.start();
expect(started.ok).toBe(true);
expect(launcher.processes).toHaveLength(1);
const result = await client.callToolIfReady("list_strings", {});
expect(result).toMatchObject({ ok: true });
});
it("bounds retries when loopback preauthentication keeps failing", async () => {
const launcher = new FixtureLauncher("reject_all_connections");
const client = clientFor(launcher, {
transport: "authenticated-loopback-tcp",
});
expect(await client.start()).toMatchObject({
ok: false,
error: { kind: "process" },
});
expect(launcher.processes).toHaveLength(1);
await expect(waitForChildExit(launcher.processes[0]!, 5_000)).resolves.toBe(
true,
);
});
@@ -184,6 +184,7 @@ it.each([false, true])(
processSupervisor: supervisor,
runtimeRoot,
shutdownConfirmed: false,
externalApplication: undefined,
};
const socket = new Socket();
onTestFinished(() => {
@@ -393,6 +394,7 @@ it("preserves confirmed document shutdown while retrying owned process cleanup",
processSupervisor: supervisor,
runtimeRoot,
shutdownConfirmed: false,
externalApplication: undefined,
} satisfies HopperOwnedResources,
activeRequest: null,
retainDocument: false,
@@ -457,6 +459,7 @@ it.each([
processSupervisor: supervisor,
runtimeRoot,
shutdownConfirmed: false,
externalApplication: undefined,
};
const result = await cleanupHopperSession({
socket: new Socket(),
@@ -30,7 +30,11 @@ describe("HopperClient restart", () => {
await expect(client.start()).resolves.toEqual({
ok: true,
value: { name: "REA Hopper bridge", version: "1.0.0" },
value: {
name: "REA Hopper bridge",
version: "1.0.0",
processId: expect.any(Number),
},
});
});
@@ -42,7 +46,11 @@ describe("HopperClient restart", () => {
await closing;
await expect(restarting).resolves.toEqual({
ok: true,
value: { name: "REA Hopper bridge", version: "1.0.0" },
value: {
name: "REA Hopper bridge",
version: "1.0.0",
processId: expect.any(Number),
},
});
await expect(client.callTool("echo", { value: "fresh" })).resolves.toEqual({
ok: true,
+11 -12
View File
@@ -186,7 +186,7 @@ describe("IDA provider composition", () => {
});
describe("IDA binary_overview execution", () => {
it("reports document selection unavailable when binary_overview executes against an IDA session", async () => {
it("uses the bound IDA document and omits unsupported segment coverage", async () => {
const { root, target } = await createIdaTarget();
roots.push(root);
const path = join(root, "registration.json");
@@ -200,17 +200,16 @@ describe("IDA binary_overview execution", () => {
expect((await session.open(target.path)).ok).toBe(true);
const tools = new EnhancedTools(session);
const overview = await tools.execute("binary_overview", {});
expect(overview).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
providerId: "ida",
operation: "list_documents",
message: expect.stringContaining(
"operation is not declared by this provider",
),
},
});
expect(overview.ok).toBe(true);
if (overview.ok) {
expect(overview.value).toMatchObject({
document: target.path,
procedure_count: 1,
string_count: 1,
});
expect(overview.value).not.toHaveProperty("segments");
expect(overview.value).not.toHaveProperty("segment_count");
}
const procedures = await session.execute("list_procedures", {});
expect(procedures.ok).toBe(true);
expect((await session.close()).ok).toBe(true);
@@ -12,6 +12,8 @@ import ghidra.program.model.listing.FlowOverride;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.RefType;
import java.io.File;
import java.lang.reflect.Method;
import java.nio.file.Files;
@@ -20,9 +22,12 @@ import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/** Injects reported analysis flags into imported ELF/Mach-O functions; never executes target code. */
public class ReaNoReturnProbe extends GhidraScript {
private record MemoryCallReference(Address source, Address target, RefType type,
SourceType sourceType, int operandIndex, boolean primary) {}
private final JsonArray checks = new JsonArray();
private final Map<Function, Address> originalEnds = new LinkedHashMap<>();
private final Map<Function, String> originalSignatures = new LinkedHashMap<>();
@@ -85,6 +90,114 @@ public class ReaNoReturnProbe extends GhidraScript {
return instance;
}
private void knownInteriorCall(GhidraScript bridge) throws Exception {
Function caller = function("rea_tls_chain");
Function target = function("rea_tls");
Address interior = target.getEntryPoint().add(1);
require("interior_target_is_owned", currentProgram.getFunctionManager().getFunctionAt(interior) == null &&
target.equals(currentProgram.getFunctionManager().getFunctionContaining(interior)));
Instruction call = null;
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
if (instruction.getFlowType().isCall()) { call = instruction; break; }
}
if (call == null) throw new IllegalStateException("Missing real call instruction");
var references = currentProgram.getReferenceManager();
List<Reference> originals = new ArrayList<>();
List<MemoryCallReference> saved = new ArrayList<>();
for (Reference reference : call.getReferencesFrom()) {
if (!reference.getReferenceType().isCall()) continue;
originals.add(reference);
saved.add(new MemoryCallReference(reference.getFromAddress(), reference.getToAddress(),
reference.getReferenceType(), reference.getSource(), reference.getOperandIndex(), reference.isPrimary()));
}
require("original_call_reference_present", !originals.isEmpty());
Reference changed = null;
try {
for (Reference reference : originals) references.delete(reference);
changed = references.addMemoryReference(call.getAddress(), interior,
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, 0);
require("interior_target_omitted_by_callee_getter", !caller.getCalledFunctions(monitor).contains(target));
JsonObject query = new JsonObject();
query.addProperty("document", currentProgram.getName());
query.addProperty("procedure", caller.getName());
query.addProperty("direction", "outgoing");
Method read = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
read.setAccessible(true);
JsonObject output = (JsonObject) read.invoke(bridge, query);
require("interior_reference_keeps_known_procedure", output.getAsJsonArray("references").asList().stream().anyMatch(
edge -> !edge.getAsJsonObject().get("target_procedure").isJsonNull() &&
edge.getAsJsonObject().getAsJsonObject("target_procedure").get("name").getAsString().equals(target.getName())));
require("known_reference_is_not_also_unresolved", output.getAsJsonArray("unresolved_calls").isEmpty());
query.remove("direction");
Method callees = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
callees.setAccessible(true);
JsonObject inventory = (JsonObject) callees.invoke(bridge, query, false);
require("omitted_callee_inventory_stays_partial", inventory.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
require("omitted_callee_direction_is_explicit",
inventory.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callees\"]"));
require("interior_caller_omitted_by_entry_getter", !target.getCallingFunctions(monitor).contains(caller));
query.addProperty("procedure", target.getName());
JsonObject incoming = (JsonObject) callees.invoke(bridge, query, true);
require("off_entry_caller_inventory_stays_partial",
incoming.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
require("off_entry_caller_direction_is_explicit",
incoming.getAsJsonObject("relationship_coverage").getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
JsonObject dossierQuery = new JsonObject();
dossierQuery.addProperty("procedure", target.getName());
Method analyze = bridge.getClass().getDeclaredMethod("analyzeFunctionObservation", JsonObject.class);
analyze.setAccessible(true);
Object observation = analyze.invoke(bridge, dossierQuery);
Method coverage = observation.getClass().getDeclaredMethod("relationshipCoverage");
coverage.setAccessible(true);
require("dossier_preserves_caller_only_gap",
((JsonObject) coverage.invoke(observation)).getAsJsonArray("limited_directions").toString().equals("[\"callers\"]"));
} finally {
if (changed != null) references.delete(changed);
for (MemoryCallReference reference : saved) {
Reference restored = references.addMemoryReference(reference.source(), reference.target(),
reference.type(), reference.sourceType(), reference.operandIndex());
references.setPrimary(restored, reference.primary());
}
}
}
private void nonCallInstructionReference(GhidraScript bridge) throws Exception {
Function caller = function("rea_tls_chain");
Function target = function("rea_memcpy");
Instruction source = null;
for (Instruction instruction : currentProgram.getListing().getInstructions(caller.getBody(), true)) {
if (!instruction.getFlowType().isCall()) { source = instruction; break; }
}
if (source == null) throw new IllegalStateException("Missing non-call instruction");
var references = currentProgram.getReferenceManager();
Reference added = references.addMemoryReference(source.getAddress(), target.getEntryPoint().add(1),
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
try {
require("non_call_reference_target_omitted", !caller.getCalledFunctions(monitor).contains(target));
JsonObject query = new JsonObject();
query.addProperty("document", currentProgram.getName());
query.addProperty("procedure", caller.getName());
Method read = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class);
read.setAccessible(true);
JsonObject output = (JsonObject) read.invoke(bridge, query, false);
require("non_call_instruction_reference_stays_partial",
output.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("partial"));
query.addProperty("direction", "outgoing");
Method edges = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class);
edges.setAccessible(true);
JsonObject known = (JsonObject) edges.invoke(bridge, query);
require("non_call_known_reference_is_not_unresolved", known.getAsJsonArray("unresolved_calls").isEmpty());
references.delete(added);
Address unknown = currentProgram.getMemory().getMaxAddress().add(0x1000);
added = references.addMemoryReference(source.getAddress(), unknown,
RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1);
JsonObject unresolved = (JsonObject) edges.invoke(bridge, query);
require("non_call_unknown_reference_keeps_site", unresolved.getAsJsonArray("unresolved_calls").size() == 1);
} finally {
references.delete(added);
}
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
@@ -164,6 +277,8 @@ public class ReaNoReturnProbe extends GhidraScript {
Object output = pseudocode.invoke(bridge, query);
require("recovered_pseudocode", output instanceof JsonObject value &&
value.get("value").getAsString().contains("rea_after_call"));
nonCallInstructionReference(bridge);
knownInteriorCall(bridge);
} finally {
((ghidra.app.decompiler.DecompInterface) field.get(bridge)).dispose();
}
@@ -17,16 +17,25 @@ const dependencies = (
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
private_runtime_dacl: {
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
reparse_safe_path_admission: {
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
}),
probeSymlinkCreation: () =>
@@ -91,16 +100,25 @@ describe("Windows host capability report", () => {
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
private_runtime_dacl: {
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
reparse_safe_path_admission: {
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
proof: "not-proven",
code: "runtime_missing",
failureStage: "artifact",
remediation: "Install the matching Windows x64 REA package.",
},
},
});
+8
View File
@@ -32,7 +32,15 @@ if (mode === "silent") setInterval(() => undefined, 1_000);
else {
await rm(endpointPath, { force: true });
const sockets = new Set();
let rejectedConnections = 0;
const server = createServer((socket) => {
if (
mode === "reject_all_connections" ||
(mode === "reject_first_connection" && rejectedConnections++ === 0)
) {
socket.destroy();
return;
}
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
socket.setEncoding("utf8");
+1
View File
@@ -217,6 +217,7 @@ const server = createServer((socket) => {
name: "REA Hopper bridge",
version: "1.0.0",
run_id: runId,
process_id: process.pid,
},
},
true,
+107
View File
@@ -0,0 +1,107 @@
"""JDK-only TCP transport regression using the production admission methods.
Run: python3 tests/fixtures/ghidraHandshakeProbe.py /path/to/java
Ghidra request parsing/dispatch are fixture seams; no engine is required.
"""
import pathlib
import subprocess
import sys
import tempfile
root = pathlib.Path(__file__).resolve().parents[2]
bridge = (root / "bridge/ghidra/ReaGhidraBridge.java").read_text()
start = bridge.index(" private static String readLoopbackHandshake(")
end = bridge.index(" private void acceptClient(", start)
methods = bridge[start:end]
harness = r'''
import java.io.*;
import java.net.*;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
public class ReaHandshakeProbe {
record SessionDescriptor(String token) {}
record Request(String method) {}
private Request parseRequest(String line, String token) {
if (!line.equals(token + ":ping")) throw new IllegalArgumentException();
return new Request("ping");
}
private void serveClient(SessionDescriptor descriptor, BufferedReader reader,
BufferedWriter writer, Request first) throws Exception {
if (!first.method().equals("ping")) throw new AssertionError("first request lost");
String next = reader.readLine();
if (next == null || next.length() != 200_000)
throw new AssertionError("pipelined authenticated request lost or capped");
writer.write("accepted\n");
writer.flush();
}
static Socket connect(int port) throws Exception {
Socket peer = new Socket("127.0.0.1", port);
peer.setSoTimeout(3_000);
return peer;
}
static void closed(Socket peer) throws Exception {
if (peer.getInputStream().read() != -1) throw new AssertionError("peer not rejected");
}
public static void main(String[] args) throws Exception {
try (ServerSocketChannel server = ServerSocketChannel.open();
ExecutorService worker = Executors.newSingleThreadExecutor()) {
server.bind(new InetSocketAddress("127.0.0.1", 0));
int port = ((InetSocketAddress) server.getLocalAddress()).getPort();
Future<?> serving = worker.submit(() -> {
try { new ReaHandshakeProbe().acceptAuthenticatedLoopbackClient(
server, new SessionDescriptor("secret")); }
catch (Exception error) { throw new RuntimeException(error); }
});
try (Socket invalid = connect(port)) {
invalid.getOutputStream().write("bad-token:ping\n".getBytes(StandardCharsets.UTF_8));
closed(invalid);
}
// EOF before a newline is not an authenticated request.
try (Socket eof = connect(port)) {
eof.getOutputStream().write("secret:ping".getBytes(StandardCharsets.UTF_8));
eof.shutdownOutput();
closed(eof);
}
try (Socket oversized = connect(port)) {
oversized.getOutputStream().write(new byte[64 * 1024 + 1]);
closed(oversized);
}
try (Socket idle = connect(port)) {
long start = System.nanoTime();
closed(idle);
long elapsed = (System.nanoTime() - start) / 1_000_000;
if (elapsed < 700 || elapsed > 2_500) throw new AssertionError("idle deadline " + elapsed);
}
try (Socket trickle = connect(port)) {
long start = System.nanoTime();
for (int i = 0; i < 4; i++) {
trickle.getOutputStream().write('x');
Thread.sleep(250);
}
closed(trickle);
if ((System.nanoTime() - start) / 1_000_000 > 2_000)
throw new AssertionError("slow input extended deadline");
}
// Same write contains the first and the following frame. The first
// reader must consume exactly one line without swallowing lookahead.
try (Socket valid = connect(port)) {
valid.getOutputStream().write(("secret:ping\n" + "x".repeat(200_000) + "\n")
.getBytes(StandardCharsets.UTF_8));
String reply = new BufferedReader(new InputStreamReader(valid.getInputStream(),
StandardCharsets.UTF_8)).readLine();
if (!"accepted".equals(reply)) throw new AssertionError("legitimate client failed");
}
serving.get(3, TimeUnit.SECONDS);
}
System.out.println("PASS: rejection, EOF, frame ceiling, idle/trickle deadlines, retry, pipeline, authenticated large request");
}
__METHODS__
}
'''.replace("__METHODS__", methods)
with tempfile.TemporaryDirectory(prefix="rea-ghidra-handshake-") as directory:
source = pathlib.Path(directory) / "ReaHandshakeProbe.java"
source.write_text(harness)
subprocess.run([sys.argv[1] if len(sys.argv) > 1 else "java", str(source)],
check=True, timeout=30)