├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── ariadne ├── __init__.py ├── analysis │ ├── analysis_functions.py │ └── ariadne_function.py ├── core.py ├── func_widget.py ├── graph.py ├── plugin.py ├── py2cytoscape.py ├── server.py ├── target.py └── util_funcs.py ├── download_bncov.py ├── headless_analysis.py ├── plugin.json ├── requirements.txt ├── screenshots ├── ariadne_screenshot.png ├── breadcrumbing.png ├── coverage_analysis.png ├── demo.gif ├── focus_node.png ├── source_sink.png └── tutorial_main.png ├── tutorial ├── README.md ├── corpus-cov │ ├── id_000001.cov │ ├── id_000006.cov │ ├── id_000015.cov │ ├── id_000016.cov │ ├── id_000018.cov │ ├── id_000033.cov │ ├── id_000059.cov │ ├── id_000091.cov │ ├── id_000095.cov │ ├── id_000140.cov │ ├── id_000166.cov │ ├── id_000186.cov │ ├── id_000233.cov │ ├── id_000307.cov │ ├── id_000309.cov │ ├── id_000312.cov │ ├── id_000360.cov │ ├── id_000366.cov │ └── id_000371.cov ├── corpus │ ├── id_000001 │ ├── id_000006 │ ├── id_000015 │ ├── id_000016 │ ├── id_000018 │ ├── id_000033 │ ├── id_000059 │ ├── id_000091 │ ├── id_000095 │ ├── id_000140 │ ├── id_000166 │ ├── id_000186 │ ├── id_000233 │ ├── id_000307 │ ├── id_000309 │ ├── id_000312 │ ├── id_000360 │ ├── id_000366 │ └── id_000371 ├── templeton.bin └── token └── web ├── css ├── main.css ├── normalize.css └── skeleton.css ├── dist ├── cytoscape-dagre.min.js ├── cytoscape-klay.js ├── cytoscape.min.js ├── dagre.min.js └── klay.js ├── index.html └── main.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | __pycache__ 4 | TODO.md 5 | venv 6 | js 7 | cache 8 | 9 | web/main-*.js 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Mark Griffin (@seeinglogic) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ariadne: Binary Ninja Graph Analysis Plugin 2 | 3 | [Ariadne](https://en.wikipedia.org/wiki/Ariadne#Mythology) is a Binary Ninja plugin that serves a browser-based interactive graph 4 | visualization for assisting reverse engineers. It implements some common static 5 | analysis tasks including call graph analysis, and can integrate block coverage 6 | information. This enables users to build interactive graphs and see exactly what 7 | they are interested in. 8 | 9 | ![Demo Screen Capture](screenshots/demo.gif) 10 | 11 | ## Quickstart 12 | 13 | 1. Install this plugin via the Plugin Manager, OR clone this repo to your Binary 14 | Ninja [plugins folder](https://docs.binary.ninja/getting-started.html#user-folder) 15 | - NOTE: If you install by cloning the repo, you may need to install the Python 16 | dependencies in `requirements.txt`. Use the command palette (`CTRL+P` or 17 | equivalent) to do `Install Python3 module` and copy the contents of 18 | requirements.txt into the dialog. 19 | 2. Open a binary in Binary Ninja 20 | 3. Right click: Plugins > Ariadne > Analyze Target 21 | 4. Open the Ariadne Sidebar (`A` icon in upper right by default) and wait for analysis to complete (watch log for issues) 22 | 5. Open a browser and surf to `http://localhost:8800` to view the interactive 23 | graph (web UI) 24 | 6. Navigate around in Binary Ninja; the graph will update when the current 25 | function changes 26 | 27 | ## Graph Styling 28 | 29 | The quick rundown on what the shapes and colors on the graph mean: 30 | 31 | - Regular functions are green circles 32 | - Import functions are diamond-shaped and colored orange 33 | - Node size is based on cyclomatic complexity; more complex functions are 34 | bigger circles 35 | - The current function active in BN is colored red 36 | - Nodes with double borders mean they have edges that weren't included for the 37 | current graph (default: local neighborhood for active function in BN, see note 38 | below) 39 | - Functions that you've looked at in the BN UI have light blue borders 40 | - If you click on a node, it becomes the "focus node" 41 | - The focus node is colored purple 42 | - Out edges/nodes (callees) are colored pink 43 | - In edges/nodes (calleRs) are colored blue 44 | - Clicking on the focus node deselects it 45 | - Clicking on another node makes that node the focus node 46 | 47 | NOTE: the default graph is a 2-hop neighborhood of the current function _BUT_ it 48 | will be automatically pruned to a smaller graph if two hops would include too 49 | many nodes. Use the context menu function graph to push the full context for 50 | the current function or use 51 | [networkx](https://networkx.org/) to build custom graphs and push them to 52 | the web UI. 53 | 54 | ## Motivation 55 | 56 | [Longer blog post on motivation](https://seeinglogic.com/posts/why-of-ariadne/) 57 | 58 | This tool is a proof-of-concept that was built to fill a gap that we observed in 59 | our own reverse-engineering workflows, with the goals of being highly 60 | configurable and to help make reverse-engineering faster. 61 | 62 | The key insight we found building/using a graph tool is that looking at too many 63 | nodes is unhelpful and layout matters a lot, so we focused on just the analysis 64 | results we wanted in the smallest and cleanest view possible. 65 | 66 | From there, we built the backend so any graph could be pushed to the backend and 67 | common graph tasks would be easy. Adding extra analysis tasks is also easy since 68 | there are places for per-function and target-wide analysis. 69 | 70 | ## Common Workflows 71 | 72 | Ariadne was built to handle some common workflows encountered in RE and fuzzing: 73 | 74 | - Source/Sink analysis: Context command allows you to select a function and see 75 | all the paths to/from the current function in the web UI. ![source-sink](screenshots/source_sink.png) 76 | - Coverage analysis via [bncov](https://github.com/ForAllSecure/bncov): allows 77 | visualization of coverage and shows where your coverage stops and uncovered 78 | complexity resides. Requires bncov, but if coverage information is detected 79 | before analysis starts it will automatically be added, or it can be added 80 | separately. [More in-depth post on coverage automation and Ariadne](https://seeinglogic.com/posts/automated-coverage-analysis/) 81 | - The Descendent Complexity metrics ("uncovered" as well as the normal one) show 82 | the sum of complexity for all functions reachable from a given function as 83 | well as the sum for just functions with zero coverage. Very useful for 84 | fuzzing! 85 | ![Coverage View](screenshots/coverage_analysis.png) 86 | - Import Hiding: Sometimes imports are helpful, other times they just convolute 87 | the graph because it's more important to see just the internal functions 88 | - Custom graphs: create any graph based on the target's graph (`ariadne.core.targets[bv].g`) and push it to the web UI with `ariadne.core.push_new_graph(new_graph)` 89 | - Standard styling: the default graph styling allows you to see which functions 90 | you have already looked at, which functions are imports, and caller/callee 91 | relationships. Helps you see which functions you haven't looked at that may be 92 | of interest. ![Breadcrumb demo](screenshots/breadcrumbing.png) 93 | - Collapsible Function Metadata sidebar: Shows all the relevant static analysis 94 | results for any function you click on. ![Focus node view](screenshots/focus_node.png) 95 | - Function search bar: start typing the name of the function you want to find in 96 | the search bar in the upper left, when the name turns green you can stop 97 | typing and hit enter to center the graph on the target function. 98 | - Freezing/unfreezing the graph: sometimes you don't want auto-updates 99 | - Save/Load analysis: redoing analysis is no good; headless analysis and 100 | save/load features allow you to crunch binaries on a separate machine if you 101 | want. 102 | - Callgraph exploration: using the web UI's `Graph Focus Function` button, now 103 | you can see what nodes aren't fully expanded in the current view and navigate 104 | between functions from within the web UI. 105 | 106 | See the [tutorial](./tutorial/README.md) for detailed explanation of features 107 | and intended workflows that you can test out on an example binary. 108 | 109 | ## Troubleshooting 110 | 111 | If the web UI is unresponsive, check the websocket status in the upper right 112 | corner. If you push a really large graph to the web UI, the page may freeze 113 | while the graph layout is computed. In any case, refreshing the page should 114 | reset the UI. 115 | 116 | Unhandled Python exceptions on startup or during processing are bugs and it'd be 117 | great if you would open a GitHub issue on the repo here and describe the problem 118 | (and include a binary to reproduce the problem, if possible). 119 | 120 | ...And of course, PR's are always welcome! 121 | 122 | ## Thank you! 123 | 124 | To everyone who tries out this tool, it would mean a lot to me if you reach out 125 | and give me your thoughts on [Twitter](https://twitter.com/seeinglogic) or starring this repo. I 126 | hope this helps you or gives you ideas on how to look at things a little 127 | differently. 128 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .ariadne.plugin import * -------------------------------------------------------------------------------- /ariadne/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/ariadne/__init__.py -------------------------------------------------------------------------------- /ariadne/analysis/analysis_functions.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Callable, Any, Set, Union 2 | import re 3 | 4 | from binaryninja import Function, LowLevelILOperation, LowLevelILInstruction, SymbolType 5 | 6 | from ..util_funcs import func_name 7 | 8 | # Analysis functions defined at the bottom of this file 9 | # That's the only place a function needs to be added 10 | def get_analysis_functions() -> Dict[str, Callable]: 11 | return analysis_functions 12 | 13 | 14 | def get_cyclomatic_complexity(bv, function) -> Dict[str, Any]: 15 | num_blocks = len(function.basic_blocks) 16 | num_edges = sum(len(bb.outgoing_edges) for bb in function.basic_blocks) 17 | return { 18 | "blocks": num_blocks, 19 | "edges": num_edges, 20 | "complexity": num_edges - num_blocks + 2 21 | } 22 | 23 | def get_basic_attributes(bv, function) -> Dict[str, Any]: 24 | f = function 25 | results: Dict[str, Union[str, int]] = {} 26 | results['instructions'] = len(list(f.instructions)) 27 | results['bytes'] = f.total_bytes 28 | results['num_args'] = len(f.parameter_vars) 29 | results['args'] = ", ".join(f'{v.type} {v.name}' for v in f.parameter_vars) 30 | results['return_type'] = str(f.return_type) 31 | results['function_type'] = str(f.symbol.type.name.replace('Symbol', '')) 32 | results['is_import'] = 1 if f.symbol.type == SymbolType.ImportedFunctionSymbol else 0 33 | return results 34 | 35 | def get_variable_refs(bv, function) -> Dict[str, Any]: 36 | global_vars = set() 37 | string_refs = set() 38 | 39 | def get_pointers(llil_inst): 40 | for i, operand in enumerate(llil_inst.operands): 41 | if isinstance(operand, LowLevelILInstruction): 42 | if operand.operation == LowLevelILOperation.LLIL_CONST_PTR: 43 | address = operand.value.value 44 | var = bv.get_data_var_at(address) 45 | if var: 46 | yield var 47 | else: 48 | get_pointers(operand) 49 | 50 | if function.llil is not None: 51 | for llil_inst in function.llil.instructions: 52 | for var in get_pointers(llil_inst): 53 | var_sym = bv.get_symbol_at(var.address) 54 | if var_sym: 55 | var_name = var_sym.name 56 | else: 57 | var_name = f'data_{var.address:x}' 58 | if re.search(r'char.*[\[0-9a-fx]\]', str(var.type)): 59 | str_pointed_at = bv.get_ascii_string_at(var.address, min_length=1) 60 | if str_pointed_at is not None: 61 | real_str = f'"{str_pointed_at.value}"' 62 | else: 63 | real_str = '' 64 | string_refs.add(f'{var_name}: {real_str}') 65 | else: 66 | global_vars.add(var_name) 67 | 68 | return { 69 | 'string_refs': sorted(string_refs), 70 | 'global_refs': sorted(global_vars) 71 | } 72 | 73 | def get_neighbors(bv, function) -> Dict[str, Any]: 74 | # Save to class member for graph 75 | # FUTURE: test bv.get_code_refs against of callers? 76 | # Same in AriadneFunction constructor 77 | callers: Set[str] = set(function.callers) 78 | callees: Set[str] = set() 79 | imports: Set[str] = set() 80 | for callee in function.callees: 81 | sym_type = callee.symbol.type 82 | if sym_type in [SymbolType.ImportedFunctionSymbol, SymbolType.ImportAddressSymbol]: 83 | imports.add(func_name(callee)) 84 | else: 85 | callees.add(func_name(callee)) 86 | return { 87 | 'callers': sorted(set(func_name(f) for f in callers)), 88 | 'local_callees': sorted(callees), 89 | 'imported_callees': sorted(imports), 90 | } 91 | 92 | def get_stack_layout(bv, function) -> Dict[str, Any]: 93 | stack_vars: List[str] = [] 94 | frame_size = 0 95 | prev_offset = None 96 | cur_offset = 0 97 | for cur_var in function.stack_layout: 98 | cur_offset = cur_var.storage 99 | # only want stack vars, presuming negative offsets 100 | if cur_offset < 0: 101 | # convert to positive offsets for convenience 102 | cur_offset *= -1 103 | if cur_offset > frame_size: 104 | frame_size = cur_offset 105 | if prev_offset is not None: 106 | prev_size = prev_offset - cur_offset 107 | if not prev_name.startswith('__'): 108 | stack_vars.append(f'{prev_name} (0x{prev_size:x})') 109 | prev_offset = cur_offset 110 | prev_name = cur_var.name 111 | # not worrying about functions whose last variable isn't a saved reg 112 | return { 113 | 'stack_frame_size': hex(frame_size), 114 | 'stack_vars': stack_vars, 115 | } 116 | 117 | 118 | analysis_functions: Dict[str, Callable]= { 119 | "get_cyclomatic_complexity": get_cyclomatic_complexity, 120 | "get_basic_attributes": get_basic_attributes, 121 | "get_neighbors": get_neighbors, 122 | "get_variable_refs": get_variable_refs, 123 | "get_stack_layout": get_stack_layout, 124 | } -------------------------------------------------------------------------------- /ariadne/analysis/ariadne_function.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from inspect import trace 3 | from typing import Dict, Any, Set, List 4 | 5 | import sys 6 | import time 7 | import json 8 | import traceback 9 | from pprint import pformat 10 | 11 | from binaryninja import Function, BinaryView, log_info, log_error 12 | 13 | from .analysis_functions import get_analysis_functions 14 | from ..util_funcs import log_info, log_error, func_name 15 | 16 | class AriadneFunction(): 17 | def __init__(self, function: Function): 18 | self.address: int = function.start 19 | self.name: str = func_name(function) 20 | self.function: Function = function 21 | self.bv = function.view 22 | # FUTURE: test bv.get_code_refs against of callers? 23 | # Same in analysis functions' get_neighbors 24 | self.callers: Set[Function] = set(self.function.callers) 25 | self.metadata: Dict[str, Any] = {} 26 | self.analysis_functions_run: List[str] = [] 27 | 28 | def serialize(self) -> str: 29 | # Keep analysis_functions_run on the target level 30 | # Otherwise save everything not linked to the BN objects 31 | serialized_data = { 32 | 'address': self.address, 33 | 'name': self.name, 34 | 'metadata': self.metadata, 35 | } 36 | return json.dumps(serialized_data) 37 | 38 | @staticmethod 39 | def deserialize( 40 | bv: BinaryView, 41 | serialized_dict: str, 42 | analysis_function_names: List[str] 43 | ) -> AriadneFunction: 44 | 45 | saved_dict = json.loads(serialized_dict) 46 | 47 | func_start = saved_dict['address'] 48 | saved_name = saved_dict['name'] 49 | 50 | bn_func = bv.get_function_at(func_start) 51 | if bn_func is None: 52 | log_error(f'Failed to load func {saved_name} @ {func_start}?!') 53 | 54 | cur_name = bn_func.symbol.short_name 55 | if cur_name != saved_name: 56 | log_error(f'Cur_name ({cur_name}) does NOT match saved ({saved_name})') 57 | 58 | new_ariadne_function = AriadneFunction(bn_func) 59 | new_ariadne_function.metadata = saved_dict['metadata'] 60 | new_ariadne_function.analysis_functions_run = analysis_function_names 61 | 62 | return new_ariadne_function 63 | 64 | def is_import(self) -> bool: 65 | if 'is_import' not in self.metadata: 66 | raise Exception(f'[!] is_import metadata not present for {self.name} ({hex(self.address)}') 67 | if self.metadata['is_import'] is None: 68 | raise Exception(f'[!] is_import for {self.name} ({hex(self.address)} is None ?!') 69 | 70 | return bool(self.metadata['is_import']) 71 | 72 | def get_metadata(self) -> str: 73 | metadata_str = f'{self.name} @ 0x{self.address:x}\n' 74 | # Show particular values in order 75 | ordered_keys = [ 76 | 'function_type', 77 | 'is_import', 78 | 'callers', 79 | 'local_callees', 80 | 'imported_callees', 81 | 'num_descendants', 82 | 'args', 83 | 'num_args', 84 | 'return_type', 85 | 'global_refs', 86 | 'stack_frame_size', 87 | 'stack_vars', 88 | 'string_refs', 89 | 'complexity', 90 | 'blocks', 91 | 'edges', 92 | 'bytes', 93 | 'instructions', 94 | ] 95 | keys_included = [] 96 | for name in ordered_keys: 97 | if name in self.metadata: 98 | value = self.metadata[name] 99 | metadata_str += f'{name}: {pformat(value)}\n' 100 | keys_included.append(name) 101 | # Include any remaining 102 | for name, value in self.metadata.items(): 103 | if name not in keys_included: 104 | metadata_str += f'{name}: {pformat(value)}\n' 105 | return metadata_str 106 | 107 | def collect_function_data(self): 108 | errored_funcs = [] 109 | for function_name, analysis_function in get_analysis_functions().items(): 110 | try: 111 | results = analysis_function(self.bv, self.function) 112 | self.metadata.update(results) 113 | self.analysis_functions_run.append(function_name) 114 | except: 115 | if function_name not in errored_funcs: 116 | log_error( 117 | f'Ariadne analysis function "{function_name}" threw an exception ' + 118 | f'on target function "{self.name}" @ {hex(self.address)}:' 119 | ) 120 | exception_str = traceback.format_exc() 121 | for line in exception_str.split('\n'): 122 | if line.strip(): 123 | log_error(" " + line) 124 | errored_funcs.append(function_name) 125 | 126 | def init_visited(self): 127 | if 'visited' not in self.metadata: 128 | self.metadata['visited'] = 0 129 | 130 | def set_visited(self): 131 | self.metadata['visited'] = 1 132 | 133 | def get_visited(self) -> int: 134 | return self.metadata.get('visited', 0) 135 | 136 | 137 | def get_analyzed_function(f: Function) -> AriadneFunction: 138 | """Get a populated AriadneFunction from a BN function""" 139 | ariadne_function = AriadneFunction(f) 140 | ariadne_function.collect_function_data() 141 | ariadne_function.init_visited() 142 | return ariadne_function 143 | 144 | 145 | if __name__ == '__main__': 146 | 147 | from binaryninja import * 148 | import pprint 149 | import binaryninja 150 | bv = binaryninja.BinaryViewType.get_view_of_file(sys.argv[1]) 151 | start = time.time() 152 | for f in bv.functions: 153 | a = AriadneFunction(f) 154 | a.collect_function_data() 155 | #metadata_str = pprint.pformat(a.metadata) 156 | #print(a.name, hex(a.address), metadata_str) 157 | duration = time.time() - start 158 | print(f'Finished function analysis in {duration:.2f} seconds') 159 | -------------------------------------------------------------------------------- /ariadne/core.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple, Optional 2 | from multiprocessing.pool import ThreadPool 3 | from pathlib import Path 4 | import socket 5 | import time 6 | import networkx as nx 7 | 8 | from binaryninja import BinaryView, Function, BackgroundTaskThread, get_choice_input 9 | 10 | from .analysis.ariadne_function import AriadneFunction, get_analyzed_function 11 | from .util_funcs import short_name, log_info, log_warn, log_error, log_debug, func_name, get_repo_dir 12 | from .target import AriadneTarget 13 | from .server import AriadneServer 14 | 15 | try: 16 | import bncov 17 | coverage_enabled = True 18 | except ImportError: 19 | try: 20 | import ForAllSecure_bncov as bncov 21 | coverage_enabled = True 22 | except ImportError: 23 | coverage_enabled = False 24 | 25 | 26 | class BackgroundAnalysis(BackgroundTaskThread): 27 | """Worker thread that actually does the analysis via AriadneTarget""" 28 | def __init__(self, bv, core, num_workers=4): 29 | super().__init__(initial_progress_text='Running function analysis', can_cancel=True) 30 | self.bv = bv 31 | self.core = core 32 | self.num_workers = num_workers 33 | 34 | def run(self): 35 | analysis_start_time = time.time() 36 | target = AriadneTarget(self.bv, self.core) 37 | 38 | # Multithreaded version of target.do_analysis 39 | function_list = list(self.bv.functions) 40 | target.function_list = function_list 41 | num_funcs = len(function_list) 42 | 43 | # Function level analysis 44 | self.progress = f'ARIADNE: Analyzing functions... {0}/{num_funcs}' 45 | start_time = time.time() 46 | with ThreadPool(self.num_workers) as pool: 47 | for i, ariadne_function in enumerate(pool.imap(get_analyzed_function, function_list, 8)): 48 | self.progress = f'ARIADNE: Analyzing functions... {i+1}/{num_funcs}' 49 | target.function_dict[ariadne_function.function] = ariadne_function 50 | if self.cancelled: 51 | break 52 | duration = time.time() - start_time 53 | log_info(f'Function analysis ({self.num_workers} threads) took {duration:.2f} seconds') 54 | 55 | # Target-level analysis 56 | if not self.cancelled: 57 | self.progress = f'ARIADNE: Generating Callgraph...' 58 | start_time = time.time() 59 | target.generate_callgraph() 60 | duration = time.time() - start_time 61 | log_info(f'Generating callgraph took {duration:.2f} seconds') 62 | if not self.cancelled: 63 | self.progress = f'ARIADNE: Graph analysis...' 64 | start_time = time.time() 65 | target.do_graph_analysis() 66 | duration = time.time() - start_time 67 | log_info(f'Graph analysis took {duration:.2f} seconds') 68 | if not self.cancelled and coverage_enabled: 69 | covdb = bncov.get_covdb(self.bv) 70 | num_files = len(covdb.coverage_files) 71 | if num_files: 72 | self.progress = f'ARIADNE: Coverage analysis...' 73 | start_time = time.time() 74 | target.init_coverage_analysis(covdb) 75 | 76 | # Wrapper function to do a parallel mapping over function list 77 | def coverage_analysis_wrapper(bn_func): 78 | target.do_function_coverage_analysis(bn_func) 79 | 80 | with ThreadPool(self.num_workers) as pool: 81 | for i, _ in enumerate(pool.imap(coverage_analysis_wrapper, function_list, 8)): 82 | self.progress = f'ARIADNE: Function coverage analysis... {i+1}/{num_funcs}' 83 | if self.cancelled: 84 | break 85 | 86 | target.mark_coverage_analysis_finished() 87 | duration = time.time() - start_time 88 | log_info(f'Coverage analysis took {duration:.2f} seconds') 89 | 90 | # If we make it here without canceling, we're done 91 | if not self.cancelled: 92 | self.progress = f'ARIADNE: Finishing analysis...' 93 | 94 | # Avoid race: add result to core, then pop history and add to it 95 | self.core.add_analysis_result(self.bv, target) 96 | visited_funcs = set(self.core.pop_history_cache(self.bv)) 97 | # Currently only setting "is_visited bit", only need to visit each once 98 | target.mark_visited_set(visited_funcs) 99 | 100 | duration = time.time() - analysis_start_time 101 | log_info(f'Analysis for "{short_name(self.bv)}" complete in {duration:.2f} seconds') 102 | else: 103 | duration = time.time() - start_time 104 | log_info(f'Analysis for "{short_name(self.bv)}" cancelled after {duration:.2f} seconds') 105 | 106 | 107 | class AriadneCore(): 108 | def __init__(self, ip='127.0.0.1', http_port=8800, websocket_port=7890): 109 | self.ip = ip 110 | self.http_port = self.find_next_open_port(http_port) 111 | self.websocket_port = self.find_next_open_port(websocket_port) 112 | self.bvs = [] 113 | self.analysis_tasks: Dict[BinaryView, BackgroundAnalysis] = {} 114 | self.current_function_map: Dict[BinaryView, Function] = {} 115 | self.targets: Dict[BinaryView, AriadneTarget] = {} 116 | self.history_cache: Dict[BinaryView, List[Function]] = {} 117 | self.server = AriadneServer(self, ip, self.http_port, self.websocket_port) 118 | self.graph_frozen = False 119 | self.cache_dir = get_repo_dir().joinpath('cache') 120 | self.current_bv: Optional[BinaryView] = None 121 | self.force_load_from_cache = False 122 | self.force_cache_overwrite = False 123 | # FUTURE: expose these as plugin settings 124 | self.neighborhood_hops = 3 125 | self.max_nodes_to_show = 50 126 | log_info(f'Instantiated AriadneCore') 127 | if not coverage_enabled: 128 | log_info(f'Download the bncov plugin in order to enable coverage analysis') 129 | 130 | def find_next_open_port(self, port: int) -> int: 131 | """Find the next best port for the server. 132 | 133 | NOTE: There will only be one server per process, but could be more than 134 | one window, each with its own separate Python interpreter 135 | """ 136 | MAX_PORT = 65535 137 | orig_port = port 138 | 139 | while port <= MAX_PORT: 140 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 141 | if s.connect_ex(('localhost', port)) != 0: 142 | break 143 | port += 1 144 | 145 | if port > MAX_PORT: 146 | raise Exception(f'No open ports between {orig_port} and {MAX_PORT}') 147 | 148 | return port 149 | 150 | def start_server(self): 151 | self.server.start_webserver() 152 | self.server.start_websocket_server() 153 | 154 | def get_cache_target(self, bv: BinaryView): 155 | return self.cache_dir / f'{short_name(bv)}.ariadne' 156 | 157 | def save_analysis_to_file(self, bv: BinaryView): 158 | if bv not in self.bvs: 159 | log_error(f'No analysis for {short_name(bv)}') 160 | return 161 | if bv not in self.targets: 162 | log_error(f'Analysis not finished for {short_name(bv)}') 163 | return 164 | 165 | target_json = self.targets[bv].serialize() 166 | 167 | if not self.cache_dir.exists(): 168 | self.cache_dir.mkdir() 169 | 170 | cache_file = self.get_cache_target(bv) 171 | if cache_file.exists() and self.force_cache_overwrite is False: 172 | user_choice = get_choice_input( 173 | f'Existing analysis file for {short_name(bv)} found, overwrite it?', 174 | 'Overwrite Saved Analysis?', 175 | ['No', 'Yes'] 176 | ) 177 | if isinstance(user_choice, int) and user_choice == 0: 178 | return 179 | 180 | with open(cache_file, 'w') as f: 181 | f.write(target_json) 182 | 183 | if not cache_file.exists(): 184 | log_error(f'Failed to write analysis to {cache_file}') 185 | else: 186 | filesize = cache_file.stat().st_size 187 | log_info(f'Wrote analysis to {cache_file} ({filesize} bytes)') 188 | 189 | def load_analysis_from_file(self, bv: BinaryView) -> bool: 190 | cached_analysis_file = self.get_cache_target(bv) 191 | if not cached_analysis_file.exists(): 192 | log_error(f'Expected cache analysis path not found ({cached_analysis_file})') 193 | return False 194 | 195 | load_start = time.time() 196 | log_info(f'loading analysis from "{cached_analysis_file}"...') 197 | with open(cached_analysis_file) as f: 198 | analysis_json = f.read() 199 | 200 | new_target = AriadneTarget.deserialize(bv, self, analysis_json) 201 | if new_target is None: 202 | return False 203 | 204 | if bv not in self.bvs: 205 | self.bvs.append(bv) 206 | 207 | visited_funcs = set(self.pop_history_cache(bv)) 208 | new_target.mark_visited_set(visited_funcs) 209 | 210 | self.add_analysis_result(bv, new_target) 211 | 212 | duration = time.time() - load_start 213 | log_info(f'Loaded analysis from file in {duration:.02f} seconds') 214 | 215 | return True 216 | 217 | def queue_analysis(self, bv: BinaryView): 218 | """Queue a BinaryView for analysis which Binary Ninja may still be working""" 219 | # New analysis target 220 | if bv not in self.bvs: 221 | self.bvs.append(bv) 222 | log_info(f'Queueing analysis for "{short_name(bv)}"') 223 | else: 224 | # Analysis is either queued or finish 225 | if bv in self.targets: 226 | # Analysis is finished, we must want to re-do it 227 | log_info(f'Redoing analysis for {short_name(bv)}') 228 | self.targets.pop(bv) 229 | else: 230 | # Analysis is queued, bail 231 | log_warn(f'Analysis for "{short_name(bv.file)}" queued but not finished') 232 | log_warn(f' Use "ariadne -> Cancel Analysis" to cancel') 233 | return 234 | 235 | def analysis_callback(): 236 | self.launch_analysis(bv) 237 | 238 | bv.add_analysis_completion_event(analysis_callback) 239 | bv.update_analysis() 240 | 241 | def cancel_analysis(self, bv: BinaryView): 242 | if bv in self.bvs: 243 | self.bvs.remove(bv) 244 | else: 245 | log_info(f'No analysis for {short_name(bv)}') 246 | return 247 | 248 | if bv in self.targets: 249 | self.targets.pop(bv) 250 | # Analysis task may be finishing/removing asynchronously 251 | cur_analysis_task = self.analysis_tasks.get(bv) 252 | if cur_analysis_task: 253 | cur_analysis_task.cancel() 254 | try: 255 | self.analysis_tasks.pop(bv) 256 | except Exception as e: 257 | pass 258 | 259 | log_info(f'Analysis cancelled/removed for {short_name(bv)}') 260 | 261 | def launch_analysis(self, bv: BinaryView): 262 | """Callback to start our own analysis after BN's finishes""" 263 | 264 | cached_analysis_file = self.get_cache_target(bv) 265 | if cached_analysis_file.exists(): 266 | # set core.load_from_cache = True to avoid prompts 267 | load_from_cache = self.force_load_from_cache 268 | if not load_from_cache: 269 | user_choice = get_choice_input( 270 | f'Cached analysis for {short_name(bv)} found, load from file?', 271 | 'Load Saved Analysis?', 272 | ['Yes', 'No'] 273 | ) 274 | if isinstance(user_choice, int) and user_choice == 0: 275 | load_from_cache = True 276 | 277 | if load_from_cache: 278 | if self.load_analysis_from_file(bv): 279 | return 280 | else: 281 | log_info('Failed to load cached analysis; continuing with analysis from scratch.') 282 | 283 | log_info(f'Starting analysis for "{short_name(bv)}"...') 284 | cur_analysis_task = BackgroundAnalysis(bv, self) 285 | cur_analysis_task.start() 286 | self.analysis_tasks[bv] = cur_analysis_task 287 | 288 | def add_analysis_result(self, bv: BinaryView, analysis_result: AriadneTarget): 289 | """Add target to core after analysis finishes""" 290 | # Little bit of a race condition here, but acceptable 291 | if bv in self.analysis_tasks: 292 | self.analysis_tasks.pop(bv) 293 | 294 | self.targets[bv] = analysis_result 295 | # If no clicks, current_function_map[bv] may be unset 296 | if bv in self.current_function_map: 297 | current_function = self.current_function_map[bv] 298 | else: 299 | current_function = bv.entry_function 300 | 301 | # Set the initial graph on the current function if possible 302 | if analysis_result.set_current_function(current_function): 303 | callgraph = analysis_result.get_callgraph() 304 | num_nodes = len(callgraph.nodes()) 305 | num_edges = len(callgraph.edges()) 306 | 307 | neighborhood_graph = analysis_result.get_near_neighbors(current_function, self.neighborhood_hops, self.max_nodes_to_show) 308 | num_nodes = len(neighborhood_graph.nodes()) 309 | num_edges = len(neighborhood_graph.edges()) 310 | log_debug(f'Initial func ({func_name(current_function)}) neighborhood: {num_nodes} nodes, {num_edges} edges') 311 | graph_title = f'Neighborhood of "{func_name(current_function)}"' 312 | 313 | cytoscape_obj = analysis_result.get_cytoscape(neighborhood_graph) 314 | self.server.set_graph_data(bv, cytoscape_obj, graph_title) 315 | 316 | log_info('To see the interactive graph, open the following url in a browser:') 317 | log_info(f'http://{self.ip}:{self.http_port}') 318 | 319 | def graph_new_neighborhood(self, bv_name: str, start: int): 320 | """Push the neighborhood of the specified function to the graph. 321 | 322 | Primarily to allow clients to drive the web UI.""" 323 | 324 | if short_name(self.current_bv) == bv_name: 325 | bv = self.current_bv 326 | else: 327 | bv = None 328 | for iter_bv in self.targets: 329 | if short_name(iter_bv) == bv_name: 330 | bv = iter_bv 331 | break 332 | if bv is None: 333 | log_error(f'graph_new_neighborhood: Could not find bv_name "{bv_name}"') 334 | return 335 | cur_target = self.targets[bv] 336 | 337 | cur_func = bv.get_function_at(start) 338 | if cur_func is None: 339 | log_error(f'graph_new_neighborhood: Could not find function starting at "{hex(start)}"') 340 | return 341 | 342 | # Style the new function as the "current function" in the graph 343 | if cur_target.set_current_function(cur_func, do_visit=False): 344 | neighborhood_graph = cur_target.get_near_neighbors(cur_func, self.neighborhood_hops, self.max_nodes_to_show) 345 | graph_title = f'Neighborhood of "{func_name(cur_func)}"' 346 | 347 | cytoscape_obj = cur_target.get_cytoscape(neighborhood_graph) 348 | self.server.set_graph_data(bv, cytoscape_obj, graph_title) 349 | 350 | 351 | def do_coverage_analysis(self, bv: BinaryView): 352 | """Import coverage data from bncov manually""" 353 | if coverage_enabled is False: 354 | log_error(f'Cannot do coverage analysis, bncov not available; please install it.') 355 | return 356 | 357 | if bv not in self.targets: 358 | if bv not in self.bvs: 359 | log_error(f'Cannot do coverage analysis without target analysis first') 360 | else: 361 | log_error(f'Cannot do coverage analysis until target analysis finishes') 362 | return 363 | 364 | covdb = bncov.get_covdb(bv) 365 | num_files = len(covdb.coverage_files) 366 | if num_files == 0: 367 | log_error('No coverage files in bncov, cannot do coverage analysis') 368 | return 369 | 370 | target = self.targets[bv] 371 | if target.coverage_available: 372 | log_info(f'Coverage data already available for "{bv.file.filename}", re-analyzing') 373 | 374 | target.do_coverage_analysis(covdb) 375 | 376 | def get_analysis_results(self, bv: BinaryView) -> Tuple[str, Optional[AriadneTarget]]: 377 | """Check if AriadneTarget analysis is complete, returning it if finished.""" 378 | if bv not in self.bvs: 379 | return ( 380 | f'Analysis NOT QUEUED for "{short_name(bv)}",\n' + 381 | 'Use the context menu to analyze,\n' + 382 | 'then click in the active view to update', 383 | None 384 | ) 385 | elif bv not in self.targets: 386 | return ( 387 | ('Waiting for analysis to finish...\n' + 388 | 'Watch the log and click a new address once complete'), 389 | None 390 | ) 391 | else: 392 | return 'Done', self.targets[bv] 393 | 394 | def get_function_metadata(self, function: Function) -> str: 395 | """Return a string representing function metadata (or status if no metadata)""" 396 | bv = function.view 397 | status, analysis_result = self.get_analysis_results(bv) 398 | if status != 'Done' or analysis_result is None: 399 | return status 400 | if function not in analysis_result.function_dict: 401 | return f'{func_name(function)} @ {function.start} not in analysis results' 402 | function_result : AriadneFunction = analysis_result.function_dict[function] 403 | return function_result.get_metadata() 404 | 405 | def add_function_transition(self, function: Function): 406 | """Annotate that user changed the function they were viewing""" 407 | bv = function.view 408 | # Keep track of most recent bv and function the user has viewed 409 | self.current_bv = bv 410 | self.current_function_map[bv] = function 411 | # If analysis is still happening, we need to save this off 412 | if bv not in self.targets: 413 | self.history_cache.setdefault(bv, []).append(function) 414 | else: 415 | cur_target = self.targets[bv] 416 | if cur_target.set_current_function(function) and self.graph_frozen is False: 417 | neighborhood_graph = cur_target.get_near_neighbors(function, self.neighborhood_hops, self.max_nodes_to_show) 418 | num_nodes = len(neighborhood_graph.nodes()) 419 | num_edges = len(neighborhood_graph.edges()) 420 | log_info(f'Current ({func_name(function)}) func neighborhood: {num_nodes} nodes, {num_edges} edges') 421 | graph_title = f'Neighborhood of {func_name(function)}' 422 | cytoscape_obj_str = cur_target.get_cytoscape(neighborhood_graph) 423 | self.server.set_graph_data(bv, cytoscape_obj_str, graph_title) 424 | 425 | def push_new_graph(self, graph: nx.DiGraph, graph_name: Optional[str] = None): 426 | current_target = self.get_current_target() 427 | if current_target: 428 | cytoscape_obj_str = current_target.get_cytoscape(graph) 429 | if graph_name: 430 | cur_graph_name = graph_name 431 | else: 432 | cur_graph_name = f'Custom Graph ({current_target.target_name})' 433 | self.server.set_graph_data(current_target.bv, cytoscape_obj_str, cur_graph_name) 434 | 435 | def pop_history_cache(self, bv: BinaryView) -> list: 436 | if bv not in self.history_cache: 437 | return [] 438 | else: 439 | history_cache = self.history_cache.pop(bv) 440 | return history_cache 441 | 442 | def get_ariadne_function(self, function: Function) -> AriadneFunction: 443 | """Helper to go straight to AriadneFunction, raise Exception on errors""" 444 | bv = function.view 445 | if bv not in self.targets: 446 | if bv not in self.bvs: 447 | raise KeyError(f'BinaryView for {func_name(function)} never queued for analysis') 448 | else: 449 | raise KeyError(f'Analysis incomplete for BinaryView of {func_name(function)}') 450 | target = self.targets[bv] 451 | if function not in target.function_dict: 452 | raise KeyError(f'Function {func_name(function)} @ 0x{function.start} not in corresponding AriadneTarget') 453 | return target.function_dict[function] 454 | 455 | def freeze_graph(self): 456 | self.graph_frozen = True 457 | 458 | def unfreeze_graph(self): 459 | self.graph_frozen = False 460 | 461 | def get_current_target(self) -> Optional[AriadneTarget]: 462 | current_bv = self.current_bv 463 | if current_bv and current_bv in self.targets: 464 | return self.targets[self.current_bv] 465 | else: 466 | return None 467 | 468 | def get_n_hops_from(self, function: Function, dist: int) -> nx.DiGraph: 469 | bv = function.view 470 | if bv not in self.targets: 471 | if bv not in self.bvs: 472 | raise KeyError(f'BinaryView for {func_name(function)} never queued for analysis') 473 | else: 474 | raise KeyError(f'Analysis incomplete for BinaryView of {func_name(function)}') 475 | 476 | target = self.targets[bv] 477 | return target.get_n_hops_out(function, dist) -------------------------------------------------------------------------------- /ariadne/func_widget.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Optional 3 | 4 | from binaryninja import BinaryView, Function 5 | from binaryninjaui import getMonospaceFont, SidebarWidget, SidebarWidgetType, SidebarWidgetLocation, SidebarContextSensitivity 6 | 7 | import binaryninjaui 8 | if "qt_major_version" in dir(binaryninjaui) and binaryninjaui.qt_major_version == 6: 9 | from PySide6.QtCore import QRectF, Qt 10 | from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout, QFormLayout, QLabel, QFrame 11 | from PySide6.QtGui import QFontMetrics, QImage, QPainter, QFont, QColor 12 | else: 13 | from PySide2.QtCore import QRectF, Qt 14 | from PySide2.QtWidgets import QPlainTextEdit, QVBoxLayout, QFormLayout, QLabel, QWidget 15 | from PySide2.QtGui import QFontMetrics, QImage, QPainter, QFont, QColor 16 | 17 | from .core import AriadneCore 18 | from .util_funcs import log_info, func_name 19 | 20 | class AriadneFuncWidget(SidebarWidget): 21 | """ 22 | Shows dynamic graphs in a widget 23 | """ 24 | 25 | # The currently focused BinaryView. 26 | bv: Optional[BinaryView] = None 27 | 28 | def __init__(self, name: str, frame: QFrame, bv: Optional[BinaryView], core: AriadneCore): 29 | """ 30 | Initialize a new AriadneGraphWidget. 31 | 32 | :param parent: the QWidget to parent this NotepadDockWidget to 33 | :param name: the name to register the dock widget under 34 | :param bv: the currently focused BinaryView (may be None) 35 | """ 36 | 37 | self.bv = bv 38 | self.core = core 39 | self.locked = False 40 | self.current_function = None 41 | self.metadata_loaded = False 42 | 43 | SidebarWidget.__init__(self, name) 44 | 45 | header_layout = QFormLayout() 46 | self.function_info = QLabel("") 47 | header_layout.addRow(self.tr("Function:"), self.function_info) 48 | 49 | textbox_layout = QVBoxLayout() 50 | self.textbox = QPlainTextEdit() 51 | self.textbox.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) 52 | self.textbox.setReadOnly(True) 53 | # getMonospaceFont from binaryninjaui 54 | font = getMonospaceFont(self) 55 | self.textbox.setFont(font) 56 | font = QFontMetrics(font) 57 | #self.textbox.setMinimumWidth(40 * font.averageCharWidth()) 58 | #self.textbox.setMinimumHeight(25 * font.lineSpacing()) 59 | 60 | textbox_layout.addWidget(self.textbox, 0) 61 | 62 | layout = QVBoxLayout() 63 | layout.addLayout(header_layout) 64 | layout.addLayout(textbox_layout) 65 | #layout.setcontentsmargins(0, 0, 0, 0) 66 | self.setLayout(layout) 67 | 68 | def set_textbox(self, text: str): 69 | self.textbox.setPlainText(text) 70 | 71 | def add_transition(self, new_function: Function): 72 | function_metadata = self.core.get_function_metadata(new_function) 73 | # Placeholder metadata will just be a short message about metadata being 74 | # queued or not, but should only be a few lines, real metadata is more 75 | if len(function_metadata.split('\n')) > 5: 76 | self.metadata_loaded = True 77 | self.set_textbox(function_metadata) 78 | 79 | def update_current_function(self, function: Function): 80 | function_name = func_name(function) 81 | function_start = function.start 82 | self.function_info.setText(f'{function_name} @ 0x{function_start:x}') 83 | if function != self.current_function: 84 | # If the target doesn't have this function, this will return false 85 | self.core.add_function_transition(function) 86 | self.add_transition(function) 87 | # If metadata wasn't loaded and we're still on the same function, 88 | # check on each click 89 | elif self.metadata_loaded is False: 90 | self.add_transition(function) 91 | self.current_function = function 92 | 93 | def notifyOffsetChanged(self, offset: int): 94 | if self.bv is None: 95 | return 96 | if not self.locked: 97 | current_function_list = self.bv.get_functions_containing(offset) 98 | if current_function_list: 99 | current_function = current_function_list[0] 100 | self.update_current_function(current_function) 101 | if len(current_function_list) > 1: 102 | log_info(f'More than one function contains 0x{offset:x}, ' + 103 | f'picked {func_name(current_function)}') 104 | 105 | def shouldBeVisible(self, view_frame): 106 | return view_frame is not None 107 | 108 | def notifyViewChanged(self, view_frame): 109 | if view_frame is None: 110 | pass 111 | else: 112 | new_bv = view_frame.getCurrentViewInterface().getData() 113 | # No need for any special handling when the BV changes 114 | self.bv = new_bv 115 | 116 | class AriadneFuncWidgetType(SidebarWidgetType): 117 | core: AriadneCore 118 | 119 | def __init__(self, _core: AriadneCore): 120 | self.core = _core 121 | # Sidebar icons are 28x28 points. Should be at least 56x56 pixels for 122 | # HiDPI display compatibility. They will be automatically made theme 123 | # aware, so you need only provide a grayscale image, where white is 124 | # the color of the shape. 125 | icon = QImage(56, 56, QImage.Format_RGB32) 126 | icon.fill(0) 127 | 128 | # Render an "H" as the example icon 129 | p = QPainter() 130 | p.begin(icon) 131 | p.setFont(QFont("Open Sans", 56)) 132 | p.setPen(QColor(255, 255, 255, 255)) 133 | p.drawText(QRectF(0, 0, 56, 56), Qt.AlignCenter, "A") 134 | p.end() 135 | 136 | SidebarWidgetType.__init__(self, icon, "Ariadne Function Pane") 137 | 138 | def createWidget(self, frame, bv): 139 | # This callback is called when a widget needs to be created for a given context. Different 140 | # widgets are created for each unique BinaryView. They are created on demand when the sidebar 141 | # widget is visible and the BinaryView becomes active. 142 | return AriadneFuncWidget("Ariadne Function Pane", frame, bv, self.core) 143 | 144 | def defaultLocation(self): 145 | # Default location in the sidebar where this widget will appear 146 | return SidebarWidgetLocation.RightContent 147 | 148 | def contextSensitivity(self): 149 | # Context sensitivity controls which contexts have separate instances of the sidebar widget. 150 | # Using `contextSensitivity` instead of the deprecated `viewSensitive` callback allows sidebar 151 | # widget implementations to reduce resource usage. 152 | 153 | # This example widget uses a single instance and detects view changes. 154 | return SidebarContextSensitivity.SelfManagedSidebarContext -------------------------------------------------------------------------------- /ariadne/graph.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | import networkx as nx 3 | 4 | from binaryninja import show_message_box 5 | from binaryninja.binaryview import BinaryView 6 | from binaryninja.function import Function, InstructionTextToken, DisassemblyTextLine 7 | from binaryninja.flowgraph import FlowGraph, FlowGraphNode 8 | from binaryninja.enums import BranchType, InstructionTextTokenType, MessageBoxIcon, SymbolType 9 | 10 | from .core import AriadneCore 11 | from .util_funcs import func_name 12 | 13 | 14 | def get_callgraph(core: AriadneCore, bv: BinaryView) -> Optional[nx.DiGraph]: 15 | """Get the underlying nx graph from core or None""" 16 | if bv not in core.targets: 17 | return None 18 | 19 | return core.targets[bv].g 20 | 21 | 22 | def get_source_sink(g: nx.DiGraph, source: Function, sink: Function) -> Optional[nx.DiGraph]: 23 | """Get graph for source/sink paths between two functions""" 24 | source_descendants = nx.descendants(g, source) 25 | sink_ancestors = nx.ancestors(g, sink) 26 | subgraph_nodes = source_descendants.intersection(sink_ancestors) 27 | subgraph_nodes.update([source, sink]) 28 | 29 | if len(subgraph_nodes) == 0: 30 | show_message_box( 31 | 'No path from source to sink', 32 | f'No paths found from {func_name(source)} to {func_name(sink)}', 33 | icon=MessageBoxIcon.ErrorIcon, 34 | ) 35 | return None 36 | 37 | return g.subgraph(subgraph_nodes) 38 | 39 | 40 | def render_flowgraph(bv: BinaryView, g: nx.DiGraph, title: str=''): 41 | """Render arbitrary networkx graph""" 42 | flowgraph = FlowGraph() 43 | flowgraph_nodes: Dict[Function, FlowGraphNode] = {} 44 | 45 | # encapsulate check/add with a helper func for clarity 46 | def add_node(node_func: Function): 47 | if node_func not in flowgraph_nodes: 48 | new_node = FlowGraphNode(flowgraph) 49 | # h/t @joshwatson on how to distinguish imports, your implementation was better 50 | if node_func.symbol.type == SymbolType.ImportedFunctionSymbol: 51 | token_type = InstructionTextTokenType.ImportToken 52 | else: 53 | token_type = InstructionTextTokenType.CodeSymbolToken 54 | cur_func_name = func_name(node_func) 55 | func_token = InstructionTextToken(token_type, cur_func_name, node_func.start) 56 | new_node.lines = [DisassemblyTextLine([func_token])] 57 | 58 | flowgraph.append(new_node) 59 | flowgraph_nodes[node_func] = new_node 60 | return new_node 61 | return flowgraph_nodes[node_func] 62 | 63 | # one traversal that adds islands and nodes with edges 64 | for node_func in g.nodes: 65 | src_flowgraph_node = add_node(node_func) 66 | for src, dst in g.out_edges(node_func): 67 | dst_flowgraph_node = add_node(dst) 68 | src_flowgraph_node.add_outgoing_edge(BranchType.CallDestination, dst_flowgraph_node) 69 | 70 | bv.show_graph_report(title, flowgraph) 71 | -------------------------------------------------------------------------------- /ariadne/plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any, Tuple 2 | 3 | from binaryninja import ( 4 | core_ui_enabled, show_message_box, get_form_input, 5 | ChoiceField, TextLineField, MessageBoxIcon, 6 | PluginCommand, BinaryView, Function, 7 | ) 8 | 9 | from .core import AriadneCore, coverage_enabled 10 | from .graph import get_callgraph, get_source_sink 11 | from .util_funcs import short_name, log_info, log_error, graph_size, func_name, filename 12 | 13 | 14 | core = AriadneCore() 15 | 16 | 17 | if core_ui_enabled(): 18 | from .func_widget import AriadneFuncWidgetType 19 | 20 | # Start serving graph visualization over HTTP 21 | core.start_server() 22 | 23 | from PySide6.QtCore import Qt 24 | from binaryninjaui import Sidebar 25 | Sidebar.addSidebarWidgetType(AriadneFuncWidgetType(core)) 26 | 27 | 28 | def analyze_current_target(bv: BinaryView): 29 | core.queue_analysis(bv) 30 | 31 | 32 | def cancel_analysis(bv: BinaryView): 33 | core.cancel_analysis(bv) 34 | 35 | 36 | def save_target_analysis(bv: BinaryView): 37 | core.save_analysis_to_file(bv) 38 | 39 | 40 | def load_target_analysis(bv: BinaryView): 41 | if core.load_analysis_from_file(bv): 42 | log_info(f'Navigate to http://{core.ip}:{core.http_port} for interactive graph') 43 | else: 44 | show_message_box( 45 | f'Failed to load analysis from file', 46 | ( 47 | f'Saved analysis not found or failed to load for "{short_name(bv)}".' + 48 | '\n\nCheck the log window for details' 49 | ), 50 | icon=MessageBoxIcon.ErrorIcon, 51 | ) 52 | 53 | 54 | def do_coverage_analysis(bv: BinaryView): 55 | core.do_coverage_analysis(bv) 56 | 57 | 58 | def toggle_graph_freeze(bv: BinaryView): 59 | if core.graph_frozen: 60 | core.unfreeze_graph() 61 | log_info('Graph unfrozen') 62 | else: 63 | core.freeze_graph() 64 | log_info('Graph frozen') 65 | 66 | 67 | PluginCommand.register( 68 | "Ariadne\\Analyze Target", 69 | "Start analysis of current target", 70 | analyze_current_target, 71 | ) 72 | 73 | PluginCommand.register( 74 | "Ariadne\\Cancel Analysis", 75 | "Cancel/remove analysis of current target", 76 | cancel_analysis, 77 | ) 78 | 79 | PluginCommand.register( 80 | "Ariadne\\File: Save to file", 81 | "Save current target analysis to file", 82 | save_target_analysis, 83 | ) 84 | 85 | PluginCommand.register( 86 | "Ariadne\\File: Load from file", 87 | "Load target analysis from file", 88 | load_target_analysis, 89 | ) 90 | 91 | PluginCommand.register( 92 | "Ariadne\\Web UI: Freeze/Unfreeze Graph", 93 | "Toggle freezing/unfreezing interactive graph", 94 | toggle_graph_freeze, 95 | ) 96 | 97 | if coverage_enabled: 98 | PluginCommand.register( 99 | "Ariadne\\Coverage: Load bncov data", 100 | "Import coverage analysis from bncov", 101 | do_coverage_analysis, 102 | ) 103 | 104 | 105 | ## GRAPH FUNCTIONS: a little bit more complex 106 | 107 | 108 | def check_callgraph(maybe_graph: Optional[Any], bv: BinaryView) -> bool: 109 | """Show warning and return False if no callgraph, True otherwise""" 110 | if maybe_graph is None: 111 | show_message_box( 112 | f'Callgraph Not Found', 113 | f'No callgraph for {short_name(bv)}, please ensure analysis has started and completed', 114 | icon=MessageBoxIcon.ErrorIcon, 115 | ) 116 | return False 117 | else: 118 | return True 119 | 120 | 121 | def show_full_callgraph(bv: BinaryView): 122 | """Build up and render the whole callgraph for a BinaryView""" 123 | cur_callgraph = get_callgraph(core, bv) 124 | if check_callgraph(cur_callgraph, bv): 125 | log_info(f'Serving full callgraph {graph_size(cur_callgraph)}') 126 | 127 | graph_name = f'Full Callgraph of "{filename(bv.file.filename)}"' 128 | core.push_new_graph(cur_callgraph, graph_name) 129 | 130 | 131 | def show_func_callgraph(bv: BinaryView, function: Function): 132 | """Show full callgraph for current function""" 133 | cur_callgraph = get_callgraph(core, bv) 134 | if check_callgraph(cur_callgraph, bv): 135 | func_neighborhood = core.targets[bv].get_near_neighbors(function, 9999, None) 136 | log_info(f'Serving callgraph for {func_name(function)} {graph_size(func_neighborhood)}') 137 | graph_name = f'Callgraph for "{func_name(function)}"' 138 | core.push_new_graph(func_neighborhood, graph_name) 139 | 140 | 141 | def show_source_sink(bv: BinaryView, function: Function): 142 | """Show full callgraph for current function""" 143 | cur_callgraph = get_callgraph(core, bv) 144 | if check_callgraph(cur_callgraph, bv): 145 | source_sink = prompt_source_sink_settings(bv, function) 146 | if not source_sink: 147 | return 148 | source, sink = source_sink 149 | source_sink_graph = get_source_sink(cur_callgraph, source, sink) 150 | log_info(f'Serving source/sink for {func_name(source)} -> {func_name(sink)} {graph_size(source_sink_graph)}') 151 | graph_name = f'Source/Sink "{func_name(source)}" -> "{func_name(sink)}"' 152 | core.push_new_graph(source_sink_graph, graph_name) 153 | 154 | 155 | def prompt_source_sink_settings(bv: BinaryView, function: Function) -> Optional[Tuple[Function, Function]]: 156 | """Use BN's interaction helper to get user to specify source/sink""" 157 | form_fields = [] 158 | form_fields.append(f'Source/Sink analysis for: {function.symbol.short_name}') 159 | 160 | source_sink_field = ChoiceField("Use current function as:", ["Source", "Sink"]) 161 | form_fields.append(source_sink_field) 162 | 163 | target_field = TextLineField("Other function/address:") 164 | form_fields.append(target_field) 165 | 166 | if not get_form_input(form_fields, "Source/Sink Analysis") or not TextLineField.result: 167 | return None 168 | 169 | target = parse_target_str(bv, target_field.result) 170 | if target is None: 171 | return None 172 | 173 | # Current function as 'source' is first choice (0), otherwise its the sink 174 | if source_sink_field.result == 0: 175 | return function, target 176 | else: 177 | return target, function 178 | 179 | 180 | def parse_target_str(bv: BinaryView, target_str: str) -> Optional[Function]: 181 | """Helper to match string to function, by its name or address it contains""" 182 | # Check function name first 183 | try: 184 | target = next(f for f in bv.functions if f.name == target_str or f.symbol.short_name == target_str) 185 | if target: 186 | return target 187 | except StopIteration: 188 | pass 189 | 190 | # Check as int 191 | try: 192 | if target_str.startswith('0x'): 193 | target_int = int(target_str, 16) 194 | else: 195 | target_int = int(target_str, 10) 196 | except ValueError: 197 | return None 198 | 199 | # Check if the int is the start of a function, then if it's _in_ a function 200 | if bv.get_function_at(target_int): 201 | target = bv.get_function_at(target_int) 202 | return target 203 | else: 204 | containing_funcs = bv.get_functions_containing(target_int) 205 | if len(containing_funcs == 0): 206 | log_error(f'No functions contain target address 0x{target_int:x}') 207 | return None 208 | else: 209 | target = containing_funcs[0] 210 | if len(containing_funcs) > 1: 211 | log_info(f'NOTE: More than one function contains target address 0x{target_int:x}') 212 | for func_name in containing_funcs: 213 | log_info(func_name) 214 | log_info(f'Going with first match {target.symbol.short_name}; use start address or name for precision') 215 | return target 216 | 217 | 218 | PluginCommand.register( 219 | "Ariadne\\Graph: All functions", 220 | "Generate callgraph for all function", 221 | show_full_callgraph, 222 | ) 223 | 224 | PluginCommand.register_for_function( 225 | "Ariadne\\Graph: Current function callgraph", 226 | "Generate callgraph for current function", 227 | show_func_callgraph, 228 | ) 229 | 230 | PluginCommand.register_for_function( 231 | "Ariadne\\Graph: Source/Sink w/Current function", 232 | "Graph the current function as a source or sink", 233 | show_source_sink, 234 | ) 235 | -------------------------------------------------------------------------------- /ariadne/py2cytoscape.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Handle generating Python dictionary objects in the format cytoscape expects. 5 | 6 | Cytoscape expects: 7 | { 8 | 'elements': [ 9 | 'nodes': [ 10 | 'data': { 11 | 'key': val, # 'id' is only required key 12 | ... 13 | }, 14 | ... 15 | ] 16 | 'edges': [ 17 | 'data': { 18 | 'id': val, # 'id', 'source', and 'target' required 19 | 'source': val, 20 | 'target': val, 21 | ... 22 | }, 23 | ... 24 | ] 25 | ] 26 | } 27 | 'nodes' and 'edges' can be combined into single list if structure is inferable 28 | ''' 29 | 30 | from typing import Dict, Any, Optional, List 31 | from pathlib import Path 32 | import os 33 | import json 34 | import sys 35 | import networkx as nx 36 | from binaryninja import Function 37 | 38 | from ..ariadne.util_funcs import func_name 39 | 40 | def get_json_from_file(input_file: str) -> dict: 41 | with open(input_file) as f: 42 | json_obj = json.load(f) 43 | return json_obj 44 | 45 | 46 | def graph_to_cytoscape( 47 | graph: nx.DiGraph, 48 | node_metadata: Optional[Dict[Function, Dict[str, Any]]]=None, 49 | edge_metadata: Optional[Dict[Function, Dict[str, Any]]]=None, 50 | ) -> Dict[str, list]: 51 | """Convert graph of BN Functions to cytoscape data dict. 52 | 53 | Metadata to be passed into nodes included in the dictionary. 54 | """ 55 | func_names = [] 56 | elements: List[Dict[str, Any]] = [] 57 | for i, cur_func in enumerate(graph.nodes): 58 | name = func_name(cur_func) 59 | # Strictly required metadata 60 | node_data = { 61 | "id": str(i), 62 | "label": str(name), 63 | } 64 | # Pass optional metadata in 65 | if node_metadata is not None: 66 | extra_metadata = node_metadata.get(cur_func, None) 67 | if extra_metadata: 68 | node_data.update(extra_metadata) 69 | 70 | elements.append({"data": node_data}) 71 | func_names.append(name) 72 | 73 | # map names to their index in the nodes list 74 | name_map = {n:i for i, n in enumerate(func_names)} 75 | 76 | for source_func, target_func in graph.edges: 77 | source = str(name_map[func_name(source_func)]) 78 | target = str(name_map[func_name(target_func)]) 79 | 80 | edge_covered = 0 81 | if edge_metadata is not None: 82 | if target_func.start in edge_metadata[source_func]['covered_edges']: 83 | edge_covered = 1 84 | 85 | elements.append({ 86 | "data": { 87 | "id": f'{source}-{target}', 88 | "source": source, 89 | "target": target, 90 | "covered": edge_covered, 91 | }, 92 | }) 93 | 94 | cytoscape_model = { 95 | "elements": elements, 96 | } 97 | 98 | return cytoscape_model 99 | 100 | def json_to_cytoscape(json_obj: dict) -> dict: 101 | callee_dict = json_obj 102 | func_names = list(callee_dict.keys()) 103 | elements = [{ 104 | "data": { 105 | "id": str(i), 106 | "label": str(n), 107 | }, 108 | } for i, n in enumerate(func_names)] 109 | 110 | # map names to their index in the nodes list 111 | name_map = {n:i for i, n in enumerate(func_names)} 112 | 113 | for cur_func, callees in callee_dict.items(): 114 | for called_func in callees: 115 | source = str(name_map[cur_func]) 116 | target = str(name_map[called_func]) 117 | elements.append({ 118 | "data": { 119 | "id": f'{source}-{target}', 120 | "source": source, 121 | "target": target, 122 | }, 123 | }) 124 | 125 | cytoscape_model = { 126 | "elements": elements, 127 | } 128 | 129 | return cytoscape_model 130 | 131 | 132 | def write_model_to_file(output_file: str, model: dict): 133 | with open(output_file, 'w') as f: 134 | json.dump(model, f) 135 | 136 | out_str = output_file.as_posix() 137 | if os.path.exists(out_str): 138 | print(f'[+] Wrote {os.path.getsize(out_str)} bytes to "{out_str}"') 139 | else: 140 | raise Exception(f'output file {out_str} not found') 141 | 142 | 143 | if __name__ == '__main__': 144 | if len(sys.argv) != 2: 145 | print(f'USAGE: {sys.argv[0]} JSON_FILE') 146 | exit(2) 147 | 148 | input_file = Path(sys.argv[1]) 149 | output_dir = Path('js/graphs') 150 | output_file = output_dir.joinpath(func_name(input_file)) 151 | 152 | json_obj = get_json_from_file(input_file) 153 | model = json_to_cytoscape(json_obj) 154 | write_model_to_file(output_file, model) 155 | -------------------------------------------------------------------------------- /ariadne/server.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The server class 3 | 4 | Handles serving the HTML and talking websockets 5 | ''' 6 | 7 | from typing import Any, Dict 8 | 9 | import asyncio 10 | import json 11 | import os 12 | from pathlib import Path 13 | import websockets 14 | 15 | from http.server import HTTPServer, SimpleHTTPRequestHandler 16 | from threading import Thread 17 | 18 | from .util_funcs import log_info, log_error, get_web_dir, short_name 19 | 20 | 21 | # Ports are per-Binary Ninja instance (which has its own Python interpreter) 22 | instance_http_port: int = -1 23 | instance_websocket_port: int = -1 24 | server_instance = None 25 | 26 | 27 | class AriadneHTTPHandler(SimpleHTTPRequestHandler): 28 | def do_GET(self): 29 | """Serve a GET request (with specific fix for JS).""" 30 | path = Path(self.translate_path(self.path)) 31 | 32 | # For nonstandard websocket ports, fix it in the JS on the fly 33 | if instance_websocket_port != 7890 and path.parent.name == 'web' and path.name == 'main.js': 34 | with open(path) as js_file: 35 | js_contents = js_file.read() 36 | js_contents = js_contents.replace('server_port = 7890', f'server_port = {instance_websocket_port}') 37 | 38 | # Write the contents to a new file, then change the path to match it 39 | new_name = f'{path.stem}-{instance_websocket_port}{path.suffix}' 40 | new_file = path.with_name(new_name) 41 | with open(new_file, 'w') as nf: 42 | nf.write(js_contents) 43 | 44 | # self.path is relative to web/ as the root 45 | override_path = f'/{new_file.name}' 46 | self.path = str(override_path) 47 | 48 | super().do_GET() 49 | 50 | def log_message(self, format: str, *args: Any) -> None: 51 | # Uncomment to see resources being requested 52 | #log_info(format % args, 'ARIADNE:HTTP') 53 | pass 54 | 55 | 56 | def run_http_server(address: str, port: int): 57 | handler = AriadneHTTPHandler 58 | os.chdir(get_web_dir()) 59 | with HTTPServer((address, port), handler) as httpd: 60 | log_info(f'Serving web UI at http://{address}:{port}', 'ARIADNE:HTTP') 61 | httpd.serve_forever() 62 | 63 | 64 | # The two global strings for websocket send/recv 65 | json_contents = None 66 | client_msg = None 67 | 68 | 69 | async def read_client(websocket): 70 | global client_msg 71 | # This will time out if the client doesn't send any messages 72 | client_msg = await websocket.recv() 73 | 74 | 75 | async def websocket_handler(websocket): 76 | global json_contents, client_msg 77 | log_info('Websocket Client connected', 'ARIADNE:WS') 78 | # Wait until graph JSON is available 79 | while json_contents is None: 80 | await asyncio.sleep(0.1) 81 | 82 | # FUTURE: switch from polling model to async 83 | prev_json = None 84 | try: 85 | while True: 86 | # Tell the core to graph a new function 87 | if client_msg: 88 | try: 89 | client_dict = json.loads(client_msg) 90 | bv_name = client_dict['bv'] 91 | start_addr = client_dict['start'] 92 | server_instance.core.graph_new_neighborhood(bv_name, start_addr) 93 | except Exception as e: 94 | log_error(f'websocket_handler: client_msg handling exception: "{e}"') 95 | # the JSON object must be str, bytes or bytearray not coroutine 96 | client_msg = None 97 | 98 | # Send the new graph data to the web UI 99 | if prev_json != json_contents: 100 | await websocket.send(json_contents) 101 | # Uncomment to see size of each JSON update being sent out 102 | #log_info(f'JSON sent, {len(json_contents)} bytes', 'ARIADNE:WS') 103 | prev_json = json_contents 104 | 105 | # Either the core will set json_contents or read_client will set client_msg 106 | while prev_json == json_contents and client_msg is None: 107 | try: 108 | await asyncio.wait_for(read_client(websocket), timeout=0.1) 109 | except asyncio.exceptions.TimeoutError: 110 | pass 111 | await asyncio.sleep(0) 112 | except websockets.exceptions.ConnectionClosedError as e: 113 | log_info(f'Client connection closed with code {e.code}', 'ARIADNE:WS') 114 | 115 | 116 | class AriadneServer(): 117 | def __init__(self, core, ip: str, http_port: int, websocket_port: int): 118 | self.core = core # AriadneCore 119 | self.ip = ip 120 | self.http_port = http_port 121 | self.websocket_port = websocket_port 122 | 123 | def start_webserver(self): 124 | global instance_http_port 125 | instance_http_port = self.http_port 126 | self.http_thread = Thread( 127 | target=run_http_server, 128 | args=(self.ip, self.http_port), 129 | daemon=True, 130 | ) 131 | self.http_thread.start() 132 | 133 | def start_websocket_server(self): 134 | global instance_websocket_port, server_instance 135 | instance_websocket_port = self.websocket_port 136 | server_instance = self 137 | 138 | self.websocket_thread = Thread( 139 | target=self.run_websocket_server, 140 | args=tuple(), 141 | daemon=True, 142 | ) 143 | self.websocket_thread.start() 144 | 145 | def set_graph_data(self, bv, json_obj: Dict[str, Any], title: str): 146 | global json_contents 147 | json_obj['title'] = title 148 | json_obj['bv'] = short_name(bv) 149 | json_str = json.dumps(json_obj) 150 | json_contents = json_str 151 | 152 | def run_websocket_server(self): 153 | try: 154 | event_loop = asyncio.new_event_loop() 155 | asyncio.set_event_loop(event_loop) 156 | 157 | task = event_loop.create_task(run_server(self.ip, self.websocket_port)) 158 | #log_info(f'Websocket listening on {self.ip}:{self.websocket_port}...', 'ARIADNE:WS') 159 | 160 | event_loop.run_until_complete(task) 161 | event_loop.run_forever() 162 | except Exception as e: 163 | log_error(f'Caught Exception: {e}', 'ARIADNE:WS') 164 | log_error(f'Websocket server stopping...', 'ARIADNE:WS') 165 | 166 | async def run_server(ip, port): 167 | await asyncio.sleep(0) 168 | 169 | try: 170 | await websockets.serve(websocket_handler, ip, port) 171 | except Exception as e: 172 | log_error(f'Caught Exception: {e}', 'ARIADNE:WS') -------------------------------------------------------------------------------- /ariadne/target.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Target-level analysis and metadata 3 | ''' 4 | 5 | from __future__ import annotations 6 | from typing import Dict, Iterable, Optional, Set, Any, List 7 | 8 | import json 9 | from pathlib import Path 10 | import networkx as nx 11 | import time 12 | 13 | from binaryninja import Function, BinaryView, ReferenceSource 14 | from .analysis.ariadne_function import AriadneFunction 15 | from .py2cytoscape import graph_to_cytoscape 16 | from .util_funcs import log_info, log_warn, log_error, func_name 17 | 18 | 19 | class AriadneTarget(): 20 | def __init__(self, bv, core): 21 | self.bv = bv 22 | self.target_name = Path(bv.file.original_filename).name 23 | self.core = core 24 | self.function_dict: Dict[Function, AriadneFunction] = {} 25 | self.function_list: List[Function] = [] 26 | self.g: Optional[nx.DiGraph]= None 27 | self.undirected: Optional[nx.Graph] = None 28 | self.current_function: Optional[Function] = None 29 | self.banned_functions: Set[Function] = set() 30 | self.coverage_available = False 31 | self.update_notification: Optional[BinaryDataNotification] = None 32 | self.update_thread: Optional[BackgroundTargetUpdate] = None 33 | self.warned_missing_funcs = False 34 | # Optional bncov data 35 | self.coverage_stats: Optional[dict] = None 36 | self.covdb = None 37 | 38 | def serialize(self): 39 | first_ariadne_func = next(iter(self.function_dict.values())) 40 | analysis_functions_run = first_ariadne_func.analysis_functions_run 41 | 42 | target_dict = { 43 | 'target_name': self.target_name, 44 | 'ariadne_functions': [f.serialize() for f in self.function_dict.values()], 45 | 'analysis_functions_run': analysis_functions_run, 46 | } 47 | if self.coverage_stats: 48 | target_dict['coverage_stats'] = self.coverage_stats 49 | 50 | return json.dumps(target_dict) 51 | 52 | @staticmethod 53 | def deserialize( 54 | bv: BinaryView, 55 | core, 56 | serialized_dict: str 57 | ) -> Optional[AriadneTarget]: 58 | new_target = AriadneTarget(bv, core) 59 | saved_dict = json.loads(serialized_dict) 60 | 61 | saved_name = Path(saved_dict['target_name']) 62 | cur_name = Path(new_target.target_name) 63 | if saved_name.stem != cur_name.stem: 64 | log_error(f'ERROR: saved target_name ({saved_name}) does not match current: ({cur_name})') 65 | return None 66 | 67 | analysis_funcs = saved_dict['analysis_functions_run'] 68 | saved_ariadne_funcs = saved_dict['ariadne_functions'] 69 | function_list = list(bv.functions) 70 | 71 | num_funcs = len(function_list) 72 | num_saved_funcs = len(saved_ariadne_funcs) 73 | if num_funcs != num_saved_funcs: 74 | log_warn(f'Encountered mismatch between number of functions in current BinaryView ({num_funcs}) and saved analysis ({num_saved_funcs})') 75 | log_warn('If the binary has not changed, you must save the result of new analysis.') 76 | return None 77 | new_target.function_list = function_list 78 | 79 | for af_dict in saved_dict['ariadne_functions']: 80 | cur_ariadne_function = AriadneFunction.deserialize(bv, af_dict, analysis_funcs) 81 | cur_function = cur_ariadne_function.function 82 | new_target.function_dict[cur_function] = cur_ariadne_function 83 | 84 | if 'coverage_stats' in saved_dict: 85 | new_target.coverage_stats = saved_dict['coverage_stats'] 86 | new_target.coverage_available = True 87 | 88 | new_target.generate_callgraph() 89 | return new_target 90 | 91 | def generate_callgraph(self): 92 | self.g = nx.DiGraph() 93 | for bn_func, ariadne_func in self.function_dict.items(): 94 | self.g.add_node(bn_func) 95 | # AriadneFunc has callers precomputed and uniq'd 96 | for caller in ariadne_func.callers: 97 | self.g.add_edge(caller, bn_func) 98 | self.undirected = nx.Graph(self.g) 99 | 100 | def get_callgraph(self) -> nx.DiGraph: 101 | if self.g is None: 102 | raise Exception(f'generate_callgraph() must be called before get_callgraph()') 103 | return self.g.copy() 104 | 105 | def get_undirected_callgraph(self) -> nx.Graph: 106 | if self.undirected is None: 107 | raise Exception(f'generate_callgraph() must be called before get_undirected_callgraph()') 108 | return self.undirected.copy() 109 | 110 | def get_cytoscape(self, graph: nx.DiGraph=None) -> Dict[str, Any]: 111 | """Return a cytoscape data object for given nx graph""" 112 | 113 | if graph is None: 114 | graph = self.get_callgraph() 115 | 116 | nodes_to_keep = [n for n in graph.nodes if n not in self.banned_functions] 117 | graph = graph.subgraph(nodes_to_keep) 118 | 119 | # Metadata used for info display and node styling 120 | node_metadata: Dict[Function, Dict[str, Any]] = { 121 | cur_func: { 122 | # Metadata shown in this order 123 | 'start': cur_func.start, 124 | 'args': self.function_dict[cur_func].metadata['args'], 125 | 'return_type': self.function_dict[cur_func].metadata['return_type'], 126 | 'blocks': self.function_dict[cur_func].metadata['blocks'], 127 | 'instructions': self.function_dict[cur_func].metadata['instructions'], 128 | 'complexity': self.function_dict[cur_func].metadata['complexity'], 129 | 'num_descendants': self.function_dict[cur_func].metadata['num_descendants'], 130 | 'descendent_complexity': self.function_dict[cur_func].metadata.get('descendent_complexity'), 131 | 'callers': self.function_dict[cur_func].metadata['callers'], 132 | 'local_callees': self.function_dict[cur_func].metadata['local_callees'], 133 | 'imported_callees': self.function_dict[cur_func].metadata['imported_callees'], 134 | 'global_refs': self.function_dict[cur_func].metadata['global_refs'], 135 | 'stack_frame_size': self.function_dict[cur_func].metadata['stack_frame_size'], 136 | # Used for styling, not shown in metadata 137 | 'visited': self.function_dict[cur_func].get_visited(), 138 | 'import': self.function_dict[cur_func].metadata['is_import'], 139 | # Coverage analysis may not be available 140 | 'coverage_percent': self.function_dict[cur_func].metadata.get('coverage_percent'), 141 | 'blocks_covered': self.function_dict[cur_func].metadata.get('blocks_covered'), 142 | 'blocks_total': self.function_dict[cur_func].metadata.get('blocks_total'), 143 | 'callsite_coverage': self.function_dict[cur_func].metadata.get('callsite_coverage'), 144 | 'uncovered_descendent_complexity': self.function_dict[cur_func].metadata.get('uncovered_descendent_complexity'), 145 | # Hint for the user that there's more to see from this node 146 | 'edges_not_shown': abs(graph.degree(cur_func) - self.g.degree(cur_func)), 147 | } 148 | for cur_func in graph.nodes 149 | } 150 | 151 | # Edge metadata, keyed off of source function 152 | if not self.coverage_available: 153 | edge_metadata = None 154 | else: 155 | edge_metadata = { 156 | cur_func: { 157 | 'covered_edges': self.function_dict[cur_func].metadata.get('covered_edges') 158 | } 159 | for cur_func in graph.nodes 160 | } 161 | 162 | bn_active_function = self.current_function 163 | if bn_active_function and bn_active_function in graph: 164 | node_metadata[bn_active_function]['current_function'] = 1 165 | 166 | cyto_json = graph_to_cytoscape(graph, node_metadata, edge_metadata) 167 | return cyto_json 168 | 169 | def get_near_neighbors(self, origin_func: Function, num_levels: int=3, max_nodes: Optional[int]=None) -> nx.DiGraph: 170 | """Return n-hop neighborhood, with optional limit on number of nodes. 171 | 172 | If max_nodes is specified, each hop after the first will be checked to 173 | see if it would go over the limit and will stop before it exceeds 174 | max_nodes.""" 175 | 176 | if not self.g: 177 | log_error(f'Callgraph required for get_near_neighbors()') 178 | return 179 | neighbors = set([origin_func, ]) 180 | to_add: Set[Function] = set() 181 | cur_level = set() 182 | for i in range(num_levels): 183 | to_check = set(neighbors) 184 | for cur_node_func in to_check: 185 | if cur_node_func not in to_add: 186 | # don't add callers of imported functions as neighbors 187 | # unless they are the origin node 188 | if cur_node_func == origin_func or not self.function_dict[cur_node_func].is_import(): 189 | new_neighbors = nx.neighbors(self.undirected, cur_node_func) 190 | neighbors.update(new_neighbors) 191 | cur_level.add(cur_node_func) 192 | # Enforce max_nodes check on levels beyond the first 193 | if max_nodes is None or i <= 1 or len(to_add) + len(cur_level) < max_nodes: 194 | to_add.update(cur_level) 195 | else: 196 | break 197 | 198 | near_neighbor_graph: nx.Graph = self.g.subgraph(to_add) 199 | return near_neighbor_graph 200 | 201 | def get_n_hops_out(self, source: Function, dist: int) -> nx.DiGraph: 202 | graph = self.get_callgraph() 203 | return nx.bfs_tree(graph, source, False, dist) 204 | 205 | def get_source_sink(self, source: Function, sink: Function) -> nx.DiGraph: 206 | """Show graph between source and sink, if any""" 207 | if not self.g: 208 | log_error(f'Callgraph required for get_source_sink()') 209 | return 210 | source_descendants = nx.descendants(self.g, source) 211 | sink_ancestors = nx.ancestors(self.g, sink) 212 | subgraph_nodes = source_descendants.intersection(sink_ancestors) 213 | subgraph_nodes.update([source, sink]) 214 | 215 | source_sink_graph = self.g.subgraph(subgraph_nodes) 216 | return source_sink_graph 217 | 218 | def do_analysis(self): 219 | """This is a single-threaded implementation; see core.BackgroundAnalysis.run() instead""" 220 | self.function_list = list(self.bv.functions) 221 | num_funcs = len(self.function_list) 222 | for i, f in enumerate(self.function_list): 223 | cur_function_obj = AriadneFunction(f) 224 | cur_function_obj.collect_function_data() 225 | cur_function_obj.init_visited() 226 | self.function_dict[f] = cur_function_obj 227 | # Cross-function analysis 228 | self.generate_callgraph() 229 | self.do_graph_analysis() 230 | 231 | def do_graph_analysis(self): 232 | for bn_func, ariadne_func in self.function_dict.items(): 233 | ariadne_func.metadata['num_descendants'] = len(nx.descendants(self.g, bn_func)) 234 | 235 | func_descendents = nx.descendants(self.g, bn_func) 236 | # complexity is always computed in core analysis 237 | descendent_complexity = sum(self.function_dict[f].metadata['complexity'] for f in func_descendents) 238 | ariadne_func.metadata['descendent_complexity'] = descendent_complexity 239 | 240 | def do_coverage_analysis(self, covdb) -> bool: 241 | """Single-threaded all-in-one coverage analysis 242 | 243 | Note that core.BackgroundAnalysis does this automatically if coverage 244 | data is present and does it multithreaded.""" 245 | start_time = time.time() 246 | if not self.init_coverage_analysis(covdb): 247 | return False 248 | 249 | coverage_files = len(self.covdb.coverage_files) 250 | log_info(f'Starting coverage analysis ({coverage_files} coverage files,' + 251 | f'{len(self.function_list)} functions)' ) 252 | 253 | for bn_func in self.function_list: 254 | self.do_function_coverage_analysis(bn_func) 255 | 256 | self.mark_coverage_analysis_finished() 257 | duration = time.time() - start_time 258 | log_info(f'Finished coverage analysis for "{self.target_name}" in {duration:.02f} seconds') 259 | 260 | return True 261 | 262 | def init_coverage_analysis(self, covdb) -> bool: 263 | """Do any target-level coverage analysis tasks""" 264 | if len(covdb.coverage_files) == 0: 265 | log_warn(f'Stopping coverage analysis: No coverage files in covdb.') 266 | return False 267 | 268 | self.covdb = covdb 269 | self.coverage_stats = covdb.collect_function_coverage() 270 | 271 | return True 272 | 273 | def get_callsite_dest(self, callsite: ReferenceSource) -> Optional[Function]: 274 | """Use MLIL to find destinations... slower but more thorough""" 275 | try: 276 | call_llil = callsite.llil 277 | if call_llil is None: 278 | return None 279 | call_dest_addr = call_llil.dest.value.value 280 | if call_dest_addr is None: 281 | return None 282 | return self.bv.get_function_at(call_dest_addr) 283 | except: 284 | return None 285 | 286 | def do_function_coverage_analysis(self, bn_func): 287 | """Execute function-level coverage analysis""" 288 | bv = bn_func.view 289 | ariadne_func = self.function_dict[bn_func] 290 | cur_func_stats = self.coverage_stats[bn_func.start] 291 | 292 | ariadne_func.metadata['coverage_percent'] = cur_func_stats.coverage_percent 293 | ariadne_func.metadata['blocks_covered'] = cur_func_stats.blocks_covered 294 | ariadne_func.metadata['blocks_total'] = cur_func_stats.blocks_total 295 | 296 | total_descendents = nx.descendants(self.g, bn_func) 297 | # complexity for strictly zero-coverage descendents 298 | uncovered_descendent_complexity = sum( 299 | self.coverage_stats[f.start].complexity for f in total_descendents 300 | if self.coverage_stats[f.start].blocks_covered == 0 301 | ) 302 | ariadne_func.metadata['uncovered_descendent_complexity'] = uncovered_descendent_complexity 303 | 304 | num_callsites = 0 305 | num_covered_callsites = 0 306 | covered_edges: List[int] = [] # Record callgraph edge coverage by dest start addr 307 | coverage = self.covdb.total_coverage 308 | for callsite in bn_func.call_sites: 309 | call_addr = callsite.address 310 | # Will "double-count" in the case of overlapping blocks 311 | for block in bv.get_basic_blocks_at(call_addr): 312 | if block.start in coverage: 313 | num_covered_callsites += 1 314 | call_dest_func = self.get_callsite_dest(callsite) 315 | if call_dest_func: 316 | call_dest = call_dest_func.start 317 | if call_dest not in covered_edges: 318 | covered_edges.append(call_dest) 319 | num_callsites += 1 320 | 321 | ariadne_func.metadata['callsite_coverage'] = f'{num_covered_callsites}/{num_callsites}' 322 | ariadne_func.metadata['covered_edges'] = covered_edges 323 | 324 | self.function_dict[bn_func] = ariadne_func 325 | 326 | def mark_coverage_analysis_finished(self): 327 | self.coverage_available = True 328 | 329 | def set_current_function(self, function: Function, do_visit: bool = True) -> bool: 330 | if function not in self.function_dict: 331 | log_warn(f'Function "{function.name}" @ {hex(function.start)} not found in Ariadne target analysis') 332 | if not self.warned_missing_funcs: 333 | log_info('If analysis is not currently underway, you can add it manually with:') 334 | log_info(' ariadne.core.targets[bv].add_new_function(current_function)') 335 | log_info('Or you can redo analysis via context menu: Ariadne -> Analyze Target') 336 | self.warned_missing_funcs = True 337 | return False 338 | self.current_function = function 339 | if do_visit: 340 | self.mark_visited(function) 341 | return True 342 | 343 | def mark_visited(self, function: Function): 344 | if function not in self.function_dict: 345 | log_error(f'Function "{function.name}" @ {hex(function.start)} not found in Ariadne target analysis') 346 | ariadne_function = self.function_dict[function] 347 | ariadne_function.set_visited() 348 | 349 | def mark_visited_set(self, func_list: Iterable[Function]): 350 | for cur_func in func_list: 351 | self.mark_visited(cur_func) 352 | 353 | def remove_imports_from_graph(self, graph: nx.Graph) -> Optional[nx.Graph]: 354 | to_remove = [] 355 | for function in graph.nodes: 356 | if function not in self.function_dict: 357 | log_error(f'Function {func_name(function)} @ 0x{function.start:x} not in function dict, stopping') 358 | return graph 359 | if self.function_dict[function].metadata.get('is_import', 0): 360 | to_remove.append(function) 361 | graph.remove_nodes_from(to_remove) 362 | return graph 363 | 364 | def remove_islands_from_graph(self, graph: nx.Graph) -> Optional[nx.Graph]: 365 | """Remove nodes with no edges""" 366 | to_remove = list(nx.isolates(graph)) 367 | graph.remove_nodes_from(to_remove) 368 | return graph 369 | 370 | def get_largest_component(self, graph: nx.Graph) -> Optional[nx.Graph]: 371 | undirected = nx.Graph(graph) 372 | # connected_components generates components, largest first 373 | largest_component = next(nx.connected_components(undirected)) 374 | return graph.subgraph(largest_component) 375 | 376 | def ban_function_from_graph(self, function: Function): 377 | """Never show the supplied function again""" 378 | if function not in self.function_dict: 379 | log_error(f'Function {func_name(function)} @ 0x{function.start:x} not in function dict') 380 | return 381 | self.banned_functions.add(function) 382 | 383 | def ban_set_from_graph(self, function_set: Iterable[Function]): 384 | """Never show any of the supplied functions in graphing""" 385 | self.banned_functions.update(function_set) 386 | 387 | def unban_function(self, function: Function): 388 | if function in self.banned_functions: 389 | self.banned_functions.remove(function) 390 | -------------------------------------------------------------------------------- /ariadne/util_funcs.py: -------------------------------------------------------------------------------- 1 | 2 | import networkx as nx 3 | from pathlib import Path 4 | 5 | from binaryninja import BinaryView, Function 6 | from binaryninja import ( 7 | log_info as bn_log_info, 8 | log_warn as bn_log_warn, 9 | log_error as bn_log_error, 10 | log_debug as bn_log_debug, 11 | ) 12 | 13 | 14 | def short_name(bv: BinaryView) -> str: 15 | """Return the short name for a BinaryView""" 16 | return Path(bv.file.original_filename).stem 17 | 18 | def filename(filepath: str) -> str: 19 | return Path(filepath).name 20 | 21 | def func_name(f: Function) -> str: 22 | """Standardized way to get function name""" 23 | return f.symbol.short_name 24 | 25 | 26 | def graph_size(g: nx.Graph) -> str: 27 | """Return formatted size str "(# nodes, # edges)" for graph""" 28 | num_nodes = len(g.nodes()) 29 | num_edges = len(g.edges()) 30 | return f'({num_nodes} nodes, {num_edges} edges)' 31 | 32 | 33 | def log_debug(msg: str, tag: str='ARIADNE'): 34 | bn_log_debug(msg, tag) 35 | 36 | def log_info(msg: str, tag: str='ARIADNE'): 37 | bn_log_info(msg, tag) 38 | 39 | def log_warn(msg: str, tag: str='ARIADNE'): 40 | bn_log_warn(msg, tag) 41 | 42 | def log_error(msg: str, tag: str='ARIADNE'): 43 | bn_log_error(msg, tag) 44 | 45 | 46 | def get_repo_dir() -> Path: 47 | cur_file = Path(__file__) 48 | return cur_file.parent.parent.absolute() 49 | 50 | def get_web_dir() -> str: 51 | return get_repo_dir().joinpath('web') 52 | -------------------------------------------------------------------------------- /download_bncov.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import shutil 5 | 6 | from pathlib import Path 7 | 8 | from binaryninja import user_plugin_path 9 | 10 | 11 | bncov_repo_url = 'https://github.com/ForAllSecure/bncov.git' 12 | plugin_dir = Path(user_plugin_path()) 13 | 14 | git_path = shutil.which('git') 15 | if git_path is None: 16 | print(f'[!] Couldn\'t detect git path, bailing...') 17 | print(f' Otherwise just manually change directory to "{plugin_dir}"') 18 | print(f' and run "git clone {bncov_repo_url}"') 19 | 20 | expected_dest = os.path.join(plugin_dir, 'bncov') 21 | if os.path.exists(expected_dest): 22 | print(f'[!] bncov dir already exists? ({expected_dest})') 23 | #exit(-2) 24 | 25 | print(f'[*] Cloning bncov to plugin directory "{plugin_dir}"') 26 | 27 | os.chdir(plugin_dir) 28 | 29 | git_clone_command = f'git clone {bncov_repo_url}' 30 | print(f'[*] Running: "{git_clone_command}"') 31 | retval = os.system(git_clone_command) 32 | if retval != 0: 33 | print(f'[*] Return code of git clone command: {retval}') 34 | else: 35 | bncov_path = os.path.abspath('bncov') 36 | if os.path.exists('bncov'): 37 | print(f'[+] bncov successfully installed to {bncov_path}') 38 | else: 39 | print(f'[!] bncov not found at "{bncov_path}", check the output above') 40 | -------------------------------------------------------------------------------- /headless_analysis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Perform Ariadne analysis via headless script (requires commercial BN license) 5 | and save the result to a file for faster loading later. 6 | 7 | Helpful for pre-processing on a server or remote machine. 8 | 9 | Can also incorporate coverage analysis from bncov as part of the analysis. 10 | ''' 11 | 12 | from typing import Optional 13 | import argparse 14 | import math 15 | import os 16 | import sys 17 | import time 18 | 19 | import binaryninja 20 | 21 | sys.path.insert(0, binaryninja.user_plugin_path()) 22 | 23 | coverage_enabled = False 24 | try: 25 | import bncov 26 | coverage_enabled = True 27 | except ImportError: 28 | try: 29 | import ForAllSecure_bncov as bncov 30 | coverage_enabled = True 31 | except ImportError: 32 | pass 33 | 34 | import ariadne 35 | 36 | 37 | USAGE = f'{sys.argv[0]} TARGET_FILE [COVERAGE_DIR]' 38 | 39 | 40 | def pretty_size(filename): 41 | filesize = os.path.getsize(filename) 42 | if filesize == 0: 43 | return "0B" 44 | suffix = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") 45 | unit_index = int(math.floor(math.log(filesize, 1024))) 46 | unit_size = math.pow(1024, unit_index) 47 | rounded_size = round(filesize / unit_size, 2) 48 | return f'{rounded_size} {suffix[unit_index]}' 49 | 50 | 51 | if __name__ == '__main__': 52 | parser = argparse.ArgumentParser() 53 | parser.add_argument('TARGET', help='Binary or bndb to analyze and save data for') 54 | parser.add_argument('-c', '--coverage_dir', help='Directory containing coverage files for target') 55 | parser.add_argument('--load_existing', help='Load cached Ariadne analysis file', action='store_true') 56 | parser.add_argument('--overwrite_existing', help='Overwrite cached Ariadne analysis file (if it exists)', 57 | action="store_true") 58 | parser.add_argument 59 | args = parser.parse_args() 60 | 61 | target = args.TARGET 62 | coverage_dir: Optional[str] = args.coverage_dir 63 | if coverage_dir: 64 | # Check that we have bncov first, then that the directory exists 65 | if coverage_enabled: 66 | if not os.path.exists(coverage_dir): 67 | print(f'[!] Coverage dir "{coverage_dir}" not found') 68 | exit(2) 69 | else: 70 | num_files = len(os.listdir(coverage_dir)) 71 | print(f'[*] Coverage dir: "{coverage_dir}" ({num_files} files)') 72 | else: 73 | print(f'[!] Couldn\'t import bncov, coverage data will be ignored') 74 | 75 | open_start = time.time() 76 | print(f'[*] Loading BinaryView for "{target}" ({pretty_size(target)})...') 77 | bv = binaryninja.open_view(target) 78 | duration = time.time() - open_start 79 | print(f'[*] Completed in {duration:.02f} seconds') 80 | 81 | if coverage_dir: 82 | coverage_start = time.time() 83 | print(f'[*] Starting coverage import...') 84 | covdb = bncov.get_covdb(bv) 85 | covdb.add_directory(coverage_dir) 86 | duration = time.time() - coverage_start 87 | num_files = len(os.listdir(coverage_dir)) 88 | num_blocks = len(covdb.total_coverage) 89 | print(f'[*] Processed {num_files} coverage files ({num_blocks} blocks) in {duration:.02f} seconds') 90 | 91 | print('[*] Instantiating Ariadne core') 92 | core = ariadne.AriadneCore() 93 | 94 | if args.load_existing: 95 | core.force_load_from_cache = True 96 | if args.overwrite_existing: 97 | core.force_cache_overwrite = True 98 | 99 | print('[*] Queuing analysis') 100 | analysis_start = time.time() 101 | core.queue_analysis(bv) 102 | 103 | print('[*] Waiting for Ariadne analysis to complete', end='') 104 | i = 0 105 | while True: 106 | time.sleep(0.1) 107 | i += 1 108 | if bv in core.targets: 109 | break 110 | if (i % 10) == 0: 111 | sys.stdout.write('.') 112 | sys.stdout.flush() 113 | duration = time.time() - open_start 114 | print(f'\n[*] Completed in {duration:.02f} seconds') 115 | 116 | core.save_analysis_to_file(bv) 117 | 118 | saved_file = core.get_cache_target(bv) 119 | if os.path.exists(saved_file): 120 | print(f'[+] Analysis file: {saved_file} (size: {pretty_size(saved_file)})') 121 | else: 122 | print(f'[-] Failed to save file to expected location ({saved_file})') 123 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginmetadataversion": 2, 3 | "name": "Ariadne", 4 | "type": [ 5 | "helper", 6 | "ui" 7 | ], 8 | "api": [ 9 | "python3" 10 | ], 11 | "description": "Browser-based interactive graph for viewing callgraph, static analysis, and coverage information", 12 | "longdescription": "", 13 | "license": { 14 | "name": "MIT", 15 | "text": "Copyright 2025 Mark Griffin (@seeinglogic)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n" 16 | }, 17 | "platforms": [ 18 | "Darwin", 19 | "Linux", 20 | "Windows" 21 | ], 22 | "installinstructions": { 23 | "Darwin": "", 24 | "Linux": "", 25 | "Windows": "" 26 | }, 27 | "dependencies": { 28 | "pip": [ 29 | "networkx", 30 | "websockets", 31 | "requests" 32 | ] 33 | }, 34 | "version": "0.1.3", 35 | "author": "Mark Griffin (@seeinglogic)", 36 | "minimumbinaryninjaversion": 4911 37 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | networkx 2 | websockets 3 | requests -------------------------------------------------------------------------------- /screenshots/ariadne_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/ariadne_screenshot.png -------------------------------------------------------------------------------- /screenshots/breadcrumbing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/breadcrumbing.png -------------------------------------------------------------------------------- /screenshots/coverage_analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/coverage_analysis.png -------------------------------------------------------------------------------- /screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/demo.gif -------------------------------------------------------------------------------- /screenshots/focus_node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/focus_node.png -------------------------------------------------------------------------------- /screenshots/source_sink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/source_sink.png -------------------------------------------------------------------------------- /screenshots/tutorial_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/screenshots/tutorial_main.png -------------------------------------------------------------------------------- /tutorial/README.md: -------------------------------------------------------------------------------- 1 | # Ariadne Tutorial 2 | 3 | This tutorial will take you through a quick tour of the features of Ariadne. 4 | 5 | We'll use the included `templeton.bin` as the target, which is a Linux target 6 | built to run on Ubuntu 20.04 (but you won't need to run it to analyze it). 7 | 8 | Before we start, make sure the plugin is installed by cloning this repo to your 9 | plugins directory. 10 | 11 | ## Starting Analsis 12 | 13 | Open the target in Binary Ninja, and if you see the Ariadne Function pane you 14 | can keep it open or close it, it will update as you navigate across functions 15 | and give you an auto-updated view of the results from the analysis tasks 16 | (analysis metadata) for the current function. If the plugin loaded properly, 17 | you should see a message indicating the web UI will be served on localhost (aka 18 | `127.0.0.1`). 19 | 20 | To start analysis, right-click in the disassembly/IL view and select Plugins > Ariadne > Analyze Target. Analysis tasks will then start and run in the 21 | background. The status of the analysis can be viewed either by the status in 22 | the lower left corner or in the log. While analysis is running, you should open 23 | a browser and navigate to `http://localhost:8800`. 24 | We recommend splitting your 25 | view horizontally so Binary Ninja and your browser are side-by-side so you can 26 | see updates as you continue. 27 | 28 | While analysis is running, the web UI will say "Graph Loading" at the top, and 29 | a graph will be pushed when analysis completes. If you have the Ariadne Function 30 | pane open, you should see that analysis metadata will update. Feel free to 31 | examine the data and see if it's useful to you, but the focus of the plugin and 32 | this tutorial is the web UI. 33 | 34 | ## Exploring the Web UI 35 | 36 | Navigate to `main` and you should see a picture like the one below. 37 | 38 | ![Tutorial main in Web UI](../screenshots/tutorial_main.png) 39 | 40 | The color scheme on the graph is apparent in this single view and the default 41 | rules for element (nodes and edges) styling are: 42 | 43 | - Regular functions are green circles 44 | - Import functions are diamond-shaped and colored orange 45 | - Node size is based on 46 | [cyclomatic complexity](https://en.wikipedia.org/wiki/Cyclomatic_complexity); 47 | more complex functions are bigger circles 48 | - The current function active in Binary Ninja is colored red 49 | - Nodes with double borders mean they have edges that weren't included for the 50 | current graph (default: local neighborhood for active function in BN, see note 51 | below) 52 | - Functions that you've looked at in the Binary Ninja UI have light blue borders 53 | - If you click on a node, it becomes the focus node: 54 | - The focus node is colored purple 55 | - Out-directed edges/nodes (callees) are colored pink 56 | - In-directed edges/nodes (callERs) are colored blue 57 | - Clicking on the focus node deselects it 58 | - Clicking on another node makes that node the focus node 59 | 60 | Starting in the upper left and going clockwise you'll see the following UI 61 | elements/controls: 62 | 63 | - Function search bar 64 | - Title of current graph 65 | - Websocket connection status 66 | - Graph control buttons 67 | - Metadata sidebar 68 | 69 | We feel that each of these are straightforward enough that simple exploration 70 | should be enough to acquaint users with the functionality. Note that the "Toggle 71 | Coverage" button will be disabled unless coverage information was included in 72 | the analysis. 73 | 74 | ## Interacting with the Graph 75 | 76 | All of the nodes are clickable, and you can drag around nodes to adjust the 77 | graph to your liking. The mouse wheel can be used to zoom in/out, and you can 78 | always use the "Reset Graph" button to get things back to the initial view. 79 | 80 | One of the key ideas with the adjustable graph is to allow you to prune the 81 | graph to just what you want to see, so you can remove a node or a node and all 82 | of it's descendent edges. Also, being able to toggle imports may make it much 83 | easier to see what you want, and that button will stay toggled as you navigate 84 | through functions. 85 | 86 | Clicking a node will make it the focus node 87 | and will cause the the function metadata sidebar to expand to show information 88 | about the focus function. If you want to hide the sidebar, you can toggle its 89 | display by clicking the header when it shows `[+]` or `[-]`. 90 | 91 | ## Breadcrumbing 92 | 93 | One feature designed to help with interactive reverse-engineering sessions is 94 | that Ariadne keeps track of whether or not you have looked at a function in 95 | Binary Ninja. Functions you have viewed (detected by whether you have clicked 96 | on an address inside of them) are annotated by a light blue outline. This can 97 | be helpful if you are trying to determine whether you have looked at all the 98 | cross-references for a given function or just remembering where you've been. 99 | 100 | ![Breadcrumbing xrefs to strcpy](../screenshots/breadcrumbing.png) 101 | 102 | ## Coverage Visualization 103 | 104 | One of the key features of Ariadne is to implement coverage visualization, 105 | allowing users to more easily view the extent of their testing either for 106 | fuzzing, malware analysis, or generic reverse-engineering workflows. Ariadne 107 | uses [bncov](https://github.com/ForAllSecure/bncov.git) for coverage analysis, 108 | so in order to visualize coverage, you must install bncov (either by cloning the 109 | repo or using the included `download_bncov.py` script). Second, you must also 110 | have or collect coverage files for your target in one of the formats supported 111 | by bncov such as `drcov` (from DynamoRio, or Lighthouse's Pin tool) or 112 | `module+offset` (from 113 | [windbg](https://github.com/0vercl0k/windbg-scripts/tree/master/codecov)), 114 | such as the ones included in this directory. 115 | 116 | The short steps to coverage visualization in the web UI are: 117 | 118 | 1. Import coverage via bncov 119 | 2. Run Ariadne analysis or add the coverage data if analysis has been run 120 | 3. Refresh the web UI and click the "Toggle Coverage" button 121 | 122 | Visualizing coverage with Ariadne and bncov allows you to see block coverage in 123 | a per-function view in the Binary Ninja UI as well as a "nearby functions" view 124 | in the Ariadne UI. You can also view detailed coverage information for each 125 | function by click the function node in the web UI, allowing you to see how many 126 | basic blocks existed in the function and reason about how much descendent 127 | complexity is potentially left uncovered in the target function as well as the 128 | functions that are reachable via the selected function (descendent complexity). 129 | 130 | ### Detailed Coverage Walkthrough 131 | 132 | For this tutorial, we will work with input files we derived from fuzzing and the 133 | associated coverage files. If you are interested in implementing coverage 134 | collection in your own workflows, please check out the 135 | [bncov repo](https://github.com/ForAllSecure/bncov.git). 136 | 137 | The easiest way to get coverage visualization is by importing coverage 138 | data in bncov in Binary Ninja _before_ running the Ariadne analysis, but you can 139 | also add it later. Import coverage data via the context menu Plugins > bncov > 140 | Coverage Data > Import Directory. If you don't see this command, ensure that 141 | bncov is installed correctly and that there are no errors in the log. 142 | 143 | A file selector will pop up and you should select the `corpus-cov` directory 144 | within this directory (`tutorial/`). If you go to the `main` function and view 145 | the disasembly, LLIL, or MLIL, you should see instructions that are highlighted 146 | blue. If you don't see blocks colored blue, ensure you are looking at the 147 | Disassembly view and that there are no errors showing in the log. 148 | 149 | Now that you have coverage data loaded, we have to integrate it with Ariadne's 150 | analysis. If you haven't run Ariadne's analysis yet, this will happen 151 | automatically when you do start it. If you've already run analysis, go to the 152 | context menu Plugins > Ariadne > Coverage: Load bncov data. This can also be 153 | used to load new or additional coverage data later. 154 | 155 | To see the coverage data in the web UI, perform a refresh of the graph either 156 | by navigating to a different function or refreshing the page in your browser. 157 | The "Toggle Coverage" data should now be clickable and you should see that the 158 | nodes for covered functions have a green outline and are filled green to a level 159 | corresponding to the block coverage percentage for that function. Ariadne colors 160 | covered edges as green, which is conservatively determined by resolving call 161 | targets for covered callsites (Note: this may be incomplete in some cases). If 162 | the "Toggle Coverage" button still appears disabled, ensure the coverage data is 163 | integrated either by looking for "blocks covered" in the Ariadne Function Pane 164 | or with the Python console: 165 | 166 | ```python 167 | cur_target = ariadne.core.targets[bv] 168 | cur_target.coverage_available 169 | # Should return True 170 | main = next(f for f in bv.functions if f.name == 'main') 171 | cur_target.function_dict[main].metadata['blocks_covered'] 172 | # Should return 12 173 | ``` 174 | 175 | If you followed along to this point, you should see that the `main` function has 176 | partial block coverage, while the socket related functions are uncovered. If you 177 | click on the `readloop` function, you'll see that while it is fully covered, it 178 | has the telltale double-border which means it has more edges than is currently 179 | shown. In addition, the uncovered descendent complexity is very large, which 180 | suggests that there is a lot of unreached code in its descendent functions 181 | (which are not currently shown in web UI). 182 | 183 | If you then navigate to `readloop` in Binary Ninja or using clicking it and 184 | using the "Graph Focus Function" button, you'll see the 185 | graph update and show more of the context "lower" in the callgraph, and you're 186 | on the path to discovering what complex functions are partially or completely 187 | uncovered. This kind of analysis is very helpful for improving coverage for 188 | fuzzing or general testing. 189 | 190 | ## Driving the Graph 191 | 192 | Once you play with the web UI for a bit, you'll notice it's intended to 193 | supplement Binary Ninja, but that it's designed to be driven from Binary Ninja. 194 | If there is something you don't see in the graph you want to, you'll have to 195 | push a new graph, which we've tried to make easy and included several common 196 | workflows available in the plugin itself as context commands 197 | (`Plugins > Ariadne > Graph: ...`): 198 | 199 | - Source/Sink w/Current Function: By setting the current function as the source 200 | or sink, pick another function and compute the graph showing all paths to/from 201 | the current function. 202 | - All functions: Display the complete call tree for the target. _WARNING_: this 203 | may push a _VERY_ large graph which may take a long time to render and be hard 204 | to interact with, so we recommend only using it for very small binaries 205 | - Current function callgraph: The default view limits how much context is shown 206 | around the current function in order to make it easier to understand. This 207 | option expands all of the ancestors/descendents to show the full context. As 208 | with the full callgraph, this may produce a graph that is too large to be 209 | really useful, so use it with some caution. 210 | 211 | Let's walk through the source/sink analysis by navigating in Binary Ninja to the 212 | function `parse_date_string`. You'll note that the immediate neighborhood view 213 | doesn't give a great idea of context, so let's see how to get to this function 214 | from `main`. Select the source/sink plugin command, and in the dialog box that 215 | opens, set the current function as the `sink` and type `main` as the other 216 | function name and hit `OK`. You should immediately see a graph that shows how 217 | main calls `readloop`, which in turn starts to call more and more specific http 218 | handling functions. 219 | 220 | Source/sink analysis sometimes shows a linear path but can also be more complex 221 | and include many paths between related functions, which is where using the web 222 | UI really helps. Try this functionality out on some functions of 223 | your choosing (`send_basic_response` or `convert_pcnts` are easy examples). 224 | 225 | In addition, you can also permanently ban functions from being displayed on the 226 | web UI and you can freeze/unfreeze the web UI to keep the graph from changing 227 | via the following functions: 228 | 229 | - ariadne.core.target.ban_function_from_graph( Function ) 230 | - ariadne.core.target.ban_set_from_graph( Iterable[Function] ) 231 | - ariadne.core.target.unban_function( Function ) 232 | - ariadne.core.freeze_graph() 233 | - ariadne.core.unfreeze_graph() 234 | 235 | ### Custom Graphs 236 | 237 | Ariadne allows users to generate arbitrary subgraphs of the target's callgraph 238 | and push them to the web UI. The underlying graph object is a networkx DiGraph, 239 | so users can manipulate it to make arbitrary graphs and display them. For 240 | example, you can generate the 241 | N-hop neighborhood for the current function without the default limit of how 242 | many nodes are displayed by running the following commands in the Binary Ninja 243 | Python console: 244 | 245 | ```python 246 | import ariadne 247 | cur_target = ariadne.core.targets[bv] 248 | new_graph = cur_target.get_near_neighbors(current_function, 3) 249 | ariadne.core.push_new_graph(new_graph) 250 | ``` 251 | 252 | Or if there is not a direct source/sink relationship between two functions but 253 | you want to see how they are related, you can use the networkx functionality to 254 | transform the graph to an undirected graph and find the shortest path between 255 | them. For example, if we want to describe a graph that shows how `readtoken` and 256 | `send_file` are related, we could try to find the shortest path between the two 257 | functions, like this: 258 | 259 | ```python 260 | import networkx as nx 261 | readtoken = next(f for f in bv.functions if f.name == 'readtoken') 262 | send_file = next(f for f in bv.functions if f.name == 'send_file') 263 | # Undirected to find a path via in- and out-edges 264 | undirected = cur_target.get_undirected_callgraph() 265 | first_path = nx.shortest_path(undirected, readtoken, send_file) 266 | # Get the subgraph of just the nodes in the path 267 | first_path_graph = nx.subgraph(cur_target.g, first_path) 268 | ariadne.core.push_new_graph(first_path_graph) 269 | ``` 270 | 271 | The graph shows a link between the two functions via the `close` function, 272 | which isn't the kind of link we're thinking of... instead we want a more nuanced 273 | analysis, which is to find the first shared ancestor where both functions are 274 | reachable via successive out-edges (signifying calls) in the callgraph and to 275 | not include imported functions in the graph. 276 | 277 | ```python 278 | # Start with the two functions, they may not be "ancestors" themselves 279 | ancestors_union = set([readtoken, send_file]) 280 | ancestors_union |= nx.ancestors(cur_target.g, readtoken) 281 | ancestors_union |= nx.ancestors(cur_target.g, send_file) 282 | 283 | # We can use the function metadata to filter out imports 284 | # It doesn't change the final result in this example, 285 | # but any metadata could be used to filter and select nodes/edges 286 | non_imports = [f for f in cur_target.function_dict if cur_target.function_dict[f].is_import() == False] 287 | ancestors_union = ancestors_union.intersection(set(non_imports)) 288 | shared_graph = nx.subgraph(undirected, ancestors_union) 289 | 290 | shortest_path = nx.shortest_path(shared_graph, readtoken, send_file) 291 | shortest_path_graph = nx.subgraph(cur_target.g, shortest_path) 292 | ariadne.core.push_new_graph(shortest_path_graph) 293 | ``` 294 | 295 | This should give you an idea of how you can make your own graphs using the 296 | analysis of your choosing and visualize them via the web UI. 297 | 298 | ## Save/Load 299 | 300 | The time it takes to complete some of the built-in analysis for Ariadne mean 301 | that saving the analysis results to a file and and loading it will be faster 302 | than recomputing the analysis. 303 | 304 | The save and load functions are accessible via the context menu actions, and 305 | the analysis files will be saved in the `cache` directory inside the root of 306 | this repo. Relevant files are recognized by the filename of the targets, so if 307 | you have multiple targets with the same name, you'll want to rename them or 308 | otherwise work around the name collisions. If a saved analysis file is detected 309 | when analysis is requested, a prompt will confirm whether to load from file or 310 | perform a fresh analysis. 311 | 312 | If you have a commercial Binary Ninja license, you can also precompute the 313 | results for a given target via the included `headless_analysis.py` script. If 314 | you load analysis data from a separate machine, it's recommended to ensure the 315 | Binary Ninja versions match between machines. 316 | -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000001.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000001.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000006.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000006.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000015.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000015.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000016.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000016.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000018.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000018.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000033.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000033.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000059.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000059.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000091.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000091.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000095.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000095.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000140.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000140.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000166.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000166.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000186.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000186.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000233.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000233.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000307.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000307.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000309.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000309.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000312.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000312.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000360.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000360.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000366.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000366.cov -------------------------------------------------------------------------------- /tutorial/corpus-cov/id_000371.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus-cov/id_000371.cov -------------------------------------------------------------------------------- /tutorial/corpus/id_000001: -------------------------------------------------------------------------------- 1 | 80 2 | -------------------------------------------------------------------------------- /tutorial/corpus/id_000006: -------------------------------------------------------------------------------- 1 | GET /100.html HTTP/80 2 | 3 | t -------------------------------------------------------------------------------- /tutorial/corpus/id_000015: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000015 -------------------------------------------------------------------------------- /tutorial/corpus/id_000016: -------------------------------------------------------------------------------- 1 | GET l HTTP/1.1 2 | H08J 3 | :80 4 | 5 | -------------------------------------------------------------------------------- /tutorial/corpus/id_000018: -------------------------------------------------------------------------------- 1 | GET HTTP/2'80 2 | 3 | t -------------------------------------------------------------------------------- /tutorial/corpus/id_000033: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000033 -------------------------------------------------------------------------------- /tutorial/corpus/id_000059: -------------------------------------------------------------------------------- 1 | PUT -%0.html HTTP/1.1 2 | 100 3 | 4 | t -------------------------------------------------------------------------------- /tutorial/corpus/id_000091: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000091 -------------------------------------------------------------------------------- /tutorial/corpus/id_000095: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000095 -------------------------------------------------------------------------------- /tutorial/corpus/id_000140: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000140 -------------------------------------------------------------------------------- /tutorial/corpus/id_000166: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000166 -------------------------------------------------------------------------------- /tutorial/corpus/id_000186: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000186 -------------------------------------------------------------------------------- /tutorial/corpus/id_000233: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000233 -------------------------------------------------------------------------------- /tutorial/corpus/id_000307: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000307 -------------------------------------------------------------------------------- /tutorial/corpus/id_000309: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000309 -------------------------------------------------------------------------------- /tutorial/corpus/id_000312: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seeinglogic/ariadne/75726a2f690316467cbbc58183ce2e43d6c05012/tutorial/corpus/id_000312 -------------------------------------------------------------------------------- /tutorial/corpus/id_000360: -------------------------------------------------------------------------------- 1 | OPTIONS 0 HTTP/1.1 2 | HT 3 | GET:/gETP/2'10 4 | GET: .buttonRow { 105 | float: right; 106 | } 107 | 108 | #btn_container > .buttonRow > button { 109 | margin-bottom: 0.3em; 110 | } 111 | 112 | #title { 113 | position: absolute; 114 | left: 50%; 115 | transform: translate(-50%); 116 | text-align: center; 117 | padding: 0.5em; 118 | padding-bottom: 0.25em; 119 | margin-top: 0em; 120 | background-color: #353535; 121 | outline: 2px solid #909090; 122 | z-index: 998; 123 | color: #e0e0e0; 124 | font-family: Helvetica, Sans-Serif; 125 | } 126 | 127 | #status_display { 128 | position: absolute; 129 | top: 0.5em; 130 | right: 0.5em; 131 | z-index: 998; 132 | text-align: center; 133 | padding: 0.5em; 134 | padding-bottom: 0.25em; 135 | margin-top: 0em; 136 | background-color: #353535; 137 | outline: 2px solid #909090; 138 | z-index: 998; 139 | color: #e0e0e0; 140 | font-family: Helvetica, Sans-Serif; 141 | } 142 | -------------------------------------------------------------------------------- /web/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /web/css/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | /* font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; */ 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 15px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | /* 326 | th, 327 | td { 328 | padding: 12px 15px; 329 | text-align: left; 330 | border-bottom: 1px solid #E1E1E1; } 331 | th:first-child, 332 | td:first-child { 333 | padding-left: 0; } 334 | th:last-child, 335 | td:last-child { 336 | padding-right: 0; } 337 | */ 338 | 339 | 340 | /* Spacing 341 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 342 | button, 343 | .button { 344 | margin-bottom: 1rem; } 345 | input, 346 | textarea, 347 | select, 348 | fieldset { 349 | margin-bottom: 1.5rem; } 350 | pre, 351 | blockquote, 352 | dl, 353 | figure, 354 | table, 355 | p, 356 | ul, 357 | ol, 358 | form { 359 | margin-bottom: 2.5rem; } 360 | 361 | 362 | /* Utilities 363 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 364 | .u-full-width { 365 | width: 100%; 366 | box-sizing: border-box; } 367 | .u-max-full-width { 368 | max-width: 100%; 369 | box-sizing: border-box; } 370 | .u-pull-right { 371 | float: right; } 372 | .u-pull-left { 373 | float: left; } 374 | 375 | 376 | /* Misc 377 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 378 | hr { 379 | margin-top: 3rem; 380 | margin-bottom: 3.5rem; 381 | border-width: 0; 382 | border-top: 1px solid #E1E1E1; } 383 | 384 | 385 | /* Clearing 386 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 387 | 388 | /* Self Clearing Goodness */ 389 | .container:after, 390 | .row:after, 391 | .u-cf { 392 | content: ""; 393 | display: table; 394 | clear: both; } 395 | 396 | 397 | /* Media Queries 398 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 399 | /* 400 | Note: The best way to structure the use of media queries is to create the queries 401 | near the relevant code. For example, if you wanted to change the styles for buttons 402 | on small devices, paste the mobile query code up in the buttons section and style it 403 | there. 404 | */ 405 | 406 | 407 | /* Larger than mobile */ 408 | @media (min-width: 400px) {} 409 | 410 | /* Larger than phablet (also point when grid becomes active) */ 411 | @media (min-width: 550px) {} 412 | 413 | /* Larger than tablet */ 414 | @media (min-width: 750px) {} 415 | 416 | /* Larger than desktop */ 417 | @media (min-width: 1000px) {} 418 | 419 | /* Larger than Desktop HD */ 420 | @media (min-width: 1200px) {} 421 | -------------------------------------------------------------------------------- /web/dist/cytoscape-dagre.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using Terser v5.15.1. 3 | * Original file: /npm/cytoscape-dagre@2.5.0/cytoscape-dagre.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | !function(e,n){"object"==typeof exports&&"object"==typeof module?module.exports=n(require("dagre")):"function"==typeof define&&define.amd?define(["dagre"],n):"object"==typeof exports?exports.cytoscapeDagre=n(require("dagre")):e.cytoscapeDagre=n(e.dagre)}(this,(function(e){return function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}return t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="",t(t.s=0)}([function(e,n,t){var r=t(1),o=function(e){e&&e("layout","dagre",r)};"undefined"!=typeof cytoscape&&o(cytoscape),e.exports=o},function(e,n,t){function r(e){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},r(e)}var o=function(e){return"function"==typeof e},i=t(2),a=t(3),u=t(4);function c(e){this.options=a({},i,e)}c.prototype.run=function(){var e=this.options,n=e.cy,t=e.eles,i=function(e,n){return o(n)?n.apply(e,[e]):n},a=e.boundingBox||{x1:0,y1:0,w:n.width(),h:n.height()};void 0===a.x2&&(a.x2=a.x1+a.w),void 0===a.w&&(a.w=a.x2-a.x1),void 0===a.y2&&(a.y2=a.y1+a.h),void 0===a.h&&(a.h=a.y2-a.y1);var c=new u.graphlib.Graph({multigraph:!0,compound:!0}),d={},f=function(e,n){null!=n&&(d[e]=n)};f("nodesep",e.nodeSep),f("edgesep",e.edgeSep),f("ranksep",e.rankSep),f("rankdir",e.rankDir),f("align",e.align),f("ranker",e.ranker),f("acyclicer",e.acyclicer),c.setGraph(d),c.setDefaultEdgeLabel((function(){return{}})),c.setDefaultNodeLabel((function(){return{}}));var s=t.nodes();o(e.sort)&&(s=s.sort(e.sort));for(var y=0;y1?n-1:0),r=1;r 'de.cau.cs.kieler.klay.layered.edgeNodeSpacingFactor': options.edgeNodeSpacingFactor, 104 | 'edgeRouting': 'de.cau.cs.kieler.edgeRouting', 105 | 'edgeSpacingFactor': 'de.cau.cs.kieler.klay.layered.edgeSpacingFactor', 106 | 'feedbackEdges': 'de.cau.cs.kieler.klay.layered.feedBackEdges', 107 | 'fixedAlignment': 'de.cau.cs.kieler.klay.layered.fixedAlignment', 108 | 'greedySwitchCrossingMinimization': 'de.cau.cs.kieler.klay.layered.greedySwitch', 109 | 'hierarchyHandling': 'de.cau.cs.kieler.hierarchyHandling', 110 | 'inLayerSpacingFactor': 'de.cau.cs.kieler.klay.layered.inLayerSpacingFactor', 111 | 'interactiveReferencePoint': 'de.cau.cs.kieler.klay.layered.interactiveReferencePoint', 112 | 'layerConstraint': 'de.cau.cs.kieler.klay.layered.layerConstraint', 113 | 'layoutHierarchy': 'de.cau.cs.kieler.layoutHierarchy', 114 | 'linearSegmentsDeflectionDampening': 'de.cau.cs.kieler.klay.layered.linearSegmentsDeflectionDampening', 115 | 'mergeEdges': 'de.cau.cs.kieler.klay.layered.mergeEdges', 116 | 'mergeHierarchyCrossingEdges': 'de.cau.cs.kieler.klay.layered.mergeHierarchyEdges', 117 | 'noLayout': 'de.cau.cs.kieler.noLayout', 118 | 'nodeLabelPlacement': 'de.cau.cs.kieler.nodeLabelPlacement', 119 | 'nodeLayering': 'de.cau.cs.kieler.klay.layered.nodeLayering', 120 | 'nodePlacement': 'de.cau.cs.kieler.klay.layered.nodePlace', 121 | 'portAlignment': 'de.cau.cs.kieler.portAlignment', 122 | 'portAlignmentEastern': 'de.cau.cs.kieler.portAlignment.east', 123 | 'portAlignmentNorth': 'de.cau.cs.kieler.portAlignment.north', 124 | 'portAlignmentSouth': 'de.cau.cs.kieler.portAlignment.south', 125 | 'portAlignmentWest': 'de.cau.cs.kieler.portAlignment.west', 126 | 'portConstraints': 'de.cau.cs.kieler.portConstraints', 127 | 'portLabelPlacement': 'de.cau.cs.kieler.portLabelPlacement', 128 | 'portOffset': 'de.cau.cs.kieler.offset', 129 | 'portSide': 'de.cau.cs.kieler.portSide', 130 | 'portSpacing': 'de.cau.cs.kieler.portSpacing', 131 | 'postCompaction': 'de.cau.cs.kieler.klay.layered.postCompaction', 132 | 'priority': 'de.cau.cs.kieler.priority', 133 | 'randomizationSeed': 'de.cau.cs.kieler.randomSeed', 134 | 'routeSelfLoopInside': 'de.cau.cs.kieler.selfLoopInside', 135 | 'separateConnectedComponents': 'de.cau.cs.kieler.separateConnComp', 136 | 'sizeConstraint': 'de.cau.cs.kieler.sizeConstraint', 137 | 'sizeOptions': 'de.cau.cs.kieler.sizeOptions', 138 | 'spacing': 'de.cau.cs.kieler.spacing', 139 | 'splineSelfLoopPlacement': 'de.cau.cs.kieler.klay.layered.splines.selfLoopPlacement', 140 | 'thoroughness': 'de.cau.cs.kieler.klay.layered.thoroughness', 141 | 'wideNodesOnMultipleLayers': 'de.cau.cs.kieler.klay.layered.wideNodesOnMultipleLayers' 142 | }; 143 | 144 | var mapToKlayNS = function mapToKlayNS(klayOpts) { 145 | var keys = Object.keys(klayOpts); 146 | var ret = {}; 147 | 148 | for (var i = 0; i < keys.length; i++) { 149 | var key = keys[i]; 150 | var nsKey = klayNSLookup[key]; 151 | var val = klayOpts[key]; 152 | 153 | ret[nsKey] = val; 154 | } 155 | 156 | return ret; 157 | }; 158 | 159 | var klayOverrides = { 160 | interactiveReferencePoint: 'CENTER' // Determines which point of a node is considered by interactive layout phases. 161 | }; 162 | 163 | var getPos = function getPos(ele) { 164 | var parent = ele.parent(); 165 | var k = ele.scratch('klay'); 166 | var p = { 167 | x: k.x, 168 | y: k.y 169 | }; 170 | 171 | while (parent.nonempty()) { 172 | var kp = parent.scratch('klay'); 173 | p.x += kp.x; 174 | p.y += kp.y; 175 | parent = parent.parent(); 176 | } 177 | 178 | return p; 179 | }; 180 | 181 | var makeNode = function makeNode(node, options) { 182 | var dims = node.layoutDimensions(options); 183 | var padding = node.numericStyle('padding'); 184 | 185 | var k = { 186 | _cyEle: node, 187 | id: node.id(), 188 | padding: { 189 | top: padding, 190 | left: padding, 191 | bottom: padding, 192 | right: padding 193 | } 194 | }; 195 | 196 | if (!node.isParent()) { 197 | k.width = dims.w; 198 | k.height = dims.h; 199 | } 200 | 201 | node.scratch('klay', k); 202 | 203 | return k; 204 | }; 205 | 206 | var makeEdge = function makeEdge(edge, options) { 207 | var k = { 208 | _cyEle: edge, 209 | id: edge.id(), 210 | source: edge.data('source'), 211 | target: edge.data('target'), 212 | properties: {} 213 | }; 214 | 215 | var priority = options.priority(edge); 216 | 217 | if (priority != null) { 218 | k.properties.priority = priority; 219 | } 220 | 221 | edge.scratch('klay', k); 222 | 223 | return k; 224 | }; 225 | 226 | var makeGraph = function makeGraph(nodes, edges, options) { 227 | var klayNodes = []; 228 | var klayEdges = []; 229 | var klayEleLookup = {}; 230 | var graph = { 231 | id: 'root', 232 | children: [], 233 | edges: [] 234 | }; 235 | 236 | // map all nodes 237 | for (var i = 0; i < nodes.length; i++) { 238 | var n = nodes[i]; 239 | var k = makeNode(n, options); 240 | 241 | klayNodes.push(k); 242 | 243 | klayEleLookup[n.id()] = k; 244 | } 245 | 246 | // map all edges 247 | for (var _i = 0; _i < edges.length; _i++) { 248 | var e = edges[_i]; 249 | var _k = makeEdge(e, options); 250 | 251 | klayEdges.push(_k); 252 | 253 | klayEleLookup[e.id()] = _k; 254 | } 255 | 256 | // make hierarchy 257 | for (var _i2 = 0; _i2 < klayNodes.length; _i2++) { 258 | var _k2 = klayNodes[_i2]; 259 | var _n = _k2._cyEle; 260 | 261 | if (!_n.isChild()) { 262 | graph.children.push(_k2); 263 | } else { 264 | var parent = _n.parent(); 265 | var parentK = klayEleLookup[parent.id()]; 266 | 267 | var children = parentK.children = parentK.children || []; 268 | 269 | children.push(_k2); 270 | } 271 | } 272 | 273 | for (var _i3 = 0; _i3 < klayEdges.length; _i3++) { 274 | var _k3 = klayEdges[_i3]; 275 | var _e = _k3._cyEle; 276 | var parentSrc = _e.source().parent(); 277 | var parentTgt = _e.target().parent(); 278 | 279 | // put all edges in the top level for now 280 | // NOTE does this cause issues in certain edgecases? 281 | if (false) { 282 | var kp = klayEleLookup[parentSrc.id()]; 283 | 284 | kp.edges = kp.edges || []; 285 | 286 | kp.edges.push(_k3); 287 | } else { 288 | graph.edges.push(_k3); 289 | } 290 | } 291 | 292 | return graph; 293 | }; 294 | 295 | function Layout(options) { 296 | var klayOptions = options.klay; 297 | 298 | this.options = assign({}, defaults, options); 299 | 300 | this.options.klay = assign({}, defaults.klay, klayOptions, klayOverrides); 301 | } 302 | 303 | Layout.prototype.run = function () { 304 | var layout = this; 305 | var options = this.options; 306 | 307 | var eles = options.eles; 308 | var nodes = eles.nodes(); 309 | var edges = eles.edges(); 310 | 311 | var graph = makeGraph(nodes, edges, options); 312 | 313 | klay.layout({ 314 | graph: graph, 315 | options: mapToKlayNS(options.klay), 316 | success: function success() {}, 317 | error: function error(_error) { 318 | throw _error; 319 | } 320 | }); 321 | 322 | nodes.filter(function (n) { 323 | return !n.isParent(); 324 | }).layoutPositions(layout, options, getPos); 325 | 326 | return this; 327 | }; 328 | 329 | Layout.prototype.stop = function () { 330 | return this; // chaining 331 | }; 332 | 333 | Layout.prototype.destroy = function () { 334 | return this; // chaining 335 | }; 336 | 337 | module.exports = Layout; 338 | 339 | /***/ }), 340 | /* 1 */ 341 | /***/ (function(module, exports, __webpack_require__) { 342 | 343 | "use strict"; 344 | 345 | 346 | // Simple, internal Object.assign() polyfill for options objects etc. 347 | 348 | module.exports = Object.assign != null ? Object.assign.bind(Object) : function (tgt) { 349 | for (var _len = arguments.length, srcs = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 350 | srcs[_key - 1] = arguments[_key]; 351 | } 352 | 353 | srcs.filter(function (src) { 354 | return src != null; 355 | }).forEach(function (src) { 356 | Object.keys(src).forEach(function (k) { 357 | return tgt[k] = src[k]; 358 | }); 359 | }); 360 | 361 | return tgt; 362 | }; 363 | 364 | /***/ }), 365 | /* 2 */ 366 | /***/ (function(module, exports, __webpack_require__) { 367 | 368 | "use strict"; 369 | 370 | 371 | var defaults = { 372 | nodeDimensionsIncludeLabels: false, // Boolean which changes whether label dimensions are included when calculating node dimensions 373 | fit: true, // Whether to fit 374 | padding: 20, // Padding on fit 375 | animate: false, // Whether to transition the node positions 376 | animateFilter: function animateFilter(node, i) { 377 | return true; 378 | }, // Whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions 379 | animationDuration: 500, // Duration of animation in ms if enabled 380 | animationEasing: undefined, // Easing of animation if enabled 381 | transform: function transform(node, pos) { 382 | return pos; 383 | }, // A function that applies a transform to the final node position 384 | ready: undefined, // Callback on layoutready 385 | stop: undefined, // Callback on layoutstop 386 | klay: { 387 | // Following descriptions taken from http://layout.rtsys.informatik.uni-kiel.de:9444/Providedlayout.html?algorithm=de.cau.cs.kieler.klay.layered 388 | addUnnecessaryBendpoints: false, // Adds bend points even if an edge does not change direction. 389 | aspectRatio: 1.6, // The aimed aspect ratio of the drawing, that is the quotient of width by height 390 | borderSpacing: 20, // Minimal amount of space to be left to the border 391 | compactComponents: false, // Tries to further compact components (disconnected sub-graphs). 392 | crossingMinimization: 'LAYER_SWEEP', // Strategy for crossing minimization. 393 | /* LAYER_SWEEP The layer sweep algorithm iterates multiple times over the layers, trying to find node orderings that minimize the number of crossings. The algorithm uses randomization to increase the odds of finding a good result. To improve its results, consider increasing the Thoroughness option, which influences the number of iterations done. The Randomization seed also influences results. 394 | INTERACTIVE Orders the nodes of each layer by comparing their positions before the layout algorithm was started. The idea is that the relative order of nodes as it was before layout was applied is not changed. This of course requires valid positions for all nodes to have been set on the input graph before calling the layout algorithm. The interactive layer sweep algorithm uses the Interactive Reference Point option to determine which reference point of nodes are used to compare positions. */ 395 | cycleBreaking: 'GREEDY', // Strategy for cycle breaking. Cycle breaking looks for cycles in the graph and determines which edges to reverse to break the cycles. Reversed edges will end up pointing to the opposite direction of regular edges (that is, reversed edges will point left if edges usually point right). 396 | /* GREEDY This algorithm reverses edges greedily. The algorithm tries to avoid edges that have the Priority property set. 397 | INTERACTIVE The interactive algorithm tries to reverse edges that already pointed leftwards in the input graph. This requires node and port coordinates to have been set to sensible values.*/ 398 | direction: 'UNDEFINED', // Overall direction of edges: horizontal (right / left) or vertical (down / up) 399 | /* UNDEFINED, RIGHT, LEFT, DOWN, UP */ 400 | edgeRouting: 'ORTHOGONAL', // Defines how edges are routed (POLYLINE, ORTHOGONAL, SPLINES) 401 | edgeSpacingFactor: 0.5, // Factor by which the object spacing is multiplied to arrive at the minimal spacing between edges. 402 | feedbackEdges: false, // Whether feedback edges should be highlighted by routing around the nodes. 403 | fixedAlignment: 'NONE', // Tells the BK node placer to use a certain alignment instead of taking the optimal result. This option should usually be left alone. 404 | /* NONE Chooses the smallest layout from the four possible candidates. 405 | LEFTUP Chooses the left-up candidate from the four possible candidates. 406 | RIGHTUP Chooses the right-up candidate from the four possible candidates. 407 | LEFTDOWN Chooses the left-down candidate from the four possible candidates. 408 | RIGHTDOWN Chooses the right-down candidate from the four possible candidates. 409 | BALANCED Creates a balanced layout from the four possible candidates. */ 410 | inLayerSpacingFactor: 1.0, // Factor by which the usual spacing is multiplied to determine the in-layer spacing between objects. 411 | layoutHierarchy: false, // Whether the selected layouter should consider the full hierarchy 412 | linearSegmentsDeflectionDampening: 0.3, // Dampens the movement of nodes to keep the diagram from getting too large. 413 | mergeEdges: false, // Edges that have no ports are merged so they touch the connected nodes at the same points. 414 | mergeHierarchyCrossingEdges: true, // If hierarchical layout is active, hierarchy-crossing edges use as few hierarchical ports as possible. 415 | nodeLayering: 'NETWORK_SIMPLEX', // Strategy for node layering. 416 | /* NETWORK_SIMPLEX This algorithm tries to minimize the length of edges. This is the most computationally intensive algorithm. The number of iterations after which it aborts if it hasn't found a result yet can be set with the Maximal Iterations option. 417 | LONGEST_PATH A very simple algorithm that distributes nodes along their longest path to a sink node. 418 | INTERACTIVE Distributes the nodes into layers by comparing their positions before the layout algorithm was started. The idea is that the relative horizontal order of nodes as it was before layout was applied is not changed. This of course requires valid positions for all nodes to have been set on the input graph before calling the layout algorithm. The interactive node layering algorithm uses the Interactive Reference Point option to determine which reference point of nodes are used to compare positions. */ 419 | nodePlacement: 'BRANDES_KOEPF', // Strategy for Node Placement 420 | /* BRANDES_KOEPF Minimizes the number of edge bends at the expense of diagram size: diagrams drawn with this algorithm are usually higher than diagrams drawn with other algorithms. 421 | LINEAR_SEGMENTS Computes a balanced placement. 422 | INTERACTIVE Tries to keep the preset y coordinates of nodes from the original layout. For dummy nodes, a guess is made to infer their coordinates. Requires the other interactive phase implementations to have run as well. 423 | SIMPLE Minimizes the area at the expense of... well, pretty much everything else. */ 424 | randomizationSeed: 1, // Seed used for pseudo-random number generators to control the layout algorithm; 0 means a new seed is generated 425 | routeSelfLoopInside: false, // Whether a self-loop is routed around or inside its node. 426 | separateConnectedComponents: true, // Whether each connected component should be processed separately 427 | spacing: 20, // Overall setting for the minimal amount of space to be left between objects 428 | thoroughness: 7 // How much effort should be spent to produce a nice layout.. 429 | }, 430 | priority: function priority(edge) { 431 | return null; 432 | } // Edges with a non-nil value are skipped when geedy edge cycle breaking is enabled 433 | }; 434 | 435 | module.exports = defaults; 436 | 437 | /***/ }), 438 | /* 3 */ 439 | /***/ (function(module, exports, __webpack_require__) { 440 | 441 | "use strict"; 442 | 443 | 444 | var impl = __webpack_require__(0); 445 | 446 | // registers the extension on a cytoscape lib ref 447 | var register = function register(cytoscape) { 448 | if (!cytoscape) { 449 | return; 450 | } // can't register if cytoscape unspecified 451 | 452 | cytoscape('layout', 'klay', impl); // register with cytoscape.js 453 | }; 454 | 455 | if (typeof cytoscape !== 'undefined') { 456 | // expose to global cytoscape (i.e. window.cytoscape) 457 | register(cytoscape); 458 | } 459 | 460 | module.exports = register; 461 | 462 | /***/ }), 463 | /* 4 */ 464 | /***/ (function(module, exports) { 465 | 466 | module.exports = __WEBPACK_EXTERNAL_MODULE_4__; 467 | 468 | /***/ }) 469 | /******/ ]); 470 | }); -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ariadne 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
Awaiting graph...
15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 |
Initializing...
24 | 25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 55 | 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /web/main.js: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // Main JS 4 | // 5 | // 6 | 7 | // 8 | // globals 9 | // 10 | var json_contents; 11 | var cur_bv; 12 | var container; 13 | var websock; 14 | var cy; 15 | var focusedNode; // The node currently clicked on 16 | var sidebarExpanded = false; 17 | var hiddenImportNodes; // For restoring removed imports 18 | var hiddenImportEdges; 19 | var removedNodes = null; // Keeping track of user-removed nodes 20 | var coverageAvailable = false; 21 | var coverageStylingOn = false; 22 | 23 | // 24 | // constants 25 | // 26 | var server_addr = "127.0.0.1"; 27 | var server_port = 7890; 28 | var websock_url = `ws://${server_addr}:${server_port}/`; 29 | log_status("Websocket url: " + websock_url); 30 | 31 | // colors 32 | var blue = '#80C6E7'; 33 | var dark_blue = '#5180c2'; 34 | var darker_blue = '#2130a2'; 35 | var light_green = '#A2D9AF'; 36 | var green = '#368448'; 37 | var dark_green = '#1b4325'; 38 | var orange = '#EDBD80'; 39 | var light_grey = '#909090'; 40 | var subtle_grey = '#707070'; 41 | var darker_subtle_grey = '#606060'; 42 | var grey = '#4a4a4a'; 43 | var dark_grey = '#2a2a2a'; 44 | var white = '#e0e0e0'; 45 | var yellow = '#eddfb3'; 46 | var bright_purple = '#cc00af'; 47 | var bright_yellow = '#d0c766'; 48 | var red = '#de8f97'; 49 | var bright_red = '#fb4141'; 50 | // variable colors referenced in styling 51 | var selected_color = bright_purple; 52 | var to_color = red; 53 | var from_color = blue; 54 | // want coverage color to look recognizably different 55 | var dark_green_alt = '#008631'; 56 | var green_alt = '#1fd655'; 57 | var covered_color = green_alt; 58 | var covered_border_color = dark_green_alt; 59 | 60 | // sizing 61 | var complexity_tiny = 5; 62 | var complexity_small = 10; 63 | var complexity_medium = 20; 64 | var complexity_large = 100; 65 | //var complexity_jumbo = 101; // just compare > large 66 | var size_tiny = 20; 67 | var size_small = 30; 68 | var size_medium = 45; 69 | var size_large = 60; 70 | var size_jumbo = 80; 71 | 72 | // layout options 73 | // more options at: https://github.com/cytoscape/cytoscape.js-klay 74 | var klay_layout = { 75 | name: 'klay', 76 | nodeDimensionsIncludeLabels: true, 77 | klay: { 78 | //'direction': 'DOWN' 79 | //'direction': 'RIGHT' 80 | }, 81 | padding: 50, // padding pixels around edge to avoid colliding with controls 82 | } 83 | var dagre_layout = { 84 | name: 'dagre', 85 | nodeDimensionsIncludeLabels: true, 86 | rankDir: 'LR', 87 | padding: 100, // extra padding for LR 88 | } 89 | var ud_dagre_layout = { 90 | name: 'dagre', 91 | nodeDimensionsIncludeLabels: true, 92 | rankDir: 'UD', 93 | padding: 50, 94 | } 95 | 96 | //var default_layout = klay_layout; 97 | var default_layout = dagre_layout; 98 | 99 | 100 | 101 | // 102 | // utility functions 103 | // 104 | 105 | // In case we need to avoid console.log 106 | function log_status(message) { 107 | console.log(message); 108 | /* 109 | var status_elem = document.getElementById("status"); 110 | if (status_elem) { 111 | let current_status = status_elem.innerHTML; 112 | current_status += message; 113 | status_elem.innerHTML = current_status; 114 | } 115 | */ 116 | } 117 | function log_collection(c) { 118 | console.log(`${c.edges().length} edges, ${c.nodes().length} nodes:`) 119 | for (let e of c) { 120 | console.log(` ${e.group()}: ${e.data().id} (label: ${e.data().label})`); 121 | } 122 | } 123 | 124 | function update_status() { 125 | var status_element_name = "status_display" 126 | status_elem = document.getElementById(status_element_name); 127 | if (status_elem) { 128 | var status = 'Inactive'; 129 | var cur_color = light_grey; 130 | 131 | if (typeof(websock) == 'object') { 132 | if (websock.readyState == 0) { 133 | status = 'Connecting...'; 134 | } 135 | else if (websock.readyState == 1) { 136 | status = 'Connected'; 137 | cur_color = white; 138 | } 139 | else if (websock.readyState == 2) { 140 | status = 'Closing'; 141 | } 142 | else if (websock.readyState == 3) { 143 | status = 'Closed'; 144 | cur_color = red; 145 | } 146 | } 147 | 148 | status_elem.innerHTML = `Websocket ${status}`; 149 | status_elem.style.color = cur_color; 150 | 151 | } 152 | else { 153 | console.log(`WARNING: update_status(): getElementById("${status_element_name}") failed`); 154 | } 155 | } 156 | 157 | // 158 | // onload callback (entrypoint); opens websocket 159 | // 160 | function js_init() { 161 | container = document.getElementById("graph_container"); 162 | document.getElementById("remove_node_btn").addEventListener("click", handleRemoveNode); 163 | document.getElementById("remove_descendents_btn").addEventListener("click", handleRemoveNodeAndDescendents); 164 | document.getElementById("reset_graph_btn").addEventListener("click", handleResetGraph); 165 | document.getElementById("hide_imports_btn").addEventListener("click", handleToggleImports); 166 | document.getElementById("toggle_coverage_btn").addEventListener("click", handleToggleCoverage); 167 | document.getElementById("redo_layout_btn").addEventListener("click", handleRedoLayout); 168 | document.getElementById("get_new_neighborhood").addEventListener("click", handleGetNewNeighborhood); 169 | 170 | document.getElementById("func_search_input").addEventListener("input", handleSearchInputChange); 171 | 172 | // Just poll websocket status in background 173 | setInterval(update_status, 1000); 174 | 175 | websock = new WebSocket(websock_url); 176 | 177 | 178 | websock.onmessage = function(event) { 179 | log_status(' Receiving websocket message.'); 180 | var title_elem = document.getElementById("title"); 181 | title_elem.innerHTML = "Loading graph..."; 182 | 183 | let parse_start_time = performance.now() 184 | var model = JSON.parse(event.data); 185 | 186 | //log_status(`DBG: JSON model: ${JSON.stringify(model)}`); 187 | if (model.elements) { 188 | log_status(`JSON model has ${model.elements.length} elements`); 189 | } 190 | 191 | let parse_duration = performance.now() - parse_start_time; 192 | json_contents = model; 193 | cur_bv = json_contents['bv']; 194 | log_status(`JSON parsed in ${parse_duration} milliseconds`); 195 | 196 | let render_start_time = performance.now() 197 | renderCytoscape(model); 198 | 199 | let render_duration = performance.now() - render_start_time; 200 | log_status(`Render function completed in ${render_duration} milliseconds`); 201 | 202 | if (model.title) { 203 | if (title_elem) { 204 | title_elem.innerHTML = model.title; 205 | } 206 | } 207 | }; 208 | 209 | websock.onclose = function(event) { 210 | if (event.wasClean) { 211 | log_status(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); 212 | } else { 213 | log_status(`[close] Connection died, reason: ${event.reason}`); 214 | } 215 | }; 216 | 217 | websock.onerror = function(error) { 218 | log_status(`[error] ${error.message}`); 219 | }; 220 | 221 | $(window).on('beforeunload', function(){ 222 | websock.close(); 223 | }); 224 | 225 | log_status(" js_init() finished.") 226 | } 227 | 228 | // 229 | // event handling 230 | // 231 | function handleNodeClick( event ) { 232 | 233 | let clickedNode = event.target; 234 | 235 | // if there was a focus node, we either change focus or deselect it 236 | if (focusedNode) { 237 | removeFocus(); 238 | } 239 | // selecting focus node deselects it 240 | if (clickedNode == focusedNode) { 241 | focusedNode = null; 242 | hideSidebarMetadata(); 243 | sidebarHeaderClickable(false); 244 | } 245 | else { // selecting other node changes focus 246 | focusedNode = clickedNode; 247 | addFocus(clickedNode); 248 | showSidebarMetadata(); 249 | sidebarHeaderClickable(true); 250 | } 251 | } 252 | 253 | function addFocus(focusNode) { 254 | focusNode.addClass('focused'); 255 | 256 | let neighborhood = cy.collection().union(focusNode); 257 | 258 | let in_edges = focusNode.incomers().addClass('from'); 259 | let in_nodes = in_edges.sources().addClass('from'); 260 | neighborhood = neighborhood.union(in_edges).union(in_nodes); 261 | 262 | let out_edges = focusNode.outgoers().addClass('to'); 263 | let out_nodes = out_edges.targets().addClass('to'); 264 | neighborhood = neighborhood.union(out_edges).union(out_nodes); 265 | 266 | cy.elements().difference(neighborhood).addClass('background'); 267 | } 268 | 269 | function removeFocus() { 270 | cy.elements().removeClass('focused') 271 | .removeClass('to') 272 | .removeClass('from') 273 | .removeClass('background'); 274 | } 275 | 276 | function hideSidebarMetadata() { 277 | if (focusedNode == null) { 278 | document.getElementById("sidebar_title").innerHTML = "No function selected"; 279 | } else { 280 | focusedFuncName = focusedNode.data().label; 281 | let sidebarLabel = `Info for "${focusedFuncName}" hidden`; 282 | document.getElementById("sidebar_title").innerHTML = sidebarLabel; 283 | } 284 | 285 | document.getElementById("sidebar_title").style.fontWeight = ""; 286 | // just hide the table, we'll delete rows when we show it again 287 | document.getElementById("sidebar_table").style.display = "none"; 288 | 289 | sidebarExpanded = false; 290 | } 291 | 292 | function showSidebarMetadata() { 293 | // deepcopy the data via spread operator 294 | let function_metadata = {...focusedNode.data()}; 295 | 296 | document.getElementById("sidebar_title").innerHTML = function_metadata.label; 297 | document.getElementById("sidebar_title").style.fontWeight = "bold"; 298 | document.getElementById("sidebar_table").style.display = "block"; 299 | 300 | // delete any existing rows in table 301 | let table_body = document.getElementById("sidebar_table_body"); 302 | while (table_body.rows.length) { 303 | table_body.deleteRow(0); 304 | } 305 | 306 | // remove metadata we don't want in the table 307 | delete function_metadata.label; 308 | delete function_metadata.id; 309 | delete function_metadata.current_function; 310 | delete function_metadata.visited; 311 | delete function_metadata.import; 312 | delete function_metadata.global_refs; 313 | delete function_metadata.start; 314 | 315 | for (const kv of Object.entries(function_metadata)) { 316 | let key_name = kv[0] 317 | let val = kv[1] 318 | 319 | if (val === null) { 320 | continue; 321 | } 322 | 323 | row = table_body.insertRow(); 324 | row.insertCell().innerHTML = key_name; 325 | 326 | // put spaces in between comma-separated lists 327 | let value_str = val.toString(); 328 | if (value_str.match(',[^ ]')) { 329 | value_str = val.join(', ') 330 | } 331 | // format floats to only show a few decimal points of precision 332 | else if (typeof(val) == 'number' && !Number.isInteger(val)) { 333 | value_str = val.toFixed(4); 334 | } 335 | 336 | row.insertCell().innerHTML = value_str; 337 | } 338 | 339 | sidebarExpanded = true; 340 | } 341 | 342 | function toggleSidebarExpansion(event) { 343 | let metadata_header = document.getElementById("sidebar_header"); 344 | 345 | if (sidebarExpanded == true) { 346 | hideSidebarMetadata(); 347 | metadata_header.innerHTML = 'METADATA SIDEBAR [+]'; 348 | } else { 349 | showSidebarMetadata(); 350 | metadata_header.innerHTML = 'METADATA SIDEBAR [-]'; 351 | } 352 | } 353 | 354 | function sidebarHeaderClickable(clickable) { 355 | let metadata_header = document.getElementById("sidebar_header"); 356 | if (clickable) { 357 | metadata_header.addEventListener('click', toggleSidebarExpansion); 358 | metadata_header.innerHTML = 'METADATA SIDEBAR [-]'; 359 | } else { 360 | metadata_header.removeEventListener('click', toggleSidebarExpansion); 361 | metadata_header.innerHTML = 'METADATA SIDEBAR'; 362 | } 363 | } 364 | 365 | function handleRemoveNode( event ) { 366 | if (focusedNode) { 367 | removeNode(focusedNode); 368 | } 369 | } 370 | 371 | function removeNode(node_to_remove) { 372 | if (focusedNode == node_to_remove) { 373 | removeFocus(); 374 | focusedNode = null; 375 | } 376 | 377 | // if we're removing the focus node, we don't want to see the sidebar 378 | if (sidebarExpanded == true) { 379 | toggleSidebarExpansion({}); 380 | } 381 | 382 | if (removedNodes == null) { 383 | removedNodes = cy.collection(); 384 | } 385 | removedNodes = removedNodes.union(node_to_remove); 386 | 387 | return cy.remove(node_to_remove); 388 | } 389 | 390 | function restoreNode(node_to_restore) { 391 | if (removedNodes != null) { 392 | removedNodes.subtract(node_to_restore); 393 | } 394 | cy.add(node_to_restore); 395 | } 396 | 397 | function handleRemoveNodeAndDescendents( event ) { 398 | 399 | if (focusedNode) { 400 | 401 | let successors = focusedNode.successors().nodes(); 402 | 403 | // temporarily remove focusedNode to enable the predecessor check below 404 | let originalFocusedNode = removeNode(focusedNode); 405 | 406 | function inSuccessors(node) { 407 | return successors.contains(node); 408 | } 409 | 410 | // strict descendents only: 411 | // only remove descendents for whom all predecessors are ALSO in the 412 | // successor group. 413 | // This keeps nodes that have incoming edges from nodes that are not 414 | // successors of the focusedNode 415 | for (let curNode of successors) { 416 | if (curNode.predecessors().nodes().every(inSuccessors)) { 417 | removeNode(curNode); 418 | } 419 | } 420 | 421 | // restore focusedNode so the net effect is only removing descendents 422 | //restoreNode(originalFocusedNode); 423 | } 424 | } 425 | 426 | function handleRemoveAncestors( event ) { 427 | 428 | if (focusedNode) { 429 | 430 | let predecessors = focusedNode.predecessors().nodes(); 431 | 432 | // temporarily remove focusedNode to enable the predecessor check below 433 | let originalFocusedNode = removeNode(focusedNode); 434 | 435 | function inPredecessors(node) { 436 | return predecessors.contains(node); 437 | } 438 | 439 | // strict ancestors only: 440 | // only remove ancestors for whom all successors are ALSO in the 441 | // predecessor group. 442 | // This keeps nodes that have outgoing edges to nodes that are not 443 | // predecessors of the focusedNode 444 | for (let curNode of predecessors) { 445 | if (curNode.predecessors().nodes().every(inPredecessors)) { 446 | // FUTURE: check if any successors of this node are now isolated? 447 | removeNode(curNode); 448 | } 449 | } 450 | 451 | // restore focusedNode so the net effect is only removing ancestors 452 | restoreNode(originalFocusedNode); 453 | } 454 | } 455 | 456 | function handleToggleImports( event ) { 457 | let hideImportsButton = document.getElementById("hide_imports_btn"); 458 | 459 | if (hiddenImportNodes) { 460 | 461 | cy.add(hiddenImportNodes); 462 | 463 | // we might be attempting to add edges to nodes that are gone 464 | // so add one at a time and catch exceptions instead of cy.add() 465 | // FUTURE: find a better way to filter edges to nodes that are gone 466 | for (let curEdge of hiddenImportEdges) { 467 | try { 468 | cy.add(curEdge); 469 | } catch (error) { 470 | if (error.message.match("Can not create edge `.*` with nonexistant") == null) { 471 | console.log('Unexpected error'); 472 | console.dir(error); 473 | } 474 | } 475 | } 476 | 477 | hiddenImportNodes = null; 478 | hiddenImportEdges = null; 479 | 480 | // redo focus styling to include newly-added imports 481 | removeFocus(); 482 | if (focusedNode) { 483 | addFocus(focusedNode); 484 | } 485 | 486 | hideImportsButton.innerHTML = "Hide Imports"; 487 | hideImportsButton.classList.remove('pressed'); 488 | } 489 | else { 490 | hiddenImportNodes = cy.$('node[import = 1]'); 491 | hiddenImportEdges = hiddenImportNodes.connectedEdges(); 492 | 493 | if (hiddenImportNodes.contains(focusedNode)) { 494 | if (sidebarExpanded) { 495 | toggleSidebarExpansion({}); 496 | } 497 | focusedNode = null; 498 | removeFocus(); 499 | } 500 | 501 | cy.remove(hiddenImportNodes); // removes edges too 502 | 503 | hideImportsButton.innerHTML = "Show Imports"; 504 | hideImportsButton.classList.add('pressed'); 505 | } 506 | } 507 | 508 | function handleRedoLayout( event ) { 509 | cy.layout(default_layout).run(); 510 | } 511 | 512 | function handleGetNewNeighborhood( event ) { 513 | 514 | if (focusedNode == null) { 515 | return; 516 | } 517 | let target_function = { 518 | 'start': focusedNode.data()['start'], 519 | 'bv': cur_bv, 520 | } 521 | let target_function_json = JSON.stringify(target_function); 522 | websock.send(target_function_json); 523 | 524 | } 525 | 526 | function handleFuncSearch( event ) { 527 | let search_input = document.getElementById("func_search_input"); 528 | let search_text = search_input.value; 529 | //console.log(`Function search was triggered: ${search_text}`); 530 | 531 | // Test exact matches and then try prefix matching 532 | let exact_matches = cy.nodes(`[label = "${search_text}"]`); 533 | // Do nothing on more than one match or none 534 | if (exact_matches.length == 1) { 535 | cy.center(exact_matches); 536 | return; 537 | } 538 | 539 | let prefix_matches = cy.filter( function(element, i) { 540 | return element.isNode() && element.data('label').startsWith(search_text); 541 | }); 542 | // NOTE: the text color will indicate whether this was a good idea 543 | if (prefix_matches.length == 1) { 544 | cy.center(prefix_matches); 545 | } 546 | } 547 | 548 | function handleSearchInputChange( event ) { 549 | let input_element = event.target; 550 | let search_text = event.target.value 551 | //console.log(`Search input change: "${search_text}"`) 552 | 553 | if (search_text.length >= 3) { 554 | let exact_match = cy.nodes(`[label = "${search_text}"]`); 555 | if (exact_match.length > 0) { 556 | input_element.style.color = "green"; 557 | } 558 | // shouldn't be collisions on label 559 | if (exact_match.length > 1) { 560 | input_element.style.color = "purple"; 561 | console.log(`WARNING: Multiple (${exact_match.length}) exact matches for "${search_text}"!?`); 562 | return; 563 | } 564 | 565 | let prefix_matches = cy.filter( function(element, i) { 566 | return element.isNode() && element.data('label').startsWith(search_text); 567 | }); 568 | if (prefix_matches.length == 1) { 569 | input_element.style.color = "green"; 570 | } 571 | else if (prefix_matches.length == 0) { 572 | input_element.style.color = "red"; 573 | } 574 | else { // prefix_matches.length > 1 575 | input_element.style.color = "orange"; 576 | } 577 | } 578 | // else text isn't long enought to start color indication (search still works though) 579 | else { 580 | input_element.style.color = "black"; 581 | } 582 | } 583 | 584 | function getCoverageGradient( ele ) { 585 | let stop_position = parseInt(ele.data('coverage_percent')); 586 | let stop_pos_str = `0% ${stop_position}% ${stop_position}%` 587 | return stop_pos_str 588 | } 589 | 590 | // Add and remove coverage styling 591 | function handleToggleCoverage( event ) { 592 | let toggleCoverageButton = document.getElementById('toggle_coverage_btn') 593 | if (coverageStylingOn) { 594 | // remove styling for classes 595 | cy.nodes('node[blocks_covered = 0]').removeClass('uncovered'); 596 | cy.nodes('node[blocks_covered > 0]').removeClass('covered'); 597 | cy.edges('edge[covered > 0]').removeClass('covered'); 598 | 599 | toggleCoverageButton.classList.remove('pressed'); 600 | } else { 601 | // add styling for classes 602 | cy.nodes('node[blocks_covered = 0]').addClass('uncovered'); 603 | cy.nodes('node[blocks_covered > 0]').addClass('covered'); 604 | cy.edges('edge[covered > 0]').addClass('covered'); 605 | 606 | toggleCoverageButton.classList.add('pressed'); 607 | } 608 | 609 | coverageStylingOn = !coverageStylingOn; 610 | } 611 | 612 | function handleResetGraph( event ) { 613 | if (json_contents) { 614 | renderCytoscape(json_contents); 615 | } 616 | } 617 | 618 | function reloadCytoscape() { 619 | renderCytoscape(json_contents); 620 | } 621 | 622 | // 623 | // callback to start cytoscape once page is loaded 624 | // 625 | function renderCytoscape(model){ 626 | 627 | 628 | // Instantiate cytoscape graph 629 | cy = cytoscape({ 630 | container: container, 631 | elements: model.elements, // list of graph elements (nodes and edges) 632 | layout: default_layout, 633 | wheelSensitivity: 0.45, 634 | /* minZoom: 0.1, */ 635 | /* maxZoom: 5, */ 636 | 637 | // default style located at: cy.style()._private.defaultProperties 638 | style: [ 639 | { 640 | selector: 'node', 641 | style: { 642 | 'background-color': light_green, 643 | 'label': 'data(label)', 644 | 'border-color': subtle_grey, 645 | 'border-width': 3, 646 | 'width': size_jumbo, 647 | 'height': size_jumbo, 648 | } 649 | }, 650 | { 651 | selector: 'edge', 652 | style: { 653 | 'line-color': darker_subtle_grey, 654 | 'target-arrow-color': darker_subtle_grey, 655 | 'target-arrow-shape': 'triangle', 656 | 'curve-style': 'bezier', 657 | } 658 | }, 659 | { 660 | selector: 'node[edges_not_shown > 0]', 661 | style: { 662 | 'border-style': 'double', 663 | 'border-width': 5, 664 | } 665 | }, 666 | { 667 | selector: 'node[label]', 668 | style: { 669 | 'color': white, 670 | } 671 | }, 672 | { 673 | selector: `node[complexity < ${complexity_large}]`, 674 | style: { 'width': size_large, 'height': size_large } 675 | }, 676 | { 677 | selector: `node[complexity < ${complexity_medium}]`, 678 | style: { 'width': size_medium, 'height': size_medium } 679 | }, 680 | { 681 | selector: `node[complexity < ${complexity_small}]`, 682 | style: { 'width': size_small, 'height': size_small } 683 | }, 684 | { 685 | selector: `node[complexity < ${complexity_tiny}]`, 686 | style: { 'width': size_tiny, 'height': size_tiny } 687 | }, 688 | { 689 | selector: 'node[import = 1]', 690 | style: { 691 | 'background-color': orange, 692 | 'shape': 'diamond', 693 | 'width': size_small, 694 | 'height': size_small, 695 | } 696 | }, 697 | { 698 | selector: 'node[visited = 1]', 699 | style: { 700 | 'border-color': dark_blue, 701 | } 702 | }, 703 | { 704 | selector: 'node[current_function]', 705 | style: { 706 | 'background-color': bright_red, 707 | } 708 | }, 709 | { 710 | selector: 'node.covered', 711 | style: { 712 | //'background-color': red, 713 | //'background-color': 'mapData(coverage_percent, 0, 100, red, blue)', 714 | 'background-fill': 'linear-gradient', 715 | 'background-gradient-direction': 'to-top', 716 | // NOTE: backtick format strings are not accepted in the line below 717 | 'background-gradient-stop-colors': covered_color + ' ' + covered_color + ' black', 718 | 'border-color': covered_border_color, 719 | 'background-gradient-stop-positions': getCoverageGradient, 720 | } 721 | }, 722 | { 723 | selector: 'edge.covered', 724 | style: { 725 | 'line-color': covered_border_color, 726 | 'target-arrow-color': covered_border_color, 727 | } 728 | }, 729 | { 730 | selector: '.uncovered', 731 | style: { 732 | 'background-color': subtle_grey, 733 | 'border-color': grey, 734 | } 735 | }, 736 | { 737 | selector: '.background', 738 | style: { 739 | 'opacity': '0.5', 740 | } 741 | }, 742 | { 743 | selector: '.from', 744 | style: { 745 | 'background-color': from_color, 746 | 'line-color': from_color, 747 | 'target-arrow-color': from_color, 748 | } 749 | }, 750 | { 751 | selector: '.to', 752 | style: { 753 | 'background-color': to_color, 754 | 'line-color': to_color, 755 | 'target-arrow-color': to_color, 756 | } 757 | }, 758 | { 759 | selector: '.focused', 760 | style: { 761 | 'background-color': selected_color, 762 | } 763 | }, 764 | ] 765 | }); 766 | 767 | cy.$('node').on('click', handleNodeClick); 768 | 769 | let coverageAvailable = !(cy.nodes().data('coverage_percent') === null); 770 | if (!coverageAvailable) { 771 | document.getElementById("toggle_coverage_btn").disabled = true; 772 | } else { 773 | document.getElementById("toggle_coverage_btn").disabled = false; 774 | } 775 | 776 | // reset focus state on graph redraw 777 | if (focusedNode) { 778 | focusedNode = null; 779 | } 780 | removeFocus(); 781 | hideSidebarMetadata(); 782 | sidebarHeaderClickable(false); 783 | 784 | // Make "Hide Imports" stay toggled on if it's on via clear/redo 785 | if (hiddenImportNodes) { 786 | hiddenImportNodes = null; 787 | hiddenImportEdges = null; 788 | document.getElementById("hide_imports_btn").innerHTML = "Hide Imports"; 789 | handleToggleImports(null); 790 | } 791 | // Persist coverage styling if toggled on 792 | if (coverageStylingOn) { 793 | handleToggleCoverage(null); // turn it off to reset 794 | handleToggleCoverage(null); // turn it back on 795 | } 796 | 797 | } 798 | --------------------------------------------------------------------------------