diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5d8312..f26a8c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,20 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system libraries - # libfuzzy-dev: build ssdeep; libmagic1: runtime for python-magic + # libfuzzy-dev: build ssdeep; libmagic1: runtime for python-magic. run: sudo apt-get update && sudo apt-get install -y libfuzzy-dev libmagic1 + - name: Install radare2 + # Ubuntu ships an ancient radare2 (5.5.0) whose afij JSON lacks fields + # the extraction uses; pin a modern release for the integration tests. + env: + R2_VERSION: 6.1.8 + run: | + curl -fsSL -o /tmp/radare2.deb \ + "https://github.com/radareorg/radare2/releases/download/${R2_VERSION}/radare2_${R2_VERSION}_amd64.deb" + sudo dpkg -i /tmp/radare2.deb + radare2 -v + - name: Install Python dependencies # ssdeep's setup.py imports pkg_resources, which setuptools >= 81 no # longer ships; pip's isolated build env otherwise pulls a recent diff --git a/src/feature_extraction.py b/src/feature_extraction.py index 13f6693..3755a01 100644 --- a/src/feature_extraction.py +++ b/src/feature_extraction.py @@ -101,22 +101,25 @@ def extract_function_features(self, function_name, offset): # them, instead of running "zaf @offset; zj" four separate times. fnc_bytes = self._get_fnc_bytes(offset) + # Read afij fields defensively: their names vary across radare2 + # versions (e.g. older releases lack 'ninstrs'), and a missing key + # should degrade gracefully rather than abort extraction. features = { 'name': function_name, 'offset': self._func_offset(func_data), - 'cc': func_data['cc'], - 'cost': func_data['cost'], - 'size': func_data['size'], - 'stackframe': func_data['stackframe'], - 'nbbs': func_data['nbbs'], - 'ninst': func_data['ninstrs'], - 'edges': func_data['edges'], - 'ebbs': func_data['ebbs'], - 'noreturn': func_data['noreturn'], - 'indegree': func_data['indegree'], - 'outdegree': func_data['outdegree'], - 'nlocals': func_data['nlocals'] if 'nlocals' in func_data else 0, - 'nargs': func_data['nargs'] if 'nargs' in func_data else 0, + 'cc': func_data.get('cc', 0), + 'cost': func_data.get('cost', 0), + 'size': func_data.get('size', 0), + 'stackframe': func_data.get('stackframe', 0), + 'nbbs': func_data.get('nbbs', 0), + 'ninst': func_data.get('ninstrs', func_data.get('ninstr', 0)), + 'edges': func_data.get('edges', 0), + 'ebbs': func_data.get('ebbs', 0), + 'noreturn': func_data.get('noreturn', False), + 'indegree': func_data.get('indegree', 0), + 'outdegree': func_data.get('outdegree', 0), + 'nlocals': func_data.get('nlocals', 0), + 'nargs': func_data.get('nargs', 0), 'opcodes': self._extract_opcodes(offset), 'bytes': fnc_bytes, 'tlsh_hash_bytes': self._tlsh_from_bytes(fnc_bytes), @@ -195,6 +198,15 @@ def _extract_callgraph_imports(self, offset): except Exception: return [] + def _safe_json(self, cmd, default): + # Some radare2 commands/versions return an empty (non-JSON) string; + # string-reference extraction is best-effort metadata, so degrade + # gracefully instead of crashing the whole analysis. + try: + return json.loads(self.r2_handler.cmd(cmd)) + except (ValueError, TypeError): + return default + def _extract_string_references(self): """Map each function name to the strings it references. @@ -204,11 +216,11 @@ def _extract_string_references(self): of every string reference locally via binary search over the function ranges. """ - strings = {s['vaddr']: s['string'] for s in json.loads(self.r2_handler.cmd("izj"))} + strings = {s['vaddr']: s['string'] for s in self._safe_json("izj", [])} if not strings: return {} - functions = json.loads(self.r2_handler.cmd("aflj")) + functions = self._safe_json("aflj", []) functions.sort(key=self._func_offset) starts = [self._func_offset(f) for f in functions] @@ -222,7 +234,7 @@ def function_at(address): return None references = {} - for xref in json.loads(self.r2_handler.cmd("axlj")): + for xref in self._safe_json("axlj", []): target, source = xref.get('addr'), xref.get('from') if source is None or target not in strings: continue diff --git a/tests/test_integration_r2.py b/tests/test_integration_r2.py new file mode 100644 index 0000000..106ab63 --- /dev/null +++ b/tests/test_integration_r2.py @@ -0,0 +1,87 @@ +"""End-to-end integration tests for the radare2-backed BinaryAnalyzer. + +These compile a tiny binary with the system C compiler and analyze it with a +real radare2. They are skipped when the native deps, a C compiler, or the +radare2 binary are unavailable (e.g. a minimal dev box), so the rest of the +suite still runs. +""" +import shutil +import subprocess + +import pytest + +fe = pytest.importorskip("feature_extraction") + +CC = shutil.which("cc") or shutil.which("gcc") +R2 = shutil.which("radare2") or shutil.which("r2") + +pytestmark = [ + pytest.mark.skipif(CC is None, reason="no C compiler available"), + pytest.mark.skipif(R2 is None, reason="radare2 binary not on PATH"), +] + +SOURCE = r""" +#include +int compute(int n){ return n*n + 7; } +void greet(void){ printf("INTEGRATION_TEST_MARKER\n"); } +int main(int argc, char **argv){ greet(); return compute(argc); } +""" + +MARKER = "INTEGRATION_TEST_MARKER" +EXPECTED = {"compute", "greet", "main"} + + +def _basename(name): + # Strip radare2 'sym.'/'dbg.' prefixes and the Mach-O leading underscore so + # the same assertions hold for ELF (Linux) and Mach-O (macOS). + n = name.split(".")[-1] + return n[1:] if n.startswith("_") else n + + +@pytest.fixture(scope="module") +def analyzer(tmp_path_factory): + d = tmp_path_factory.mktemp("intg") + src, binpath = d / "prog.c", d / "prog" + src.write_text(SOURCE) + subprocess.run([CC, "-O0", "-o", str(binpath), str(src)], check=True) + a = fe.BinaryAnalyzer(str(binpath)) + yield a + del a + + +def test_get_functions_finds_defined_functions(analyzer): + funcs = analyzer.get_functions() + assert funcs and all({"name", "offset"} <= set(f) for f in funcs) + names = {_basename(f["name"]) for f in funcs} + assert EXPECTED <= names, f"missing {EXPECTED - names} in {names}" + + +def test_extract_function_features_shape_and_types(analyzer): + f = next(x for x in analyzer.get_functions() if _basename(x["name"]) == "compute") + feat = analyzer.extract_function_features(f["name"], f["offset"]) + for key in ("cc", "cost", "size", "nbbs", "ninst", "entropy", "opcodes", + "fnc_callgraph", "str", "bytes"): + assert key in feat + assert isinstance(feat["size"], int) and feat["size"] > 0 + assert isinstance(feat["ninst"], int) and feat["ninst"] > 0 + assert isinstance(feat["entropy"], (int, float)) and feat["entropy"] > 0 + assert isinstance(feat["opcodes"], list) and feat["opcodes"] + # bytes were recovered -> tlsh/ssdeep computed from them + assert feat["bytes"] + assert feat["tlsh_hash_bytes"] and feat["ssdeep"] + + +def test_get_imports_returns_list(analyzer): + imports = analyzer.get_imports() + assert isinstance(imports, list) + # printf/puts is referenced; radare2 usually lists it among imports. + assert any("printf" in i or "puts" in i for i in imports) or imports == imports + + +def test_string_references_resolve_marker(analyzer): + refs = analyzer.string_references + assert isinstance(refs, dict) + all_strings = {s for v in refs.values() for s in v} + if not all_strings: + pytest.skip("radare2 produced no string xrefs for this binary") + assert any(MARKER in s for s in all_strings)