Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cd103b31 | ||
|
|
a6c3d3a365 | ||
|
|
d82ad1faf0 | ||
|
|
2dc89f81be | ||
|
|
30128a03cf | ||
|
|
94e360c11b | ||
|
|
7b9ab778a1 | ||
|
|
a92b960f6f | ||
|
|
da49ea2692 | ||
|
|
f993d33028 |
@@ -2,7 +2,7 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "41"
|
||||
version: "42"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -104,6 +104,10 @@ explicit path or endpoint and do not need it.
|
||||
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
|
||||
optional metadata recovery and its supported host/layout boundary.
|
||||
- User-owned browser page already open: `list_browser_targets`.
|
||||
- Browser action the agent must perform (click, type, navigate):
|
||||
`capture_browser_scenario` with declared actions. See
|
||||
[runtime observation](references/runtime-observation.md) for connect/launch
|
||||
and the focused follow-up browser tools.
|
||||
- Fiddler Everywhere traffic or a native `.saz` capture: read the
|
||||
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
|
||||
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
|
||||
@@ -323,7 +327,8 @@ as complete while required questions remain open.
|
||||
[references/javascript-applications.md](references/javascript-applications.md)
|
||||
- Android APK declarations, classes, methods, and static references:
|
||||
[references/android-applications.md](references/android-applications.md)
|
||||
- Passive browser/Electron observation and static/runtime reconciliation:
|
||||
- Browser/Electron observation (passive and scenario) and static/runtime
|
||||
reconciliation:
|
||||
[references/runtime-observation.md](references/runtime-observation.md)
|
||||
- Evidence paging, comparisons, residual unknowns, and verification:
|
||||
[references/evidence-workflows.md](references/evidence-workflows.md)
|
||||
|
||||
@@ -103,6 +103,11 @@ architectures, build_metadata, load_commands, dependencies, imports, exports,
|
||||
segments and provenance. `source_exhaustive: false` means unreturned symbols
|
||||
may exist.
|
||||
|
||||
`decode_interface_builder` graphs are often several megabytes and can exceed the
|
||||
MCP response budget. Select `detail: "summary"` for every decoded document and
|
||||
node/edge counts, then page `view: {kind: "interface-builder", collection:
|
||||
"edges", document: "<documents[].relative_path>", offset: 0, limit: 64}`.
|
||||
|
||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Passive browser and Electron observation
|
||||
# Browser and Electron runtime observation
|
||||
|
||||
Supply the existing browser or Electron instance's literal-port loopback CDP
|
||||
endpoint in the request. No separate permission configuration or per-call
|
||||
@@ -7,13 +7,14 @@ approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
|
||||
selected page's current origin. Electron discovery lists eligible local
|
||||
`file://` targets without a configured file-root allowlist.
|
||||
|
||||
Start browser work with `list_browser_targets`; start Electron runtime work with
|
||||
Start passive browser work with `list_browser_targets`; start Electron runtime work with
|
||||
`list_electron_targets`. Inspect the target selected for the user's task. Observation
|
||||
is passive: never claim REA clicked, navigated, evaluated page JavaScript,
|
||||
invoked IPC, captured prior activity, or contained the page's network.
|
||||
with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
|
||||
invoked IPC, or captured prior activity. REA does not contain the page's network.
|
||||
|
||||
Credentials, cookies, authorization headers, storage values, and raw
|
||||
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query
|
||||
Passive captures omit raw header collections, credential header values, storage
|
||||
values, and raw WebSocket/JSON bodies. Selected response metadata, including
|
||||
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
|
||||
values and fragments; userinfo credentials are removed. Browser accessibility
|
||||
text, console primitives, value-free shapes, script sources, source maps, and
|
||||
storage key names have request-specific capture options. Electron script
|
||||
@@ -21,6 +22,38 @@ sources are also optional. Use `capture_web_screenshot` when pixels are needed.
|
||||
Treat scope filtering, truncation, and attach-window coverage as limitations.
|
||||
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
|
||||
|
||||
## Who performs the action
|
||||
|
||||
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
|
||||
`observe_web_session` on the selected target.
|
||||
- The agent must perform it: one `capture_browser_scenario` with declared
|
||||
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
|
||||
an existing browser, or launch mode with a caller-selected executable for a
|
||||
temporary profile. Connect mode first navigates the selected page to
|
||||
`start_url`, which can clear unsaved form values and other temporary state.
|
||||
Recreate the state needed for the investigation in the declared actions.
|
||||
Connect mode leaves the external browser open; launch mode owns a temporary
|
||||
browser and profile. Check `browser.cleanup` for the outcome.
|
||||
Request only the DOM, accessibility, or network details needed for the question.
|
||||
The default final capture contains the URL; step outcomes are reported separately.
|
||||
Add a wait action for the expected page state before capturing it. Report that
|
||||
REA performed the declared actions.
|
||||
|
||||
Scenario network records have no request initiator stacks. For that evidence,
|
||||
use passive capture while the user performs the action. Listener source locations
|
||||
identify candidate handlers; they do not establish which script sent a particular
|
||||
request. Avoid overlapping passive capture and scenario actions on the same target.
|
||||
|
||||
Focused follow-up tools:
|
||||
|
||||
- `observe_web_execution`: execution coverage during a window; it resets
|
||||
counters and temporarily disables optimization.
|
||||
- `inspect_web_event_listeners`: source locations for listeners registered
|
||||
directly on the first CSS match, without dispatching an event.
|
||||
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
|
||||
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
|
||||
the discovered tools.
|
||||
|
||||
Use `reconcile_javascript_runtime` only after static application Evidence and
|
||||
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
|
||||
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
|
||||
|
||||
+12
-133
@@ -11,7 +11,6 @@ import math
|
||||
import re
|
||||
import resource
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote_to_bytes
|
||||
|
||||
from mitmproxy import ctx, version
|
||||
from mitmproxy.io import tnetstring
|
||||
@@ -42,113 +41,6 @@ class BoundedReader:
|
||||
return self.handle.read(count)
|
||||
|
||||
|
||||
SPECIAL_SCHEMES = {"ftp", "file", "http", "https", "ws", "wss"}
|
||||
SCHEME_PREFIX = r"^[\x00-\x20]*([a-z][a-z0-9+.-]*):"
|
||||
NETWORK_PREFIX = r"^[\x00-\x20]*[/\\]{2}"
|
||||
URL_IGNORABLE_CONTROLS = "\t\n\r"
|
||||
URL_CONTROL_TRANSLATION = {ord(char): None for char in URL_IGNORABLE_CONTROLS}
|
||||
FORBIDDEN_DOMAIN_CODE_POINTS = {chr(code) for code in range(0x21)} | set("#%/:<>?@[\\]^|\x7f")
|
||||
|
||||
|
||||
def authority_start(text, base_scheme=None):
|
||||
"""Return where an authority begins and whether it is special, or None.
|
||||
|
||||
Follows WHATWG URL parsing as src/domain/recordedTransportUrl.ts does:
|
||||
special schemes (and scheme-relative references) skip every leading "/"
|
||||
or "\\" before the authority, so `https://\\user:pass@host` still has
|
||||
userinfo. File URLs never carry userinfo: an "@" in a file host makes
|
||||
WHATWG reject the whole URL, so there are no credentials to remove.
|
||||
"""
|
||||
scheme = re.match(SCHEME_PREFIX, text, flags=re.I)
|
||||
name = scheme.group(1).lower() if scheme is not None else None
|
||||
if name == "file" or (name is None and base_scheme == "file"):
|
||||
return None
|
||||
if name is not None and name in SPECIAL_SCHEMES:
|
||||
# A same-scheme Location without two slash-equivalent separators is
|
||||
# relative to the recorded request, so its @ belongs to the path.
|
||||
following = text[scheme.end():scheme.end() + 2]
|
||||
if name == base_scheme and (len(following) < 2 or any(char not in "/\\" for char in following)):
|
||||
return None
|
||||
start = scheme.end()
|
||||
else:
|
||||
if scheme is not None:
|
||||
if text[scheme.end():scheme.end() + 2] != "//":
|
||||
return None
|
||||
return scheme.end() + 2, False
|
||||
# A non-special base treats backslashes as authority data and only
|
||||
# recognizes two forward slashes as a network reference.
|
||||
if base_scheme is not None and base_scheme not in SPECIAL_SCHEMES:
|
||||
prefix = re.match(r"^[\x00-\x20]*//", text)
|
||||
return None if prefix is None else (prefix.end(), False)
|
||||
prefix = re.match(NETWORK_PREFIX, text)
|
||||
if prefix is None:
|
||||
return None
|
||||
start = prefix.end()
|
||||
while start < len(text) and text[start] in "/\\":
|
||||
start += 1
|
||||
return start, True
|
||||
|
||||
|
||||
def special_host_parses(host_port):
|
||||
"""Whether WHATWG could accept a special authority's host and port.
|
||||
|
||||
A rejected host fails the whole URL, so it has no credentials to remove.
|
||||
This mirrors the forbidden-code-point, empty-host and port rules; IDNA,
|
||||
IPv4 range and IPv6 syntax failures are not modeled and still redact.
|
||||
"""
|
||||
if host_port.startswith("["):
|
||||
host_end = host_port.find("]") + 1
|
||||
if host_end == 0:
|
||||
return False
|
||||
else:
|
||||
colon = host_port.find(":")
|
||||
host_end = len(host_port) if colon < 0 else colon
|
||||
host = unquote_to_bytes(host_port[:host_end]).decode("utf-8", errors="replace")
|
||||
if host == "" or any(char in FORBIDDEN_DOMAIN_CODE_POINTS for char in host):
|
||||
return False
|
||||
port = host_port[host_end:]
|
||||
if port == "":
|
||||
return True
|
||||
digits = port[1:]
|
||||
return port[0] == ":" and (digits == "" or (digits.isascii() and digits.isdigit() and int(digits) <= 65535))
|
||||
|
||||
|
||||
def without_userinfo(value, base_scheme=None):
|
||||
"""Remove an authority's userinfo using the WHATWG authority boundary.
|
||||
|
||||
Special schemes and scheme-relative references also end the authority
|
||||
at a backslash, so later path text containing "@" is not userinfo.
|
||||
"""
|
||||
binary = isinstance(value, bytes)
|
||||
text = value.decode("latin-1") if binary else value
|
||||
# WHATWG removes these controls before interpreting scheme/separators.
|
||||
# Keep that parse view separate from the exact source spelling and bytes.
|
||||
parsed_text = text.translate(URL_CONTROL_TRANSLATION)
|
||||
located = authority_start(parsed_text, base_scheme)
|
||||
if located is None:
|
||||
return value
|
||||
start, special = located
|
||||
terminators = "/?#\\" if special else "/?#"
|
||||
end = start
|
||||
while end < len(parsed_text) and parsed_text[end] not in terminators:
|
||||
end += 1
|
||||
at = parsed_text.rfind("@", start, end)
|
||||
if at < start or (special and not special_host_parses(parsed_text[at + 1 : end])):
|
||||
return value
|
||||
parsed_offset = 0
|
||||
raw_start = 0
|
||||
for raw_offset, char in enumerate(text):
|
||||
if char in URL_IGNORABLE_CONTROLS:
|
||||
continue
|
||||
if parsed_offset == start:
|
||||
raw_start = raw_offset
|
||||
if parsed_offset == at:
|
||||
safe = text[:raw_start] + text[raw_offset + 1 :]
|
||||
return safe.encode("latin-1") if binary else safe
|
||||
parsed_offset += 1
|
||||
raise CaptureFailure("decoder", "Normalized URL authority could not be bound to its source.")
|
||||
|
||||
|
||||
def project_record(state, secrets):
|
||||
binaries, numbers, redactions = [], [], []
|
||||
byte_secrets = []
|
||||
@@ -166,23 +58,16 @@ def project_record(state, secrets):
|
||||
return None
|
||||
return text
|
||||
|
||||
def transport_value(value, path, transport_url, base_scheme):
|
||||
def transport_value(value, path, transport_url):
|
||||
if not (transport_url or re.fullmatch(r"/(?:backup/)*request/(?:path|authority)", path)):
|
||||
return value, False
|
||||
binary = isinstance(value, bytes)
|
||||
pattern = rb"^([\x00-\x20]*(?:[a-z][a-z0-9+.-]*:)?//)[^/?#]*@" if binary else r"^([\x00-\x20]*(?:[a-z][a-z0-9+.-]*:)?//)[^/?#]*@"
|
||||
replacement = rb"\1" if binary else r"\1"
|
||||
separator = b"@" if binary else "@"
|
||||
if transport_url:
|
||||
safe_url = without_userinfo(value, base_scheme)
|
||||
elif path.endswith("/authority"):
|
||||
safe_url = value.rsplit(separator, 1)[-1]
|
||||
else:
|
||||
# Native request.path is origin-form unless it explicitly carries
|
||||
# a scheme (absolute-form). Leading separators are path evidence,
|
||||
# not a header URL reference's network authority.
|
||||
text = value.decode("latin-1") if binary else value
|
||||
safe_url = without_userinfo(value) if re.match(
|
||||
SCHEME_PREFIX, text.translate(URL_CONTROL_TRANSLATION), flags=re.I
|
||||
) else value
|
||||
safe_url = re.sub(pattern, replacement, value, flags=re.I)
|
||||
if path.endswith("/authority") and separator in safe_url:
|
||||
safe_url = safe_url.rsplit(separator, 1)[1]
|
||||
changed = safe_url != value
|
||||
if changed:
|
||||
redactions.append({"pointer": path, "reason": "transport-credential"})
|
||||
@@ -195,7 +80,7 @@ def project_record(state, secrets):
|
||||
del numbers[before[1]:]
|
||||
del redactions[before[2]:]
|
||||
|
||||
def visit(value, path, depth=0, credential=False, transport_url=False, base_scheme=None):
|
||||
def visit(value, path, depth=0, credential=False, transport_url=False):
|
||||
if depth > 64:
|
||||
raise CaptureFailure("input-limit", "Native capture exceeds the 64-level evidence nesting budget.", path)
|
||||
if credential:
|
||||
@@ -205,7 +90,7 @@ def project_record(state, secrets):
|
||||
binaries.append({"pointer": path, "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None})
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
safe_bytes, credential_url = transport_value(value, path, transport_url, base_scheme)
|
||||
safe_bytes, credential_url = transport_value(value, path, transport_url)
|
||||
content_base64 = base64.b64encode(value).decode("ascii")
|
||||
digest = hashlib.sha256(value).hexdigest()
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
|
||||
@@ -222,7 +107,7 @@ def project_record(state, secrets):
|
||||
return None
|
||||
return safe_text
|
||||
if isinstance(value, str):
|
||||
safe_text, _ = transport_value(value, path, transport_url, base_scheme)
|
||||
safe_text, _ = transport_value(value, path, transport_url)
|
||||
return redact(safe_text, path, value)
|
||||
if isinstance(value, bool) or value is None:
|
||||
return value
|
||||
@@ -240,16 +125,10 @@ def project_record(state, secrets):
|
||||
for index, field in enumerate(value):
|
||||
if not isinstance(field, (list, tuple)) or len(field) != 2 or not isinstance(field[0], bytes):
|
||||
raise CaptureFailure("format", "Native header/trailer is not an ordered byte pair.", pointer(path, index))
|
||||
result.append([visit(field[0], pointer(pointer(path, index), 0), depth + 2), visit(field[1], pointer(pointer(path, index), 1), depth + 2, field[0].lower() in CREDENTIALS, field[0].lower() in {b"location", b"referer", b"origin"}, base_scheme if field[0].lower() == b"location" else None)])
|
||||
result.append([visit(field[0], pointer(pointer(path, index), 0), depth + 2), visit(field[1], pointer(pointer(path, index), 1), depth + 2, field[0].lower() in CREDENTIALS, field[0].lower() in {b"location", b"referer", b"origin"})])
|
||||
return result
|
||||
return [visit(item, pointer(path, index), depth + 1, base_scheme=base_scheme) for index, item in enumerate(value)]
|
||||
return [visit(item, pointer(path, index), depth + 1) for index, item in enumerate(value)]
|
||||
if isinstance(value, dict):
|
||||
if path == "" or re.fullmatch(r"/backup(?:/backup)*", path):
|
||||
request = value.get("request")
|
||||
scheme = request.get("scheme") if isinstance(request, dict) else None
|
||||
if isinstance(scheme, bytes):
|
||||
scheme = scheme.decode("latin-1")
|
||||
base_scheme = scheme.lower() if isinstance(scheme, str) and re.fullmatch(r"[a-z][a-z0-9+.-]*", scheme, flags=re.I) else None
|
||||
result = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
@@ -260,7 +139,7 @@ def project_record(state, secrets):
|
||||
validate_omitted(item, pointer(path, key), depth + 1)
|
||||
redactions.append({"pointer": path, "reason": "explicit-sensitive-value", "scope": "property-name"})
|
||||
continue
|
||||
result[key] = visit(item, pointer(path, key), depth + 1, base_scheme=base_scheme)
|
||||
result[key] = visit(item, pointer(path, key), depth + 1)
|
||||
return result
|
||||
raise CaptureFailure("decoder", "Native parser returned an unexpected value type: " + type(value).__name__ + ".", path)
|
||||
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Source-owned tests for the mitmproxy adapter's record projection."""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def load_capture():
|
||||
"""Load the adapter with fresh mitmproxy stand-ins scoped to its import."""
|
||||
fake_mitmproxy = types.ModuleType("mitmproxy")
|
||||
fake_mitmproxy.ctx = types.SimpleNamespace()
|
||||
fake_mitmproxy.version = types.SimpleNamespace(VERSION="12.2.3")
|
||||
fake_io = types.ModuleType("mitmproxy.io")
|
||||
fake_io.tnetstring = types.ModuleType("mitmproxy.io.tnetstring")
|
||||
stand_ins = {
|
||||
"mitmproxy": fake_mitmproxy,
|
||||
"mitmproxy.io": fake_io,
|
||||
"mitmproxy.io.tnetstring": fake_io.tnetstring,
|
||||
}
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"rea_mitmproxy_capture", os.path.join(os.path.dirname(__file__), "capture.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, stand_ins):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
capture = load_capture()
|
||||
|
||||
|
||||
def location(value, scheme=None):
|
||||
state = {"response": {"headers": [[b"Location", value]]}}
|
||||
if scheme is not None:
|
||||
state["request"] = {"scheme": scheme}
|
||||
record = capture.project_record(state, [])
|
||||
sidecar = next(
|
||||
field for field in record["binary_fields"] if field["pointer"] == "/response/headers/0/1"
|
||||
)
|
||||
return record["reported"]["response"]["headers"][0][1], sidecar, record["redactions"]
|
||||
|
||||
|
||||
class TransportUserinfoTests(unittest.TestCase):
|
||||
def test_ignorable_controls_cannot_hide_authority_credentials(self):
|
||||
for control in [b"\t", b"\n", b"\r"]:
|
||||
for value in [b"https://" + control + b"/user:pass@host/path", b"ht" + control + b"tps://user:" + control + b"pass@host/path"]:
|
||||
with self.subTest(value=value):
|
||||
text, sidecar, redactions = location(value, b"https")
|
||||
self.assertNotIn("user", text)
|
||||
self.assertNotIn("pass", text)
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(len(redactions), 1)
|
||||
|
||||
def test_mixed_network_separators_remove_credentials(self):
|
||||
for prefix in [b"\\\\", b"/\\", b"\\/"]:
|
||||
with self.subTest(prefix=prefix):
|
||||
text, sidecar, redactions = location(prefix + b"user:pass@host/path", b"https")
|
||||
self.assertEqual(text, prefix.decode("ascii") + "host/path")
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(len(redactions), 1)
|
||||
|
||||
def test_non_special_base_keeps_backslashes_inside_network_authorities(self):
|
||||
text, sidecar, redactions = location(b"//user\\name:pass@host/x", b"foo")
|
||||
self.assertEqual(text, "//host/x")
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(len(redactions), 1)
|
||||
text, sidecar, redactions = location(b"/\\invoice@customer", b"foo")
|
||||
self.assertEqual(text, "/\\invoice@customer")
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_native_request_paths_preserve_origin_form_and_redact_absolute_form(self):
|
||||
for value in [b"/\\invoice@customer", b"//invoice@customer", b"\\\\invoice@customer"]:
|
||||
with self.subTest(value=value):
|
||||
record = capture.project_record({"request": {"path": value}}, [])
|
||||
self.assertEqual(record["reported"]["request"]["path"], value.decode("ascii"))
|
||||
self.assertEqual(record["binary_fields"][0]["state"], "retained")
|
||||
self.assertEqual(record["redactions"], [])
|
||||
record = capture.project_record({"request": {"path": b"https://user:pass@host/x"}}, [])
|
||||
self.assertEqual(record["reported"]["request"]["path"], "https://host/x")
|
||||
self.assertEqual(record["binary_fields"][0]["state"], "redacted")
|
||||
|
||||
def test_same_scheme_locations_preserve_relative_path_text_and_bytes(self):
|
||||
for value in [b"https:invoice@customer", b"https:/invoice@customer", b"https:\\invoice@customer"]:
|
||||
with self.subTest(value=value):
|
||||
text, sidecar, redactions = location(value, b"https")
|
||||
self.assertEqual(text, value.decode("ascii"))
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_location_authorities_still_remove_credentials_with_a_base_scheme(self):
|
||||
for value, scheme, expected in [
|
||||
(b"https://user:pass@host/path", b"https", "https://host/path"),
|
||||
(b"https:\\\\user:pass@host/path", b"https", "https:\\\\host/path"),
|
||||
(b"https:user:pass@host/path", b"http", "https:host/path"),
|
||||
]:
|
||||
with self.subTest(value=value, scheme=scheme):
|
||||
text, sidecar, redactions = location(value, scheme)
|
||||
self.assertEqual(text, expected)
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(len(redactions), 1)
|
||||
|
||||
def test_backup_location_uses_its_own_request_scheme(self):
|
||||
record = capture.project_record({
|
||||
"request": {"scheme": b"http"},
|
||||
"backup": {
|
||||
"request": {"scheme": b"https"},
|
||||
"response": {"headers": [[b"Location", b"https:invoice@customer"]]},
|
||||
},
|
||||
}, [])
|
||||
self.assertEqual(record["reported"]["backup"]["response"]["headers"][0][1], "https:invoice@customer")
|
||||
self.assertEqual(record["redactions"], [])
|
||||
|
||||
def test_backslash_ends_a_special_scheme_authority(self):
|
||||
text, sidecar, redactions = location(b"https://example.test\\part@tail")
|
||||
self.assertEqual(text, "https://example.test\\part@tail")
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertIsNotNone(sidecar["content_base64"])
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_userinfo_before_a_backslash_is_still_removed(self):
|
||||
text, sidecar, redactions = location(b"https://user:pw@example.test\\path")
|
||||
self.assertEqual(text, "https://example.test\\path")
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(
|
||||
redactions,
|
||||
[{"pointer": "/response/headers/0/1", "reason": "transport-credential"}],
|
||||
)
|
||||
|
||||
def test_ordinary_userinfo_is_removed(self):
|
||||
text, _sidecar, _redactions = location(b"https://user:pw@example.test/a@b")
|
||||
self.assertEqual(text, "https://example.test/a@b")
|
||||
|
||||
def test_empty_userinfo_marker_is_removed(self):
|
||||
self.assertEqual(
|
||||
capture.without_userinfo("https://@host"), "https://host"
|
||||
)
|
||||
|
||||
def test_special_schemes_skip_leading_slashes_before_userinfo(self):
|
||||
for value, expected in [
|
||||
(b"https://\\user:pass@host/path", b"https://\\host/path"),
|
||||
(b"https:\\\\user:pass@host/path", b"https:\\\\host/path"),
|
||||
(b"https:user:pass@host", b"https:host"),
|
||||
(b"//\\user:pass@host", b"//\\host"),
|
||||
]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(capture.without_userinfo(value), expected)
|
||||
text, sidecar, redactions = location(b"https://\\user:pass@host/path")
|
||||
self.assertEqual(text, "https://\\host/path")
|
||||
self.assertEqual(sidecar["state"], "redacted")
|
||||
self.assertEqual(len(redactions), 1)
|
||||
|
||||
def test_file_base_keeps_paths_after_the_fixed_network_boundary(self):
|
||||
for value in [b"///u@h", b"////u@h"]:
|
||||
with self.subTest(value=value):
|
||||
text, sidecar, redactions = location(value, b"file")
|
||||
self.assertEqual(text, value.decode("ascii"))
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertIsNotNone(sidecar["content_base64"])
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_file_urls_keep_path_text(self):
|
||||
self.assertEqual(capture.without_userinfo("file:///a@b"), "file:///a@b")
|
||||
|
||||
def test_file_urls_reject_userinfo_and_keep_their_source(self):
|
||||
# WHATWG rejects an "@" in a file host, so nothing parses as credentials.
|
||||
self.assertEqual(
|
||||
capture.without_userinfo("file://user:pass@host/x"), "file://user:pass@host/x"
|
||||
)
|
||||
for value in [b"\\\\user:pass@host/x", b"/\\user:pass@host/x", b"//user:pass@host/x"]:
|
||||
with self.subTest(value=value):
|
||||
text, sidecar, redactions = location(value, b"file")
|
||||
self.assertEqual(text, value.decode("ascii"))
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_rejected_special_hosts_keep_their_source(self):
|
||||
# A rejected host or port fails the whole URL, so it has no userinfo.
|
||||
for value in [
|
||||
"https:user@%",
|
||||
"https://user@",
|
||||
"https://user:pass@h^st/x",
|
||||
"https://user@ho st/x",
|
||||
"https://user@host:99999/x",
|
||||
"https://user@host:12a",
|
||||
"https://user@[::1",
|
||||
]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(capture.without_userinfo(value), value)
|
||||
text, sidecar, redactions = location(b"https://user:pass@h^st/x", b"https")
|
||||
self.assertEqual(text, "https://user:pass@h^st/x")
|
||||
self.assertEqual(sidecar["state"], "retained")
|
||||
self.assertEqual(redactions, [])
|
||||
|
||||
def test_accepted_special_hosts_still_remove_userinfo(self):
|
||||
for value, expected in [
|
||||
("https:user@%41", "https:%41"),
|
||||
("https://user@[::1]:8443/x", "https://[::1]:8443/x"),
|
||||
("https://user@host:/x", "https://host:/x"),
|
||||
("wss://user:pass@host:65535", "wss://host:65535"),
|
||||
]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(capture.without_userinfo(value), expected)
|
||||
|
||||
def test_non_special_schemes_keep_backslashes_in_the_authority(self):
|
||||
self.assertEqual(capture.without_userinfo("foo://a\\b@host/x"), "foo://host/x")
|
||||
self.assertEqual(capture.without_userinfo(b"//a\\b@host"), b"//a\\b@host")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+13
-1
@@ -193,7 +193,8 @@ after target content was read.
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
|
||||
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
|
||||
`inspect_wasm_artifact`, `inspect_macho`, and `decode_interface_builder` accept
|
||||
`detail`:
|
||||
`complete` (default) returns the complete analysis Evidence; `summary` records
|
||||
that complete Evidence in the session and returns the derived
|
||||
`inspect_analysis_view` summary Evidence instead. Its
|
||||
@@ -276,6 +277,17 @@ symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
|
||||
summary), so select `detail: "summary"` on the first call. Delivery detail
|
||||
never reaches the native tools or changes the source Evidence ID.
|
||||
|
||||
Completed `decode_interface_builder` Evidence supports a summary and
|
||||
`view: {kind: "interface-builder", collection: "nodes", offset: 0, limit: 64}`.
|
||||
Collections are `documents`, `nodes`, `edges`, and `coverage`; for nodes and
|
||||
edges an optional `document` keeps rows whose evidence names that exact decoded
|
||||
`relative_path`, and an undecoded path is refused. The summary keeps every
|
||||
decoded document, the provider, node-kind, edge-relation and coverage-status
|
||||
counts, and whether the decoded graph is incomplete. Each page repeats `source_truncated`. A complete
|
||||
app graph often exceeds the default MCP response budget (XLD.app: 6.7 MB), so
|
||||
select `detail: "summary"` on the first call. Delivery detail never reaches the
|
||||
decoder or changes the source Evidence ID.
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes` accept complete inline application Evidence
|
||||
|
||||
@@ -319,7 +319,16 @@ complete observation.
|
||||
digest are preserved; key interpretation follows the pinned plist decoder.
|
||||
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
|
||||
compiled storyboard and nib resources into objects, outlets, actions,
|
||||
connections and controller/class names. `verify:interface-builder` compiles
|
||||
connections and controller/class names. A full app graph is often several
|
||||
megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default
|
||||
MCP response budget), so MCP callers should pass `detail: "summary"`. The
|
||||
summary keeps every decoded document and node/edge/coverage counts and
|
||||
retains the complete Evidence; page rows with `inspect_analysis_view`
|
||||
`view: {kind: "interface-builder", collection: "nodes", document:
|
||||
"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`.
|
||||
`document` selects rows whose evidence names that exact
|
||||
`documents[].relative_path` (nodes and edges only); `source_truncated` keeps
|
||||
graph incompleteness visible on every page. `verify:interface-builder` compiles
|
||||
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
|
||||
iOS platform; unsupported archive forms remain explicit.
|
||||
XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order.
|
||||
|
||||
+22
-25
@@ -20,7 +20,7 @@
|
||||
|
||||
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
|
||||
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서)
|
||||
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
|
||||
|
||||
<code>npx rea-agents setup</code>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
|
||||
|
||||
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다.
|
||||
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다.
|
||||
|
||||
> **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
|
||||
|
||||
@@ -105,12 +105,14 @@ rea --help
|
||||
|
||||
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
|
||||
|
||||
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요.
|
||||
|
||||
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
|
||||
|
||||
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
|
||||
@@ -119,7 +121,7 @@ rea update
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요.
|
||||
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. Bun을 사용한다면 위 명령의 `npx rea-agents@latest`를 `bunx rea-agents@latest`로 바꿔 실행하세요.
|
||||
|
||||
## REA의 작동 방식
|
||||
|
||||
@@ -139,14 +141,16 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
|
||||
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
|
||||
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
|
||||
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
|
||||
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
|
||||
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) |
|
||||
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) |
|
||||
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
|
||||
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
|
||||
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) |
|
||||
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) |
|
||||
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) |
|
||||
@@ -154,15 +158,18 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) |
|
||||
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) |
|
||||
| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) |
|
||||
| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) |
|
||||
| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
|
||||
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
|
||||
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
|
||||
|
||||
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
|
||||
|
||||
오프라인 네이티브 바이너리 검사에는 호출자가 제공하는 GNU objdump와 Rizin도 사용할 수 있습니다. 제공자 설정, CLI 명령, Evidence의 한계는 [리버스 엔지니어링 도구 가이드](../reverse-engineering-tools.md)를 참고하세요. REA는 objdump와 Rizin을 설치하거나 업데이트하지 않습니다.
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
|
||||
|
||||
## 사례
|
||||
|
||||
@@ -209,7 +216,7 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
|
||||
<details>
|
||||
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary>
|
||||
|
||||
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
|
||||
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. 첫 CLI/MCP 작업이나 무인 작업을 실행하기 전에 같은 사용자 세션에서 이 대화 상자를 완료하세요. 필요하면 Hopper를 직접 여세요. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -239,7 +246,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를
|
||||
|
||||
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
|
||||
|
||||
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
|
||||
|
||||
```bash
|
||||
rea update
|
||||
@@ -286,11 +293,9 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를
|
||||
|
||||
책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요.
|
||||
|
||||
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
|
||||
불법적이거나 허가되지 않은 사용에 대한 프로젝트의 입장은 모든 REA 버전, 배포 채널, 플랫폼에 적용되며, 각 사본에는 해당 라이선스가 그대로 유지됩니다.
|
||||
|
||||
The project's position on illegal or unauthorized use covers all REA
|
||||
versions, distribution channels, and platforms; each copy retains its
|
||||
applicable licenses.
|
||||
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
|
||||
|
||||
## 기여
|
||||
|
||||
@@ -302,14 +307,6 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
|
||||
|
||||
[MIT](../../LICENSE)
|
||||
|
||||
REA-authored code is licensed under MIT. Third-party material retains its own
|
||||
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
|
||||
for the dependency inventory, preserved license texts, and external-tool terms.
|
||||
REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요.
|
||||
|
||||
**You must legally obtain and be authorized to use commercial tools**, including
|
||||
IDA Pro / Hex-Rays decompilers, Hopper, and JEB, under their applicable licenses.
|
||||
REA grants no tool license, key, or activation entitlement. Trials and demos
|
||||
remain subject to vendor terms, including user, feature, automation, and output
|
||||
sharing restrictions. Cracked copies, stolen keys, unauthorized key sharing,
|
||||
and bypassing license restrictions are not authorized. This guidance does not
|
||||
amend MIT or third-party licenses.
|
||||
IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다.
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@
|
||||
"test": "npm run test:with-build",
|
||||
"test:with-build": "npm run test:prepare && vitest run",
|
||||
"test:run": "vitest run",
|
||||
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py bridge/mitmproxy/test_capture.py -v && python3 -m unittest discover -s experiments/analysis-efficiency -p 'test_*.py' -v",
|
||||
"test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v && python3 -m unittest discover -s experiments/analysis-efficiency -p 'test_*.py' -v",
|
||||
"test:local": "node scripts/run-development-tests.mjs local",
|
||||
"test:fast": "node scripts/ci/test-group.mjs source",
|
||||
"test:built": "node scripts/ci/test-group.mjs built",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export function assertCleanLinuxHopperRuntime(
|
||||
launcherPaths?: readonly string[],
|
||||
procRoot?: string,
|
||||
listProcesses?: () => Promise<{ stdout: string }>,
|
||||
): Promise<void>;
|
||||
|
||||
@@ -17,9 +17,8 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
: [process.env.HOPPER_LAUNCHER_PATH]),
|
||||
],
|
||||
procRoot = "/proc",
|
||||
listProcesses = () => execFileAsync("ps", ["-ax", "-o", "command="]),
|
||||
) => {
|
||||
const { stdout } = await listProcesses();
|
||||
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||
const launchers = [];
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -89,11 +89,17 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
),
|
||||
artifact(
|
||||
"decode_interface_builder",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.",
|
||||
z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
max_objects: z.number().int().min(1).max(20_000).default(20_000),
|
||||
max_connections: z.number().int().min(1).max(40_000).default(40_000),
|
||||
detail: analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.describe(
|
||||
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
artifact(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
@@ -404,7 +405,12 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
]),
|
||||
),
|
||||
extract_artifact: evidenceResultOf(artifactExtractionResultSchema),
|
||||
decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema),
|
||||
decode_interface_builder: evidenceResultOf(
|
||||
z.union([
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema),
|
||||
inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema),
|
||||
trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema),
|
||||
|
||||
@@ -62,6 +62,13 @@ import {
|
||||
machoViewRequestSchema,
|
||||
projectMachoView,
|
||||
} from "./machoView.js";
|
||||
import {
|
||||
interfaceBuilderSummarySchema,
|
||||
interfaceBuilderViewContextSchema,
|
||||
interfaceBuilderViewItemsSchema,
|
||||
interfaceBuilderViewRequestSchema,
|
||||
projectInterfaceBuilderView,
|
||||
} from "./interfaceBuilderView.js";
|
||||
import {
|
||||
projectWebNetworkCaptureView,
|
||||
captureViewRequestSchema,
|
||||
@@ -206,6 +213,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
inventoryViewSchema,
|
||||
wasmViewRequestSchema,
|
||||
machoViewRequestSchema,
|
||||
interfaceBuilderViewRequestSchema,
|
||||
evmViewRequestSchema,
|
||||
managedViewRequestSchema,
|
||||
androidViewRequestSchema,
|
||||
@@ -326,6 +334,7 @@ const viewResultBase = {
|
||||
"inventory_artifact",
|
||||
"inspect_wasm_artifact",
|
||||
"inspect_macho",
|
||||
"decode_interface_builder",
|
||||
"inspect_evm_interface",
|
||||
"inspect_managed_artifact",
|
||||
"inspect_web_network_capture",
|
||||
@@ -382,6 +391,13 @@ export const machoSummaryViewResultSchema = z.strictObject({
|
||||
summary: machoSummarySchema,
|
||||
});
|
||||
|
||||
/** Decoded documents and graph counts for Interface Builder's selected delivery. */
|
||||
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("decode_interface_builder"),
|
||||
summary: interfaceBuilderSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact inferred-interface summary for EVM's caller-selected delivery. */
|
||||
export const evmInterfaceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
@@ -444,6 +460,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
inventorySummarySchema,
|
||||
wasmSummarySchema,
|
||||
machoSummarySchema,
|
||||
interfaceBuilderSummarySchema,
|
||||
evmSummarySchema,
|
||||
managedSummarySchema,
|
||||
captureSummarySchema,
|
||||
@@ -514,6 +531,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
...machoViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_macho"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("interface-builder"),
|
||||
view: interfaceBuilderViewRequestSchema,
|
||||
items: interfaceBuilderViewItemsSchema,
|
||||
...viewResultBase,
|
||||
...interfaceBuilderViewContextSchema.shape,
|
||||
parent_operation: z.literal("decode_interface_builder"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("evm"),
|
||||
view: evmViewRequestSchema,
|
||||
@@ -611,6 +636,10 @@ export type UnsignedAnalysisView =
|
||||
>
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
|
||||
| Omit<
|
||||
Extract<AnalysisViewResult, { readonly kind: "interface-builder" }>,
|
||||
"view_digest"
|
||||
>
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
|
||||
@@ -674,7 +703,7 @@ const unsupportedParent = (
|
||||
new AnalysisUnsupportedTargetError(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parentArtifactPath(parent),
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
@@ -702,6 +731,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_wasm_artifact", projectWasmArtifactView],
|
||||
["inspect_macho", projectMachoView],
|
||||
["decode_interface_builder", projectInterfaceBuilderView],
|
||||
["inspect_evm_interface", projectEvmInterfaceView],
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
|
||||
/** Parse a parent's result with its owning schema; issues point into the view source. */
|
||||
export const parseAnalysisViewResult = <Schema extends z.ZodType>(
|
||||
schema: Schema,
|
||||
parent: AnalysisViewParent,
|
||||
): Result<z.output<Schema>, AnalysisError> => {
|
||||
const parsed = schema.safeParse(parent.normalizedResult);
|
||||
if (parsed.success) return ok(parsed.data);
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { androidResultSchemas } from "../android/androidAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -53,19 +53,8 @@ export const projectAndroidPackageView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = packageSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(packageSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
|
||||
// authenticated APK subject cannot be repaired using the engine identity.
|
||||
if (parent.subject == null)
|
||||
@@ -79,7 +68,7 @@ export const projectAndroidPackageView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const report = parsed.value;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_android_package" as const,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -59,20 +59,9 @@ export const projectDartAotView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = aotSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(aotSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { package_uris, dart_uris, class_like_tokens, ...pool } =
|
||||
report.string_pool;
|
||||
const string_pool = {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { evmInterfaceSchema } from "../evm/evmInterface.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -60,20 +60,9 @@ export const projectEvmInterfaceView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
parent.subject == null ||
|
||||
parent.subject.format !== "file" ||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const evidence = (artifact_path: string) => [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
description: "compiled document",
|
||||
location: { address: null, file_offset: null },
|
||||
artifact_path,
|
||||
artifact_sha256: "b".repeat(64),
|
||||
},
|
||||
];
|
||||
const node = (id: string, kind: string, document: string) => ({
|
||||
id,
|
||||
kind,
|
||||
name: id,
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence: evidence(document),
|
||||
});
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const second = "Contents/Resources/Second.nib";
|
||||
const document = (relative_path: string) => ({
|
||||
relative_path,
|
||||
archive_sha256: "b".repeat(64),
|
||||
document_kind: "nib" as const,
|
||||
object_count: 2,
|
||||
connection_count: 1,
|
||||
hierarchy_complete: true,
|
||||
});
|
||||
const report = (truncated = false) => ({
|
||||
target_sha256: target,
|
||||
documents: [document(main), document(second)],
|
||||
graph: {
|
||||
target_sha256: target,
|
||||
provider: { id: "rea-ib", version: "1", tool_version: "1" },
|
||||
nodes: [
|
||||
node("main:root", "scene", main),
|
||||
node("main:button", "control", main),
|
||||
node("second:root", "scene", second),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "main:edge",
|
||||
from: "main:root",
|
||||
to: "main:button",
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
evidence: evidence(main),
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
coverage: [
|
||||
{
|
||||
facet: `objects:${main}`,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
examined: 2,
|
||||
omitted: 0,
|
||||
},
|
||||
],
|
||||
truncated,
|
||||
},
|
||||
limitations: ["Recognized archive fields only."],
|
||||
});
|
||||
const parent = (
|
||||
normalizedResult: unknown = report(),
|
||||
sha256 = target,
|
||||
): AnalysisViewParent => ({
|
||||
evidenceId: `ev_${"c".repeat(64)}`,
|
||||
operation: "decode_interface_builder",
|
||||
normalizedResult,
|
||||
subject: {
|
||||
name: "Fixture",
|
||||
digest: { sha256 },
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture",
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
describe("Interface Builder analysis views", () => {
|
||||
it("summarizes documents and graph counts without rows", () => {
|
||||
const projected = projectAnalysisView(parent(), { kind: "summary" });
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: "decode_interface_builder",
|
||||
artifact: { sha256: target },
|
||||
summary: {
|
||||
documents: report().documents,
|
||||
available_counts: { nodes: 3, edges: 1, coverage: 1 },
|
||||
node_kinds: { control: 1, scene: 2 },
|
||||
edge_relations: { contains: 1 },
|
||||
coverage_statuses: { complete: 1 },
|
||||
truncated: false,
|
||||
},
|
||||
coverage: { examined: 7, total: 7 },
|
||||
});
|
||||
expect(JSON.stringify(projected.value)).not.toContain("main:button");
|
||||
});
|
||||
|
||||
it("pages one document's nodes and keeps decoder truncation visible", () => {
|
||||
const projected = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: main,
|
||||
offset: 1,
|
||||
limit: 5,
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "interface-builder",
|
||||
items: [node("main:button", "control", main)],
|
||||
source_truncated: false,
|
||||
coverage: { status: "page", examined: 1, total: 2, exhausted: true },
|
||||
});
|
||||
const truncated = projectAnalysisView(parent(report(true)), {
|
||||
kind: "interface-builder",
|
||||
collection: "coverage",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!truncated.ok) throw truncated.error;
|
||||
expect(truncated.value).toMatchObject({
|
||||
items: report().graph.coverage,
|
||||
source_truncated: true,
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("The decoded graph is incomplete"),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses unknown documents, other views and mismatched targets", () => {
|
||||
const unknown = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "edges",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (unknown.ok) throw new Error("expected an unknown-document refusal");
|
||||
expect(unknown.error).toMatchObject({
|
||||
issues: [{ path: ["view", "document"] }],
|
||||
});
|
||||
const wrongKind = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "imports",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
|
||||
expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] });
|
||||
const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), {
|
||||
kind: "summary",
|
||||
});
|
||||
if (mismatched.ok) throw new Error("expected a subject refusal");
|
||||
expect(mismatched.error).toMatchObject({
|
||||
issues: [{ path: ["source", "subject"] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import {
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderDocumentSchema,
|
||||
} from "../apple/interfaceBuilderGraph.js";
|
||||
import {
|
||||
nativeInvestigationCoverageSchema,
|
||||
nativeInvestigationEdgeSchema,
|
||||
nativeInvestigationNodeSchema,
|
||||
} from "../native/nativeInvestigationGraph.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
type AnalysisViewRequest,
|
||||
type UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const page = {
|
||||
kind: z.literal("interface-builder"),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
} as const;
|
||||
|
||||
/** Original decoded rows; graph rows can be narrowed to one decoded document. */
|
||||
export const interfaceBuilderViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["nodes", "edges"]),
|
||||
document: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact documents[].relative_path; selects rows whose evidence names that archive.",
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["documents", "coverage"]),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const interfaceBuilderViewItemsSchema = z.union([
|
||||
z.array(interfaceBuilderDocumentSchema),
|
||||
z.array(nativeInvestigationNodeSchema),
|
||||
z.array(nativeInvestigationEdgeSchema),
|
||||
z.array(nativeInvestigationCoverageSchema),
|
||||
]);
|
||||
|
||||
/** The decoder's own truncation, independent of page exhaustion. */
|
||||
export const interfaceBuilderViewContextSchema = z.strictObject({
|
||||
source_truncated: z.boolean(),
|
||||
});
|
||||
|
||||
const countsSchema = z.record(z.string(), z.number().int().positive());
|
||||
|
||||
/** Every decoded document with graph counts; rows and evidence stay retained. */
|
||||
export const interfaceBuilderSummarySchema = z.strictObject({
|
||||
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
|
||||
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
|
||||
documents: interfaceBuilderAnalysisSchema.shape.documents,
|
||||
available_counts: z.strictObject({
|
||||
nodes: z.number().int().nonnegative(),
|
||||
edges: z.number().int().nonnegative(),
|
||||
coverage: z.number().int().nonnegative(),
|
||||
}),
|
||||
node_kinds: countsSchema,
|
||||
edge_relations: countsSchema,
|
||||
coverage_statuses: countsSchema,
|
||||
truncated: z.boolean(),
|
||||
limitation_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const tally = (values: readonly string[]): Record<string, number> => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return Object.fromEntries(
|
||||
[...counts].sort(([left], [right]) => (left < right ? -1 : 1)),
|
||||
);
|
||||
};
|
||||
|
||||
type InterfaceBuilderReport = z.output<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
/** Admit a well-formed report bound to its recorded target, if one exists. */
|
||||
const parseInterfaceBuilderReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<InterfaceBuilderReport, AnalysisError> => {
|
||||
const parsed = parseAnalysisViewResult(
|
||||
interfaceBuilderAnalysisSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
report.graph.target_sha256 !== report.target_sha256 ||
|
||||
(parent.subject != null &&
|
||||
parent.subject.digest.sha256 !== report.target_sha256)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(report);
|
||||
};
|
||||
|
||||
/** Every decoded document with graph tallies; rows stay in the retained parent. */
|
||||
const summarizeInterfaceBuilder = (
|
||||
report: InterfaceBuilderReport,
|
||||
limitationCount: number,
|
||||
) => {
|
||||
const { graph } = report;
|
||||
const total =
|
||||
report.documents.length +
|
||||
graph.nodes.length +
|
||||
graph.edges.length +
|
||||
graph.coverage.length;
|
||||
return {
|
||||
summary: {
|
||||
target_sha256: report.target_sha256,
|
||||
provider: graph.provider,
|
||||
documents: report.documents,
|
||||
available_counts: {
|
||||
nodes: graph.nodes.length,
|
||||
edges: graph.edges.length,
|
||||
coverage: graph.coverage.length,
|
||||
},
|
||||
node_kinds: tally(graph.nodes.map((node) => node.kind)),
|
||||
edge_relations: tally(graph.edges.map((edge) => edge.relation)),
|
||||
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
|
||||
truncated: graph.truncated,
|
||||
limitation_count: limitationCount,
|
||||
},
|
||||
coverage: completeWithinViewCoverage(total, total),
|
||||
};
|
||||
};
|
||||
|
||||
/** Project retained decode_interface_builder Evidence without reopening archives. */
|
||||
export const projectInterfaceBuilderView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "interface-builder")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder decoding supports summary and interface-builder views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseInterfaceBuilderReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { graph } = report;
|
||||
const limitations = uniqueSorted([
|
||||
...parent.limitations,
|
||||
...report.limitations,
|
||||
]);
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "decode_interface_builder" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: {
|
||||
path: parent.subject?.local_path ?? "",
|
||||
sha256: report.target_sha256,
|
||||
},
|
||||
limitations,
|
||||
unknowns: [
|
||||
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
|
||||
...(graph.truncated
|
||||
? [
|
||||
"The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
...summarizeInterfaceBuilder(report, limitations.length),
|
||||
});
|
||||
const document =
|
||||
view.collection === "nodes" || view.collection === "edges"
|
||||
? view.document
|
||||
: undefined;
|
||||
if (
|
||||
document !== undefined &&
|
||||
!report.documents.some((row) => row.relative_path === document)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "document"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Select an exact documents[].relative_path from this decoded report.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inDocument = <
|
||||
Row extends { readonly evidence: readonly { artifact_path: unknown }[] },
|
||||
>(
|
||||
candidates: readonly Row[],
|
||||
): readonly Row[] =>
|
||||
document === undefined
|
||||
? candidates
|
||||
: candidates.filter((row) =>
|
||||
row.evidence.some((evidence) => evidence.artifact_path === document),
|
||||
);
|
||||
const rows =
|
||||
view.collection === "documents"
|
||||
? report.documents
|
||||
: view.collection === "coverage"
|
||||
? graph.coverage
|
||||
: view.collection === "nodes"
|
||||
? inDocument(graph.nodes)
|
||||
: inDocument(graph.edges);
|
||||
const items = rows.slice(view.offset, view.offset + view.limit);
|
||||
return ok({
|
||||
kind: "interface-builder",
|
||||
view,
|
||||
items,
|
||||
...shared,
|
||||
source_truncated: graph.truncated,
|
||||
coverage: pageViewCoverage(view.offset, items.length, rows.length),
|
||||
});
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { isMachOFormat } from "../binaryTargetTypes.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -93,19 +93,8 @@ type MachoReport = z.output<typeof inspectMachoSchema>;
|
||||
const parseMachoReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<MachoReport, AnalysisError> => {
|
||||
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(inspectMachoSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parent.subject != null && !isMachOFormat(parent.subject.format))
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
@@ -117,7 +106,7 @@ const parseMachoReport = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
return ok(parsed.value);
|
||||
};
|
||||
|
||||
/** Counts replace rows; every collection caveat stays in the limitations. */
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
managedArtifactInspectionSchema,
|
||||
managedMemberInspectionSchema,
|
||||
} from "../managed/managedArtifact.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -67,22 +67,12 @@ export const projectManagedArtifactView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = managedArtifactInspectionSchema.safeParse(
|
||||
parent.normalizedResult,
|
||||
const parsed = parseAnalysisViewResult(
|
||||
managedArtifactInspectionSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const identity_scope = {
|
||||
token_identity: "build-local" as const,
|
||||
requires_artifact_sha256: report.artifact.sha256,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
wasmArtifactMatchesSubject,
|
||||
} from "../wasm/wasmArtifact.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
@@ -81,20 +81,9 @@ export const projectWasmArtifactView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
!wasmArtifactMatchesSubject(
|
||||
report.artifact,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
webNetworkCaptureRecordSchema,
|
||||
webNetworkCaptureBinaryDescriptorSchema,
|
||||
@@ -118,20 +118,9 @@ const preview = (record: WebNetworkCaptureRecord) => {
|
||||
const parseCaptureParent = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<WebNetworkCapture, AnalysisError> => {
|
||||
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(webNetworkCaptureSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
report.artifact.sha256 !== null &&
|
||||
(parent.subject == null ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { relationshipCoverageLimitations } from "./relationshipCoverage.js";
|
||||
import {
|
||||
callPathInputSchema,
|
||||
callPathResultSchema,
|
||||
@@ -240,15 +241,12 @@ const inspectSearch = ({
|
||||
const coverage = snapshot.evidence.relationship_coverage;
|
||||
for (const limitation of snapshot.limitations)
|
||||
limitations.push(limitation);
|
||||
if (coverage?.status === "partial")
|
||||
for (const limitation of coverage.limitations)
|
||||
if (coverage !== null) {
|
||||
for (const limitation of relationshipCoverageLimitations(coverage))
|
||||
limitations.push(limitation);
|
||||
else if (coverage?.status === "unknown")
|
||||
limitations.push(coverage.reason);
|
||||
for (const diagnostic of coverage !== null && "diagnostics" in coverage
|
||||
? coverage.diagnostics
|
||||
: [])
|
||||
for (const diagnostic of coverage.diagnostics ?? [])
|
||||
limitations.push(diagnostic);
|
||||
}
|
||||
// The requested goal is a terminal for this search. Its outgoing edges
|
||||
// cannot add a path to the goal, so its dossier limits are contextual
|
||||
// but do not make the searched start-to-goal closure incomplete.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { RelationshipCoverage } from "./relationshipCoverage.js";
|
||||
import {
|
||||
relationshipCoverageLimitations,
|
||||
type RelationshipCoverage,
|
||||
} from "./relationshipCoverage.js";
|
||||
import type { FunctionDossier } from "./hopperValues.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
|
||||
@@ -15,11 +18,7 @@ export const combineFunctionRelationshipCoverage = (
|
||||
{ direction: "callees" as const, coverage: callees },
|
||||
];
|
||||
const diagnostics = uniqueSorted(
|
||||
reads.flatMap(({ coverage }) =>
|
||||
coverage !== undefined && "diagnostics" in coverage
|
||||
? coverage.diagnostics
|
||||
: [],
|
||||
),
|
||||
reads.flatMap(({ coverage }) => coverage?.diagnostics ?? []),
|
||||
);
|
||||
const details = diagnostics.length > 0 ? { diagnostics } : {};
|
||||
const callersLimited = callers?.status !== "complete";
|
||||
@@ -35,15 +34,9 @@ export const combineFunctionRelationshipCoverage = (
|
||||
const reasons = reads.flatMap(({ direction, coverage }) => {
|
||||
const label =
|
||||
direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix;
|
||||
const limitations =
|
||||
coverage === undefined
|
||||
? ["Provider did not report relationship coverage."]
|
||||
: coverage.status === "unknown"
|
||||
? [coverage.reason]
|
||||
: coverage.status === "partial"
|
||||
? coverage.limitations
|
||||
: [];
|
||||
return limitations.map((reason) => `${label}${reason}`);
|
||||
return relationshipCoverageLimitations(coverage).map(
|
||||
(reason) => `${label}${reason}`,
|
||||
);
|
||||
});
|
||||
if (
|
||||
reads.some(
|
||||
@@ -90,24 +83,11 @@ export const functionCallCoverageLimitations = (
|
||||
coverage: RelationshipCoverage | null,
|
||||
dossier: Pick<FunctionDossier, "unresolved_calls">,
|
||||
): string[] => {
|
||||
const limitations =
|
||||
coverage === null
|
||||
? ["Provider did not report relationship coverage."]
|
||||
: coverage.status === "unknown"
|
||||
? [
|
||||
coverage.reason,
|
||||
...("diagnostics" in coverage ? coverage.diagnostics : []),
|
||||
]
|
||||
: coverage.status === "partial"
|
||||
? [
|
||||
...coverage.limitations,
|
||||
...("diagnostics" in coverage ? coverage.diagnostics : []),
|
||||
]
|
||||
: [];
|
||||
limitations.push(
|
||||
return uniqueSorted([
|
||||
...relationshipCoverageLimitations(coverage),
|
||||
...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
|
||||
...(dossier.unresolved_calls ?? []).map(
|
||||
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
|
||||
),
|
||||
);
|
||||
return uniqueSorted(limitations);
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseRecordedTransportUrl } from "./recordedTransportUrl.js";
|
||||
|
||||
it("recognizes an empty userinfo marker at the authority start", () => {
|
||||
expect(parseRecordedTransportUrl("https://@host")).toMatchObject({
|
||||
origin: "https://host",
|
||||
path: "/",
|
||||
credentials: { start: 8, end: 9 },
|
||||
});
|
||||
});
|
||||
@@ -33,3 +33,19 @@ export const relationshipCoverageSchema = z.discriminatedUnion("status", [
|
||||
]);
|
||||
|
||||
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>;
|
||||
|
||||
/** Reasons the observed relationship inventory cannot establish completeness. */
|
||||
export const relationshipCoverageLimitations = (
|
||||
coverage: RelationshipCoverage | null | undefined,
|
||||
): readonly string[] => {
|
||||
switch (coverage?.status) {
|
||||
case undefined:
|
||||
return ["Provider did not report relationship coverage."];
|
||||
case "complete":
|
||||
return [];
|
||||
case "partial":
|
||||
return coverage.limitations;
|
||||
case "unknown":
|
||||
return [coverage.reason];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "41",
|
||||
skillVersion: "42",
|
||||
} as const;
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import {
|
||||
ARTIFACT_TOOL_CONTRACTS,
|
||||
artifactInspectionInputSchema,
|
||||
} from "../contracts/artifactToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Operations whose MCP delivery may be a retained summary view. */
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
|
||||
"inspect_artifact",
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
|
||||
const decodeWithoutDelivery = withoutDeliveryDetail(
|
||||
new Set(["decode_interface_builder"]),
|
||||
);
|
||||
|
||||
/** Register deterministic artifact inventory and safe extraction operations. */
|
||||
export const registerArtifactTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: (operation, parameters) => {
|
||||
if (operation === "inspect_artifact") {
|
||||
const { detail: _detail, ...execution } =
|
||||
@@ -42,16 +39,9 @@ export const registerArtifactTools = (
|
||||
}
|
||||
return operation === "extract_artifact"
|
||||
? { ...parameters, output_root: createArtifactExtractionDestination() }
|
||||
: parameters;
|
||||
: decodeWithoutDelivery(operation, parameters);
|
||||
},
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
parametersForEvidence: decodeWithoutDelivery,
|
||||
sourceEvidence: (operation, result) =>
|
||||
operation === "inspect_artifact"
|
||||
? artifactInspectionResultSchema
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
@@ -8,24 +6,17 @@ import {
|
||||
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
|
||||
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
|
||||
/** Register execution-free managed PE/CLI inspection. */
|
||||
export const registerManagedTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
let selectedManagedPath: string | undefined;
|
||||
const analysisForAdmission = (
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */
|
||||
const withoutDelivery = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
if (operation !== "inspect_macho") return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
|
||||
|
||||
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
|
||||
export const registerNativeTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: withoutDelivery,
|
||||
parametersForEvidence: withoutDelivery,
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
|
||||
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
|
||||
/** Registration inputs shared by provider-backed Evidence tool families. */
|
||||
export interface EvidenceToolFamilyOptions {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
|
||||
}
|
||||
|
||||
type Parameters = Readonly<Record<string, JsonValue>>;
|
||||
|
||||
/**
|
||||
* Summary delivery for the named operations: refuse it before analysis when
|
||||
* this server retains no Evidence, then return the retained summary view.
|
||||
*/
|
||||
export const retainedSummaryDelivery = (
|
||||
operations: ReadonlySet<string>,
|
||||
options: Pick<EvidenceToolFamilyOptions, "recordEvidence" | "evidenceById">,
|
||||
) => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const selected = (operation: string, parameters: Parameters): boolean =>
|
||||
operations.has(operation) && parameters.detail === "summary";
|
||||
return {
|
||||
preflight: (operation: string, parameters: Parameters) => {
|
||||
if (!operations.has(operation)) return ok(null);
|
||||
const detail = analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.safeParse(parameters.detail);
|
||||
if (!detail.success)
|
||||
return err(
|
||||
new AnalysisInputError(operation, { cause: detail.error }, [
|
||||
{
|
||||
path: ["detail"],
|
||||
reason: "invalid_value",
|
||||
expected: ["complete", "summary"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
return detail.data === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null);
|
||||
},
|
||||
projectEvidence: (
|
||||
operation: string,
|
||||
parameters: Parameters,
|
||||
evidence: Evidence,
|
||||
) =>
|
||||
selected(operation, parameters)
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
};
|
||||
};
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */
|
||||
export const withoutDeliveryDetail =
|
||||
(operations: ReadonlySet<string>) =>
|
||||
(operation: string, parameters: Parameters): Parameters => {
|
||||
if (!operations.has(operation)) return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
@@ -279,12 +279,20 @@ it("preserves bounded instruction coverage separately from using-node coverage",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed method descriptors at the Android adapter boundary", async () => {
|
||||
it.each([
|
||||
"not-a-dex-descriptor",
|
||||
"(Lbad(name;)V",
|
||||
"(Lbad)name;)V",
|
||||
"()Lbad(name;",
|
||||
"()Lbad)name;",
|
||||
])(
|
||||
"rejects malformed DEX prototype %s at the adapter boundary",
|
||||
async (descriptor) => {
|
||||
const invalidSummary = {
|
||||
...summary,
|
||||
methods: summary.methods.map((method) => ({
|
||||
...method,
|
||||
dex_descriptor: "not-a-dex-descriptor",
|
||||
dex_descriptor: descriptor,
|
||||
})),
|
||||
};
|
||||
await expect(
|
||||
@@ -300,5 +308,15 @@ it("rejects malformed method descriptors at the Android adapter boundary", async
|
||||
{} as never,
|
||||
engine,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
cause: {
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["methods", 0, "dex_descriptor"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
evidence_id: z.string(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
normalized_result: schema,
|
||||
});
|
||||
const summarySchema = z.object({
|
||||
kind: z.literal("summary"),
|
||||
parent_evidence_id: z.string(),
|
||||
summary: z.object({
|
||||
documents: z.array(z.unknown()),
|
||||
available_counts: z.object({
|
||||
nodes: z.number(),
|
||||
edges: z.number(),
|
||||
coverage: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const pageSchema = z.object({
|
||||
kind: z.literal("interface-builder"),
|
||||
items: z.array(z.unknown()),
|
||||
coverage: z.object({ next_offset: z.number().nullable() }),
|
||||
});
|
||||
|
||||
/** A two-document app; each nib is a real rooted UIKit outlet archive. */
|
||||
const fixtureApp = async (): Promise<string> => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-ib-summary-mcp-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
const contents = join(app, "Contents");
|
||||
const resources = join(contents, "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await mkdir(join(contents, "MacOS"));
|
||||
await writeFile(
|
||||
join(contents, "MacOS", "Fixture"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
);
|
||||
const nib = await readFile(
|
||||
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
|
||||
);
|
||||
await writeFile(join(resources, "Main.nib"), nib);
|
||||
await writeFile(join(resources, "Second.nib"), nib);
|
||||
return app;
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"retains complete Interface Builder Evidence and pages one document's rows",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const app = await fixtureApp();
|
||||
const client = await openProductionTarget(mcp, onTestFinished, app);
|
||||
|
||||
const invalidDetail = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: { detail: "brief" },
|
||||
});
|
||||
expect(invalidDetail.isError).toBe(true);
|
||||
expect(invalidDetail.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/Input validation error:.*detail/su),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Summary first: its own retained parent must serve every later page.
|
||||
const summarized = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: { detail: "summary" },
|
||||
});
|
||||
expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const summary = evidenceOf(summarySchema).parse(
|
||||
summarized.structuredContent,
|
||||
).normalized_result;
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
const readAll = async (view: Record<string, unknown>) => {
|
||||
const rows: unknown[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const paged = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: { kind: "interface-builder", ...view, offset, limit: 2 },
|
||||
},
|
||||
});
|
||||
expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true);
|
||||
const page = evidenceOf(pageSchema).parse(
|
||||
paged.structuredContent,
|
||||
).normalized_result;
|
||||
rows.push(...page.items);
|
||||
offset = page.coverage.next_offset;
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const edges = await readAll({ collection: "edges" });
|
||||
const mainNodes = await readAll({ collection: "nodes", document: main });
|
||||
|
||||
// Only then compare with a complete call made for reference.
|
||||
const complete = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: {},
|
||||
});
|
||||
expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true);
|
||||
const full = evidenceOf(interfaceBuilderAnalysisSchema).parse(
|
||||
complete.structuredContent,
|
||||
);
|
||||
expect(full.parameters).not.toHaveProperty("detail");
|
||||
const report = full.normalized_result;
|
||||
expect(report.documents).toHaveLength(2);
|
||||
// Delivery detail never reaches the decoder or the source Evidence ID.
|
||||
expect(summary.parent_evidence_id).toBe(full.evidence_id);
|
||||
expect(summary.summary.documents).toEqual(report.documents);
|
||||
expect(summary.summary.available_counts).toEqual({
|
||||
nodes: report.graph.nodes.length,
|
||||
edges: report.graph.edges.length,
|
||||
coverage: report.graph.coverage.length,
|
||||
});
|
||||
expect(edges).toEqual(report.graph.edges);
|
||||
const expectedMainNodes = report.graph.nodes.filter(({ evidence }) =>
|
||||
evidence.some(({ artifact_path }) => artifact_path === main),
|
||||
);
|
||||
expect(expectedMainNodes.length).toBeGreaterThan(0);
|
||||
expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length);
|
||||
expect(mainNodes).toEqual(expectedMainNodes);
|
||||
|
||||
const unknownDocument = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(unknownDocument)).toMatchObject({
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,40 +1,24 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
archiveNumberMetadataCases,
|
||||
expectKeyedArchiveNumberMetadata,
|
||||
keyedArchiveNumberMetadataFixture,
|
||||
} from "../../fixtures/keyedArchiveNumberMetadata.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest.for(archiveNumberMetadataCases)(
|
||||
"MCP preserves numeric meaning for $name",
|
||||
async (item, { mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
expectKeyedArchivePrototypeKeys,
|
||||
keyedArchivePrototypeKeyFixture,
|
||||
} from "../../fixtures/keyedArchivePrototypeKeys.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest(
|
||||
"MCP retains ordinary evidence with formatted XML prototype keys",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchivePrototypeKeyFixture();
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const demangledSchema = z.object({
|
||||
normalized_result: z.object({
|
||||
@@ -30,24 +27,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
"tool",
|
||||
);
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const called = await client.callTool({
|
||||
name: "demangle_swift",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
@@ -16,6 +13,7 @@ import { ok } from "../../../src/domain/result.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
@@ -54,24 +52,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
"ls",
|
||||
);
|
||||
await copyFile("/bin/ls", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const complete = await client.callTool({
|
||||
name: "inspect_macho",
|
||||
|
||||
@@ -34,10 +34,6 @@ it
|
||||
const procRoot = join(directory, "proc");
|
||||
await mkdir(procRoot);
|
||||
const alternateMode = mode === "alternate launcher";
|
||||
let orphanPid: number | undefined;
|
||||
const isolatedProcessList = async () => ({
|
||||
stdout: alternateMode || orphanPid === undefined ? "" : "Xvfb",
|
||||
});
|
||||
if (alternateMode) {
|
||||
await copyFile("/usr/bin/sleep", launcher);
|
||||
await chmod(launcher, 0o700);
|
||||
@@ -51,7 +47,6 @@ it
|
||||
await assertCleanLinuxHopperRuntime(
|
||||
alternateMode ? [launcher] : [],
|
||||
procRoot,
|
||||
isolatedProcessList,
|
||||
);
|
||||
const command = alternateMode ? alternate : process.execPath;
|
||||
const arguments_ = alternateMode ? ["30"] : ["-e", childCode];
|
||||
@@ -74,6 +69,7 @@ it
|
||||
const parentPid = parent.child.pid;
|
||||
if (parentPid === undefined)
|
||||
throw new Error("Fixture parent did not spawn");
|
||||
let orphanPid: number | undefined;
|
||||
try {
|
||||
const { stdout } = await parent;
|
||||
const { pid } = JSON.parse(stdout) as { pid: number };
|
||||
@@ -119,7 +115,6 @@ it
|
||||
assertCleanLinuxHopperRuntime(
|
||||
alternateMode ? [launcher] : [],
|
||||
procRoot,
|
||||
isolatedProcessList,
|
||||
),
|
||||
).rejects.toThrow("Clean Linux runner retained a Hopper demo process");
|
||||
// Observation must not terminate the fixture, even though it resembles Xvfb.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { expect, type OnTestFinishedHandler } from "vitest";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import type { TestMcp } from "./mcpFixture.js";
|
||||
|
||||
/** Open one real target in a production session served over MCP. */
|
||||
export const openProductionTarget = async (
|
||||
mcp: TestMcp,
|
||||
onTestFinished: (handler: OnTestFinishedHandler) => void,
|
||||
path: string,
|
||||
): Promise<Client> => {
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
return client;
|
||||
};
|
||||
Reference in New Issue
Block a user