├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE.TXT ├── ida_settings ├── __init__.py ├── ida_settings.py ├── tests.py └── ui │ ├── __init__.py │ └── ida_settings_viewer.py ├── img └── ui.png ├── readme.md └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '2.7' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload --skip-existing dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /ida_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .ida_settings import IDASettings, PermissionError 2 | -------------------------------------------------------------------------------- /ida_settings/ida_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | ida_settings provides a mechanism for settings and fetching 3 | configration values for IDAPython scripts and plugins. 4 | Configurations are namespaced by plugin name, 5 | and scoped to the global system, current user, 6 | working directory, or current IDB file. Configurations 7 | can be exported and imported using an .ini-style intermediate 8 | representation. 9 | 10 | Example fetching a configuration value: 11 | 12 | settings = IDASettings("MSDN-doc") 13 | if settings["verbosity"] == "high": 14 | ... 15 | 16 | Example setting a global configuration value: 17 | 18 | settings = IDASettings("MSDN-doc") 19 | settings.system["verbosity"] = "high" 20 | 21 | Example setting a working directory configuration value: 22 | 23 | settings = IDASettings("MSDN-doc") 24 | settings.directory["verbosity"] = "high" 25 | 26 | Use the properties "system", "user", "directory" and "idb" 27 | to scope configuration accesses and mutations to the global 28 | system, current user, working directory, or current IDB file. 29 | 30 | Plugins that write settings should pick the appropriate 31 | scope for their settings. Plugins that read settings should 32 | fetch them from the default scope. This allows for precedence 33 | of scopes, such as the current IDB over system-wide configuration. 34 | For example: 35 | 36 | settings = IDASettings("MSDN-doc") 37 | # when writing, use a scope: 38 | settings.user["verbosity"] = "high" 39 | 40 | # when reading, use the default scope: 41 | settings["verbosity"] --> "high" 42 | 43 | Generally, treat a settings instance like a dictionary. For example: 44 | 45 | settings = IDASettings("MSDN-doc") 46 | "verbosity" in settings.user --> False 47 | settings.user["verbosity"] = "high" 48 | settings.user["verbosity"] --> "high" 49 | settings.user.keys() --> ["verbosity"] 50 | settings.user.values() --> ["high"] 51 | settings.user.items() --> [("verbosity", "high')] 52 | 53 | The value of a particular settings entry must be a JSON-encodable 54 | value. For example, these are fine: 55 | 56 | settings = IDASettings("MSDN-doc") 57 | settings.user["verbosity"] = "high" 58 | settings.user["count"] = 1 59 | settings.user["percentage"] = 0.75 60 | settings.user["filenames"] = ["a.txt", "b.txt"] 61 | settings.user["aliases"] = {"bp": "breakpoint", "g": "go"} 62 | 63 | and this is not: 64 | 65 | settings.user["object"] = hashlib.md5() # this is not JSON-encodable 66 | 67 | To export the current effective settings, use the `export_settings` 68 | function. For example: 69 | 70 | settings = IDASettings("MSDN-doc") 71 | export_settings(settings, "/home/user/desktop/current.ini") 72 | 73 | To import existing settings into a settings instance, such as 74 | the open IDB, use the `import_settings` function. For example: 75 | 76 | settings = IDASettings("MSDN-doc") 77 | import_settings(settings.idb, "/home/user/desktop/current.ini") 78 | 79 | Enumerate the plugin names for the various levels using the 80 | IDASettings class properties: 81 | 82 | IDASettings.get_system_plugin_names() --> ["plugin-1", "plugin-2"] 83 | IDASettings.get_user_plugin_names() --> ["plugin-3", "plugin-4"] 84 | IDASettings.get_directory_plugin_names() --> ["plugin-5", "plugin-6"] 85 | IDASettings.get_idb_plugin_names() --> ["plugin-7", "plugin-8"] 86 | 87 | This module is a single file that you can include in IDAPython 88 | plugin module or scripts. 89 | 90 | It depends on ida-netnode, which you can download here: 91 | https://github.com/williballenthin/ida-netnode 92 | 93 | This project is licensed under the Apache 2.0 license. 94 | 95 | Author: Willi Ballenthin 96 | """ 97 | import os 98 | import abc 99 | import sys 100 | import json 101 | import datetime 102 | 103 | import six 104 | 105 | try: 106 | import idc 107 | import idaapi 108 | import netnode 109 | except ImportError: 110 | pass 111 | 112 | 113 | # we'll use a function here to avoid polluting our global variable namespace. 114 | def import_qtcore(): 115 | """ 116 | This nasty piece of code is here to force the loading of IDA's 117 | Qt bindings. 118 | Without it, Python attempts to load PySide from the site-packages 119 | directory, and failing, as it does not play nicely with IDA. 120 | 121 | via: github.com/tmr232/Cute 122 | """ 123 | has_ida = False 124 | try: 125 | # if we're running under IDA, 126 | # then we'll use IDA's Qt bindings 127 | import idaapi 128 | 129 | has_ida = True 130 | except ImportError: 131 | # not running under IDA, 132 | # so use default Qt installation 133 | has_ida = False 134 | 135 | if has_ida: 136 | old_path = sys.path[:] 137 | try: 138 | ida_python_path = os.path.dirname(idaapi.__file__) 139 | sys.path.insert(0, ida_python_path) 140 | if idaapi.IDA_SDK_VERSION >= 690: 141 | from PyQt5 import QtCore 142 | 143 | return QtCore 144 | else: 145 | from PySide import QtCore 146 | 147 | return QtCore 148 | finally: 149 | sys.path = old_path 150 | else: 151 | try: 152 | from PyQt5 import QtCore 153 | 154 | return QtCore 155 | except ImportError: 156 | pass 157 | 158 | try: 159 | from PySide import QtCore 160 | 161 | return QtCore 162 | except ImportError: 163 | pass 164 | 165 | raise ImportError("No module named PySide or PyQt") 166 | 167 | 168 | QtCore = import_qtcore() 169 | 170 | CONFIG_FILE_NANE = ".ida-settings.ini" 171 | IDA_SETTINGS_ORGANIZATION = "IDAPython" 172 | IDA_SETTINGS_APPLICATION = "IDA-Settings" 173 | 174 | 175 | def validate_key(key): 176 | if not isinstance(key, str): 177 | raise TypeError("key must be str") 178 | 179 | 180 | def validate_value(value): 181 | if six.PY3 and isinstance(value, bytes): 182 | raise TypeError("value cannot be bytes") 183 | 184 | try: 185 | _ = json.dumps(value) 186 | except: 187 | raise TypeError("value cannot be json encoded") 188 | 189 | 190 | # enforce methods required by settings providers 191 | class IDASettingsInterface(six.with_metaclass(abc.ABCMeta)): 192 | @abc.abstractmethod 193 | def get_value(self, key): 194 | """ 195 | Fetch the settings value with the given key, or raise KeyError. 196 | 197 | type key: basestring 198 | rtype value: Union[basestring, int, float, List, Dict] 199 | """ 200 | raise NotImplemented 201 | 202 | @abc.abstractmethod 203 | def set_value(self, key, value): 204 | """ 205 | Set the settings value with the given key. 206 | 207 | type key: basestring 208 | type value: Union[basestring, int, float, List, Dict] 209 | """ 210 | raise NotImplemented 211 | 212 | @abc.abstractmethod 213 | def del_value(self, key): 214 | """ 215 | Remove the settings value with the given key. 216 | Does not raise an error if the key does not already exist. 217 | 218 | type key: basestring 219 | """ 220 | raise NotImplemented 221 | 222 | @abc.abstractmethod 223 | def get_keys(self): 224 | """ 225 | Fetch an iterable of the settings keys, which are strings. 226 | 227 | rtype: Iterable[basestring] 228 | """ 229 | raise NotImplemented 230 | 231 | @abc.abstractmethod 232 | def clear(self): 233 | """ 234 | Delete all settings for this settings instance. 235 | 236 | rtype: None 237 | """ 238 | raise NotImplemented 239 | 240 | 241 | def validate(s): 242 | # the slash character is used by QSettings to denote a subgroup 243 | # we want to have a single nested structure of settings 244 | if "/" in s: 245 | return False 246 | if "\\" in s: 247 | # QSettings automatically translates '\' to '/' 248 | return False 249 | return True 250 | 251 | 252 | # provide base constructor args required by settings providers 253 | class IDASettingsBase(IDASettingsInterface): 254 | def __init__(self, plugin_name): 255 | super(IDASettingsBase, self).__init__() 256 | if not validate(plugin_name): 257 | raise RuntimeError("invalid plugin name") 258 | self._plugin_name = plugin_name 259 | 260 | 261 | # allow IDASettings to look like dicts 262 | class DictMixin: 263 | def __getitem__(self, key): 264 | validate_key(key) 265 | return self.get_value(key) 266 | 267 | def __setitem__(self, key, value): 268 | validate_key(key) 269 | validate_value(value) 270 | return self.set_value(key, value) 271 | 272 | def __delitem__(self, key): 273 | validate_key(key) 274 | return self.del_value(key) 275 | 276 | def get(self, key, default=None): 277 | try: 278 | return self[key] 279 | except KeyError: 280 | return default 281 | 282 | def __contains__(self, key): 283 | try: 284 | if self[key] is not None: 285 | return True 286 | return False 287 | except KeyError: 288 | return False 289 | 290 | def iterkeys(self): 291 | return self.get_keys() 292 | 293 | def keys(self): 294 | return [k for k in self.iterkeys()] 295 | 296 | def itervalues(self): 297 | for k in self.iterkeys(): 298 | yield self[k] 299 | 300 | def values(self): 301 | return [v for v in self.itervalues()] 302 | 303 | def iteritems(self): 304 | for k in self.iterkeys(): 305 | yield k, self[k] 306 | 307 | def items(self): 308 | return [(k, v) for k, v in self.items()] 309 | 310 | 311 | MARKER_KEY = "__meta/permission_check" 312 | 313 | 314 | def has_qsettings_write_permission(settings): 315 | value = datetime.datetime.now().isoformat("T") 316 | settings.setValue(MARKER_KEY, value) 317 | settings.sync() 318 | # there's a race here, if another thread/process also 319 | # performs the same check at the same time 320 | if settings.status() != QtCore.QSettings.NoError: 321 | return False 322 | if settings.value(MARKER_KEY) != value: 323 | return False 324 | settings.remove(MARKER_KEY) 325 | settings.sync() 326 | return True 327 | 328 | 329 | class PermissionError(IOError): 330 | def __init__(self): 331 | super(PermissionError, self).__init__("Unable to write to QSettings") 332 | 333 | 334 | class QSettingsIDASettings(IDASettingsInterface): 335 | """ 336 | An IDASettings implementation that uses an existing QSettings 337 | instance to persist the keys and values. 338 | """ 339 | 340 | def __init__(self, qsettings): 341 | super(QSettingsIDASettings, self).__init__() 342 | self._settings = qsettings 343 | self._has_perms = None 344 | 345 | def _check_perms(self): 346 | if self._has_perms is None: 347 | self._has_perms = has_qsettings_write_permission(self._settings) 348 | if not self._has_perms: 349 | raise PermissionError() 350 | 351 | def get_value(self, key): 352 | validate_key(key) 353 | v = self._settings.value(key) 354 | if v is None: 355 | raise KeyError("key not found") 356 | return json.loads(v) 357 | 358 | def set_value(self, key, value): 359 | validate_key(key) 360 | validate_value(value) 361 | self._check_perms() 362 | self._settings.setValue(key, json.dumps(value)) 363 | 364 | def del_value(self, key): 365 | validate_key(key) 366 | self._check_perms() 367 | return self._settings.remove(key) 368 | 369 | def get_keys(self): 370 | for k in self._settings.allKeys(): 371 | yield k 372 | 373 | def clear(self): 374 | self._check_perms() 375 | # Qt: the empty string removes all entries in the current group 376 | self._settings.remove("") 377 | 378 | 379 | class SystemIDASettings(IDASettingsBase, DictMixin): 380 | """ 381 | An IDASettings implementation that persists keys and values in the 382 | system scope using a QSettings instance. 383 | """ 384 | 385 | def __init__(self, plugin_name, *args, **kwargs): 386 | super(SystemIDASettings, self).__init__(plugin_name, *args, **kwargs) 387 | s = QtCore.QSettings(QtCore.QSettings.SystemScope, IDA_SETTINGS_ORGANIZATION, IDA_SETTINGS_APPLICATION) 388 | s.beginGroup(self._plugin_name) 389 | self._qsettings = QSettingsIDASettings(s) 390 | 391 | def get_value(self, key): 392 | validate_key(key) 393 | return self._qsettings.get_value(key) 394 | 395 | def set_value(self, key, value): 396 | validate_key(key) 397 | validate_value(value) 398 | return self._qsettings.set_value(key, value) 399 | 400 | def del_value(self, key): 401 | validate_key(key) 402 | return self._qsettings.del_value(key) 403 | 404 | def get_keys(self): 405 | return self._qsettings.get_keys() 406 | 407 | def clear(self): 408 | return self._qsettings.clear() 409 | 410 | 411 | class UserIDASettings(IDASettingsBase, DictMixin): 412 | """ 413 | An IDASettings implementation that persists keys and values in the 414 | user scope using a QSettings instance. 415 | """ 416 | 417 | def __init__(self, plugin_name, *args, **kwargs): 418 | super(UserIDASettings, self).__init__(plugin_name, *args, **kwargs) 419 | s = QtCore.QSettings(QtCore.QSettings.UserScope, IDA_SETTINGS_ORGANIZATION, IDA_SETTINGS_APPLICATION) 420 | s.beginGroup(self._plugin_name) 421 | self._qsettings = QSettingsIDASettings(s) 422 | 423 | def get_value(self, key): 424 | validate_key(key) 425 | return self._qsettings.get_value(key) 426 | 427 | def set_value(self, key, value): 428 | validate_key(key) 429 | validate_value(value) 430 | return self._qsettings.set_value(key, value) 431 | 432 | def del_value(self, key): 433 | validate_key(key) 434 | return self._qsettings.del_value(key) 435 | 436 | def get_keys(self): 437 | return self._qsettings.get_keys() 438 | 439 | def clear(self): 440 | return self._qsettings.clear() 441 | 442 | 443 | def get_directory_config_path(directory=None): 444 | if directory is None: 445 | directory = os.path.dirname(idc.get_idb_path()) 446 | config_path = os.path.join(directory, CONFIG_FILE_NANE) 447 | return config_path 448 | 449 | 450 | class DirectoryIDASettings(IDASettingsBase, DictMixin): 451 | """ 452 | An IDASettings implementation that persists keys and values in the 453 | directory scope using a QSettings instance. 454 | """ 455 | 456 | def __init__(self, plugin_name, *args, **kwargs): 457 | config_directory = kwargs.pop("directory") 458 | super(DirectoryIDASettings, self).__init__(plugin_name, *args, **kwargs) 459 | config_path = get_directory_config_path(config_directory) 460 | s = QtCore.QSettings(config_path, QtCore.QSettings.IniFormat) 461 | s.beginGroup(self._plugin_name) 462 | self._qsettings = QSettingsIDASettings(s) 463 | 464 | def get_value(self, key): 465 | validate_key(key) 466 | return self._qsettings.get_value(key) 467 | 468 | def set_value(self, key, value): 469 | validate_key(key) 470 | validate_value(value) 471 | return self._qsettings.set_value(key, value) 472 | 473 | def del_value(self, key): 474 | validate_key(key) 475 | return self._qsettings.del_value(key) 476 | 477 | def get_keys(self): 478 | return self._qsettings.get_keys() 479 | 480 | def clear(self): 481 | return self._qsettings.clear() 482 | 483 | 484 | def get_meta_netnode(): 485 | """ 486 | Get the netnode used to store settings metadata in the current IDB. 487 | Note that this implicitly uses the open IDB via the idc iterface. 488 | """ 489 | node_name = "$ {org:s}.{application:s}".format(org=IDA_SETTINGS_ORGANIZATION, application=IDA_SETTINGS_APPLICATION) 490 | return netnode.Netnode(node_name) 491 | 492 | 493 | PLUGIN_NAMES_KEY = "plugin_names" 494 | 495 | 496 | def get_netnode_plugin_names(): 497 | """ 498 | Get a iterable of the plugin names registered in the current IDB. 499 | Note that this implicitly uses the open IDB via the idc iterface. 500 | """ 501 | try: 502 | return json.loads(get_meta_netnode()[PLUGIN_NAMES_KEY]) 503 | except KeyError: 504 | # TODO: there may be other exception types to catch here 505 | return [] 506 | 507 | 508 | def add_netnode_plugin_name(plugin_name): 509 | """ 510 | Add the given plugin name to the list of plugin names registered in 511 | the current IDB. 512 | Note that this implicitly uses the open IDB via the idc iterface. 513 | """ 514 | current_names = set(get_netnode_plugin_names()) 515 | if plugin_name in current_names: 516 | return 517 | 518 | current_names.add(plugin_name) 519 | 520 | get_meta_netnode()[PLUGIN_NAMES_KEY] = json.dumps(list(current_names)) 521 | 522 | 523 | def del_netnode_plugin_name(plugin_name): 524 | """ 525 | Remove the given plugin name to the list of plugin names registered in 526 | the current IDB. 527 | Note that this implicitly uses the open IDB via the idc iterface. 528 | """ 529 | current_names = set(get_netnode_plugin_names()) 530 | if plugin_name not in current_names: 531 | return 532 | 533 | try: 534 | current_names.remove(plugin_name) 535 | except KeyError: 536 | return 537 | 538 | get_meta_netnode()[PLUGIN_NAMES_KEY] = json.dumps(list(current_names)) 539 | 540 | 541 | class IDBIDASettings(IDASettingsBase, DictMixin): 542 | """ 543 | An IDASettings implementation that persists keys and values in the 544 | current IDB database. 545 | """ 546 | 547 | @property 548 | def _netnode(self): 549 | node_name = "$ {org:s}.{application:s}.{plugin_name:s}".format( 550 | org=IDA_SETTINGS_ORGANIZATION, application=IDA_SETTINGS_APPLICATION, plugin_name=self._plugin_name 551 | ) 552 | return netnode.Netnode(node_name) 553 | 554 | def get_value(self, key): 555 | validate_key(key) 556 | 557 | try: 558 | v = self._netnode[key] 559 | except TypeError: 560 | raise KeyError("key not found") 561 | if v is None: 562 | raise KeyError("key not found") 563 | 564 | return json.loads(v) 565 | 566 | def set_value(self, key, value): 567 | validate_key(key) 568 | validate_value(value) 569 | self._netnode[key] = json.dumps(value) 570 | add_netnode_plugin_name(self._plugin_name) 571 | 572 | def del_value(self, key): 573 | validate_key(key) 574 | 575 | try: 576 | del self._netnode[key] 577 | except KeyError: 578 | pass 579 | 580 | def get_keys(self): 581 | return iter(self._netnode.keys()) 582 | 583 | def clear(self): 584 | for k in self.get_keys(): 585 | self.del_value(k) 586 | self._netnode.kill() 587 | del_netnode_plugin_name(self._plugin_name) 588 | 589 | 590 | def ensure_ida_loaded(): 591 | try: 592 | import idc 593 | import idaapi 594 | except ImportError: 595 | raise EnvironmentError("Must be running in IDA to access IDB or directory settings") 596 | 597 | 598 | class IDASettings(object): 599 | def __init__(self, plugin_name, directory=None): 600 | super(IDASettings, self).__init__() 601 | if not validate(plugin_name): 602 | raise RuntimeError("invalid plugin name") 603 | self._plugin_name = plugin_name 604 | self._config_directory = directory 605 | 606 | @property 607 | def idb(self): 608 | """ 609 | Fetch the IDASettings instance for the curren plugin with IDB scope. 610 | 611 | rtype: IDASettingsInterface 612 | """ 613 | ensure_ida_loaded() 614 | return IDBIDASettings(self._plugin_name) 615 | 616 | @property 617 | def directory(self): 618 | """ 619 | Fetch the IDASettings instance for the curren plugin with directory scope. 620 | 621 | rtype: IDASettingsInterface 622 | """ 623 | if self._config_directory is None: 624 | ensure_ida_loaded() 625 | return DirectoryIDASettings(self._plugin_name, directory=self._config_directory) 626 | 627 | @property 628 | def user(self): 629 | """ 630 | Fetch the IDASettings instance for the curren plugin with user scope. 631 | 632 | rtype: IDASettingsInterface 633 | """ 634 | return UserIDASettings(self._plugin_name) 635 | 636 | @property 637 | def system(self): 638 | """ 639 | Fetch the IDASettings instance for the curren plugin with system scope. 640 | 641 | rtype: IDASettingsInterface 642 | """ 643 | return SystemIDASettings(self._plugin_name) 644 | 645 | def get_value(self, key): 646 | """ 647 | Fetch the settings value with the highest precedence for the given 648 | key, or raise KeyError. 649 | Precedence: 650 | - IDB scope 651 | - directory scope 652 | - user scope 653 | - system scope 654 | 655 | type key: basestring 656 | rtype value: Union[basestring, int, float, List, Dict] 657 | """ 658 | validate_key(key) 659 | 660 | try: 661 | return self.idb.get_value(key) 662 | except (KeyError, EnvironmentError): 663 | pass 664 | try: 665 | return self.directory.get_value(key) 666 | except (KeyError, EnvironmentError): 667 | pass 668 | try: 669 | return self.user.get_value(key) 670 | except KeyError: 671 | pass 672 | try: 673 | return self.system.get_value(key) 674 | except KeyError: 675 | pass 676 | 677 | raise KeyError("key not found") 678 | 679 | def iterkeys(self): 680 | """ 681 | Enumerate the keys found at any scope for the current plugin. 682 | 683 | rtype: Generator[str] 684 | """ 685 | visited_keys = set() 686 | try: 687 | for key in self.idb.keys(): 688 | if key not in visited_keys: 689 | yield key 690 | visited_keys.add(key) 691 | except (PermissionError, EnvironmentError): 692 | pass 693 | 694 | try: 695 | for key in self.directory.keys(): 696 | if key not in visited_keys: 697 | yield key 698 | visited_keys.add(key) 699 | except (PermissionError, EnvironmentError): 700 | pass 701 | 702 | try: 703 | for key in self.user.keys(): 704 | if key not in visited_keys: 705 | yield key 706 | visited_keys.add(key) 707 | except (PermissionError, EnvironmentError): 708 | pass 709 | 710 | try: 711 | for key in self.system.keys(): 712 | if key not in visited_keys: 713 | yield key 714 | visited_keys.add(key) 715 | except (PermissionError, EnvironmentError): 716 | pass 717 | 718 | def keys(self): 719 | """ 720 | Enumerate the keys found at any scope for the current plugin. 721 | 722 | rtype: Generator[str] 723 | """ 724 | 725 | return list(self.keys()) 726 | 727 | def itervalues(self): 728 | """ 729 | Enumerate the values found at any scope for the current plugin. 730 | 731 | rtype: Generator[jsonable] 732 | """ 733 | 734 | for key in self.keys(): 735 | yield self[key] 736 | 737 | def values(self): 738 | """ 739 | Enumerate the values found at any scope for the current plugin. 740 | 741 | rtype: Sequence[jsonable] 742 | """ 743 | 744 | return list(self.values()) 745 | 746 | def iteritems(self): 747 | """ 748 | Enumerate the (key, value) pairs found at any scope for the current plugin. 749 | 750 | rtype: Sequence[Tuple[str, jsonable]] 751 | """ 752 | for key in self.keys(): 753 | yield (key, self[key]) 754 | 755 | def items(self): 756 | """ 757 | Enumerate the (key, value) pairs found at any scope for the current plugin. 758 | 759 | rtype: Sequence[Tuple[str, jsonable]] 760 | """ 761 | return list(self.items()) 762 | 763 | def __getitem__(self, key): 764 | validate_key(key) 765 | return self.get_value(key) 766 | 767 | def get(self, key, default=None): 768 | validate_key(key) 769 | try: 770 | return self[key] 771 | except KeyError: 772 | return default 773 | 774 | def __contains__(self, key): 775 | validate_key(key) 776 | try: 777 | if self[key] is not None: 778 | return True 779 | return False 780 | except KeyError: 781 | return False 782 | 783 | @staticmethod 784 | def get_system_plugin_names(): 785 | """ 786 | Get the names of all plugins at the system scope. 787 | As this is a static method, you can call the directly on IDASettings: 788 | 789 | import ida_settings 790 | print( ida_settings.IDASettings.get_system_plugin_names() ) 791 | 792 | rtype: Sequence[str] 793 | """ 794 | return QtCore.QSettings( 795 | QtCore.QSettings.SystemScope, IDA_SETTINGS_ORGANIZATION, IDA_SETTINGS_APPLICATION 796 | ).childGroups()[:] 797 | 798 | @staticmethod 799 | def get_user_plugin_names(): 800 | """ 801 | Get the names of all plugins at the user scope. 802 | As this is a static method, you can call the directly on IDASettings: 803 | 804 | import ida_settings 805 | print( ida_settings.IDASettings.get_user_plugin_names() ) 806 | 807 | rtype: Sequence[str] 808 | """ 809 | return QtCore.QSettings( 810 | QtCore.QSettings.UserScope, IDA_SETTINGS_ORGANIZATION, IDA_SETTINGS_APPLICATION 811 | ).childGroups()[:] 812 | 813 | @staticmethod 814 | def get_directory_plugin_names(config_directory=None): 815 | """ 816 | Get the names of all plugins at the directory scope. 817 | Provide a config directory path to use this method outside of IDA. 818 | As this is a static method, you can call the directly on IDASettings: 819 | 820 | import ida_settings 821 | print( ida_settings.IDASettings.get_directory_plugin_names("/tmp/ida/1/") ) 822 | 823 | type config_directory: str 824 | rtype: Sequence[str] 825 | """ 826 | ensure_ida_loaded() 827 | return QtCore.QSettings( 828 | get_directory_config_path(directory=config_directory), QtCore.QSettings.IniFormat 829 | ).childGroups()[:] 830 | 831 | @staticmethod 832 | def get_idb_plugin_names(): 833 | """ 834 | Get the names of all plugins at the IDB scope. 835 | Cannot be used outside of IDA. 836 | As this is a static method, you can call the directly on IDASettings: 837 | 838 | import ida_settings 839 | print( ida_settings.IDASettings.get_idb_plugin_names() ) 840 | 841 | rtype: Sequence[str] 842 | """ 843 | ensure_ida_loaded() 844 | return get_netnode_plugin_names() 845 | 846 | 847 | def import_settings(settings, config_path): 848 | """ 849 | Import settings from the given file system path to given settings instance. 850 | 851 | type settings: IDASettingsInterface 852 | type config_path: str 853 | """ 854 | other = QtCore.QSettings(config_path, QtCore.QSettings.IniFormat) 855 | for k in other.allKeys(): 856 | settings[k] = other.value(k) 857 | 858 | 859 | def export_settings(settings, config_path): 860 | """ 861 | Export the given settings instance to the given file system path. 862 | 863 | type settings: IDASettingsInterface 864 | type config_path: str 865 | """ 866 | other = QtCore.QSettings(config_path, QtCore.QSettings.IniFormat) 867 | for k, v in settings.items(): 868 | other.setValue(k, v) 869 | -------------------------------------------------------------------------------- /ida_settings/tests.py: -------------------------------------------------------------------------------- 1 | ####################################################################################### 2 | # 3 | # Test Cases 4 | # run this file as an IDAPython script to invoke the tests. 5 | # 6 | ####################################################################################### 7 | import logging 8 | import unittest 9 | import contextlib 10 | 11 | from ida_settings import IDASettings, PermissionError 12 | 13 | g_logger = logging.getLogger("ida-settings") 14 | 15 | PLUGIN_1 = "plugin1" 16 | PLUGIN_2 = "plugin2" 17 | KEY_1 = "key_1" 18 | KEY_2 = "key_2" 19 | VALUE_1 = "hello" 20 | VALUE_2 = "goodbye" 21 | VALUE_STR = "foo" 22 | VALUE_INT = 69 23 | VALUE_FLOAT = 69.69 24 | VALUE_LIST = ["a", "b", "c"] 25 | VALUE_DICT = {"a": 1, "b": "2", "c": 3.0} 26 | # bytes are not supported 27 | VALUE_BYTES = b"foo" 28 | 29 | 30 | class TestSync(unittest.TestCase): 31 | """ 32 | Demonstrate that creating new instances of the settings objects shows the same data. 33 | """ 34 | 35 | def test_system(self): 36 | try: 37 | # this may fail if the user is not running as admin 38 | IDASettings(PLUGIN_1).system.set_value(KEY_1, VALUE_1) 39 | self.assertEqual(IDASettings(PLUGIN_1).system.get_value(KEY_1), VALUE_1) 40 | except PermissionError: 41 | g_logger.warning("swallowing PermissionError during testing") 42 | 43 | def test_user(self): 44 | try: 45 | IDASettings(PLUGIN_1).user.set_value(KEY_1, VALUE_1) 46 | self.assertEqual(IDASettings(PLUGIN_1).user.get_value(KEY_1), VALUE_1) 47 | except PermissionError: 48 | g_logger.warning("swallowing PermissionError during testing") 49 | 50 | def test_directory(self): 51 | try: 52 | IDASettings(PLUGIN_1).directory.set_value(KEY_1, VALUE_1) 53 | self.assertEqual(IDASettings(PLUGIN_1).directory.get_value(KEY_1), VALUE_1) 54 | except PermissionError: 55 | g_logger.warning("swallowing PermissionError during testing") 56 | 57 | def test_idb(self): 58 | try: 59 | IDASettings(PLUGIN_1).idb.set_value(KEY_1, VALUE_1) 60 | self.assertEqual(IDASettings(PLUGIN_1).idb.get_value(KEY_1), VALUE_1) 61 | except PermissionError: 62 | g_logger.warning("swallowing PermissionError during testing") 63 | 64 | 65 | @contextlib.contextmanager 66 | def clearing(settings): 67 | settings.clear() 68 | try: 69 | yield 70 | finally: 71 | settings.clear() 72 | 73 | 74 | class TestSettingsMixin(object): 75 | """ 76 | A mixin that adds standard tests test cases with: 77 | - self.settings, an IDASettingsInterface implementor 78 | """ 79 | 80 | def test_set(self): 81 | try: 82 | with clearing(self.settings): 83 | # simple set 84 | self.settings.set_value(KEY_1, VALUE_1) 85 | self.assertEqual(self.settings.get_value(KEY_1), VALUE_1) 86 | # overwrite 87 | self.settings.set_value(KEY_1, VALUE_2) 88 | self.assertEqual(self.settings.get_value(KEY_1), VALUE_2) 89 | except PermissionError: 90 | g_logger.warning("swallowing PermissionError during testing") 91 | 92 | def test_del(self): 93 | try: 94 | with clearing(self.settings): 95 | self.settings.set_value(KEY_1, VALUE_1) 96 | self.settings.del_value(KEY_1) 97 | with self.assertRaises(KeyError): 98 | self.settings.get_value(KEY_1) 99 | except PermissionError: 100 | g_logger.warning("swallowing PermissionError during testing") 101 | 102 | def test_keys(self): 103 | try: 104 | with clearing(self.settings): 105 | self.settings.del_value(KEY_1) 106 | self.settings.del_value(KEY_2) 107 | self.settings.set_value(KEY_1, VALUE_1) 108 | self.assertEqual(list(self.settings.get_keys()), [KEY_1]) 109 | self.settings.set_value(KEY_2, VALUE_2) 110 | self.assertEqual(list(self.settings.get_keys()), [KEY_1, KEY_2]) 111 | except PermissionError: 112 | g_logger.warning("swallowing PermissionError during testing") 113 | 114 | def test_dict(self): 115 | try: 116 | with clearing(self.settings): 117 | self.assertFalse(KEY_1 in self.settings) 118 | self.settings[KEY_1] = VALUE_1 119 | self.assertEqual(self.settings[KEY_1], VALUE_1) 120 | self.settings[KEY_2] = VALUE_2 121 | self.assertEqual(self.settings[KEY_2], VALUE_2) 122 | self.assertEqual(list(self.settings.keys()), [KEY_1, KEY_2]) 123 | self.assertEqual(list(self.settings.values()), [VALUE_1, VALUE_2]) 124 | del self.settings[KEY_1] 125 | self.assertEqual(list(self.settings.keys()), [KEY_2]) 126 | except PermissionError: 127 | g_logger.warning("swallowing PermissionError during testing") 128 | 129 | def test_types(self): 130 | try: 131 | with clearing(self.settings): 132 | for v in [VALUE_STR, VALUE_INT, VALUE_FLOAT, VALUE_LIST, VALUE_DICT]: 133 | self.settings.set_value(KEY_1, v) 134 | self.assertEqual(self.settings[KEY_1], v) 135 | 136 | self.assertRaises(TypeError, self.settings.set_value, KEY_1, VALUE_BYTES) 137 | 138 | except PermissionError: 139 | g_logger.warning("swallowing PermissionError during testing") 140 | 141 | def test_large_values(self): 142 | large_value_1 = "".join(VALUE_1 * 1000) 143 | large_value_2 = "".join(VALUE_2 * 1000) 144 | try: 145 | with clearing(self.settings): 146 | # simple set 147 | self.settings.set_value(KEY_1, large_value_1) 148 | self.assertEqual(self.settings.get_value(KEY_1), large_value_1) 149 | # overwrite 150 | self.settings.set_value(KEY_1, large_value_2) 151 | self.assertEqual(self.settings.get_value(KEY_1), large_value_2) 152 | except PermissionError: 153 | g_logger.warning("swallowing PermissionError during testing") 154 | 155 | 156 | class TestSystemSettings(unittest.TestCase, TestSettingsMixin): 157 | def setUp(self): 158 | self.settings = IDASettings(PLUGIN_1).system 159 | 160 | 161 | class TestUserSettings(unittest.TestCase, TestSettingsMixin): 162 | def setUp(self): 163 | self.settings = IDASettings(PLUGIN_1).user 164 | 165 | 166 | class TestDirectorySettings(unittest.TestCase, TestSettingsMixin): 167 | def setUp(self): 168 | self.settings = IDASettings(PLUGIN_1).directory 169 | 170 | 171 | class TestIdbSettings(unittest.TestCase, TestSettingsMixin): 172 | def setUp(self): 173 | self.settings = IDASettings(PLUGIN_1).idb 174 | 175 | 176 | class TestUserAndSystemSettings(unittest.TestCase): 177 | def setUp(self): 178 | self.system = IDASettings(PLUGIN_1).system 179 | self.user = IDASettings(PLUGIN_1).user 180 | 181 | def test_system_fallback(self): 182 | """ 183 | QSettings instances with scope "user" automatically fall back to 184 | scope "system" if the key doesn't exist. 185 | """ 186 | try: 187 | with clearing(self.system): 188 | with clearing(self.user): 189 | self.system.set_value(KEY_1, VALUE_1) 190 | self.assertEqual(self.user.get_value(KEY_1), VALUE_1) 191 | except PermissionError: 192 | g_logger.warning("swallowing PermissionError during testing") 193 | 194 | 195 | class TestSettingsPrecendence(unittest.TestCase): 196 | def setUp(self): 197 | self.system = IDASettings(PLUGIN_1).system 198 | self.user = IDASettings(PLUGIN_1).user 199 | self.directory = IDASettings(PLUGIN_1).directory 200 | self.idb = IDASettings(PLUGIN_1).idb 201 | self.mux = IDASettings(PLUGIN_1) 202 | 203 | def test_user_gt_system(self): 204 | try: 205 | with clearing(self.system): 206 | with clearing(self.user): 207 | self.system.set_value(KEY_1, VALUE_1) 208 | self.user.set_value(KEY_1, VALUE_2) 209 | self.assertEqual(self.mux.get_value(KEY_1), VALUE_2) 210 | except PermissionError: 211 | g_logger.warning("swallowing PermissionError during testing") 212 | 213 | def test_directory_gt_user(self): 214 | try: 215 | with clearing(self.user): 216 | with clearing(self.directory): 217 | self.user.set_value(KEY_1, VALUE_1) 218 | self.directory.set_value(KEY_1, VALUE_2) 219 | self.assertEqual(self.mux.get_value(KEY_1), VALUE_2) 220 | except PermissionError: 221 | g_logger.warning("swallowing PermissionError during testing") 222 | 223 | def test_idb_gt_directory(self): 224 | try: 225 | with clearing(self.directory): 226 | with clearing(self.idb): 227 | self.directory.set_value(KEY_1, VALUE_1) 228 | self.idb.set_value(KEY_1, VALUE_2) 229 | self.assertEqual(self.mux.get_value(KEY_1), VALUE_2) 230 | except PermissionError: 231 | g_logger.warning("swallowing PermissionError during testing") 232 | 233 | 234 | class TestPluginNamesAccessors(unittest.TestCase): 235 | def test_system_plugin_names(self): 236 | try: 237 | self.assertEqual(set(IDASettings.get_system_plugin_names()), set([])) 238 | 239 | s1 = IDASettings(PLUGIN_1).system 240 | with clearing(s1): 241 | s1[KEY_1] = VALUE_1 242 | self.assertEqual(set(IDASettings.get_system_plugin_names()), set([PLUGIN_1])) 243 | 244 | s2 = IDASettings(PLUGIN_2).system 245 | with clearing(s2): 246 | s2[KEY_1] = VALUE_1 247 | self.assertEqual(set(IDASettings.get_system_plugin_names()), set([PLUGIN_1, PLUGIN_2])) 248 | 249 | self.assertEqual(set(IDASettings.get_system_plugin_names()), set([])) 250 | except PermissionError: 251 | g_logger.warning("swallowing PermissionError during testing") 252 | 253 | def test_user_plugin_names(self): 254 | try: 255 | self.assertEqual(set(IDASettings.get_user_plugin_names()), set([])) 256 | 257 | s1 = IDASettings(PLUGIN_1).user 258 | with clearing(s1): 259 | s1[KEY_1] = VALUE_1 260 | self.assertEqual(set(IDASettings.get_user_plugin_names()), set([PLUGIN_1])) 261 | 262 | s2 = IDASettings(PLUGIN_2).user 263 | with clearing(s2): 264 | s2[KEY_1] = VALUE_1 265 | self.assertEqual(set(IDASettings.get_user_plugin_names()), set([PLUGIN_1, PLUGIN_2])) 266 | 267 | self.assertEqual(set(IDASettings.get_user_plugin_names()), set([])) 268 | except PermissionError: 269 | g_logger.warning("swallowing PermissionError during testing") 270 | 271 | def test_directory_plugin_names(self): 272 | try: 273 | self.assertEqual(set(IDASettings.get_directory_plugin_names()), set([])) 274 | 275 | s1 = IDASettings(PLUGIN_1).directory 276 | with clearing(s1): 277 | s1[KEY_1] = VALUE_1 278 | self.assertEqual(set(IDASettings.get_directory_plugin_names()), set([PLUGIN_1])) 279 | 280 | s2 = IDASettings(PLUGIN_2).directory 281 | with clearing(s2): 282 | s2[KEY_1] = VALUE_1 283 | self.assertEqual(set(IDASettings.get_directory_plugin_names()), set([PLUGIN_1, PLUGIN_2])) 284 | 285 | self.assertEqual(set(IDASettings.get_directory_plugin_names()), set([])) 286 | except PermissionError: 287 | g_logger.warning("swallowing PermissionError during testing") 288 | 289 | def test_idb_plugin_names(self): 290 | try: 291 | self.assertEqual(set(IDASettings.get_idb_plugin_names()), set([])) 292 | 293 | s1 = IDASettings(PLUGIN_1).idb 294 | with clearing(s1): 295 | s1[KEY_1] = VALUE_1 296 | self.assertEqual(set(IDASettings.get_idb_plugin_names()), set([PLUGIN_1])) 297 | 298 | s2 = IDASettings(PLUGIN_2).idb 299 | with clearing(s2): 300 | s2[KEY_1] = VALUE_1 301 | self.assertEqual(set(IDASettings.get_idb_plugin_names()), set([PLUGIN_1, PLUGIN_2])) 302 | 303 | self.assertEqual(set(IDASettings.get_idb_plugin_names()), set([])) 304 | except PermissionError: 305 | g_logger.warning("swallowing PermissionError during testing") 306 | 307 | 308 | def main(): 309 | try: 310 | unittest.main() 311 | except SystemExit: 312 | pass 313 | 314 | 315 | if __name__ == "__main__": 316 | main() 317 | -------------------------------------------------------------------------------- /ida_settings/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williballenthin/ida-settings/6f693d74d622cf295591bddcdb302a174d25b920/ida_settings/ui/__init__.py -------------------------------------------------------------------------------- /ida_settings/ui/ida_settings_viewer.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from PyQt5 import QtGui, QtCore, QtWidgets 4 | from idaapi import PluginForm 5 | 6 | import ida_settings 7 | 8 | 9 | class IdaSettingsEditor(PluginForm): 10 | def OnCreate(self, form): 11 | # 6.8 and below 12 | # self.parent = self.FormToPySideWidget(form) 13 | # 6.9 and above 14 | self.parent = self.FormToPyQtWidget(form) 15 | self.PopulateForm() 16 | 17 | def PopulateForm(self): 18 | """ 19 | +-----------------------------------------------------------------------+ 20 | | +--- splitter ------------------------------------------------------+ | 21 | | | +-- list widget--------------+ +- IdaSettingsView -------------+ | | 22 | | | | | | | | | 23 | | | | - plugin name | | | | | 24 | | | | - plugin name | | | | | 25 | | | | - plugin name | | | | | 26 | | | | | | | | | 27 | | | | | | | | | 28 | | | | | | | | | 29 | | | | | | | | | 30 | | | | | | | | | 31 | | | | | | | | | 32 | | | | | | | | | 33 | | | | | | | | | 34 | | | | | | | | | 35 | | | | | | | | | 36 | | | | | | | | | 37 | | | | | | | | | 38 | | | | | | | | | 39 | | | +----------------------------+ +-------------------------------+ | | 40 | | +-------------------------------------------------------------------+ | 41 | +-----------------------------------------------------------------------+ 42 | """ 43 | hbox = QtWidgets.QHBoxLayout(self.parent) 44 | 45 | self._splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) 46 | self._plugin_list = QtWidgets.QListWidget() 47 | 48 | plugin_names = set([]) 49 | for scope, fn in ( 50 | ("idb", ida_settings.IDASettings.get_idb_plugin_names), 51 | ("directory", ida_settings.IDASettings.get_directory_plugin_names), 52 | ("user", ida_settings.IDASettings.get_user_plugin_names), 53 | ("system", ida_settings.IDASettings.get_system_plugin_names), 54 | ): 55 | for plugin_name in fn(): 56 | plugin_names.add(plugin_name) 57 | for plugin_name in plugin_names: 58 | self._plugin_list.addItem(plugin_name) 59 | self._splitter.addWidget(self._plugin_list) 60 | 61 | hbox.addWidget(self._splitter) 62 | self.parent.setLayout(hbox) 63 | 64 | self._plugin_list.currentItemChanged.connect(self._handle_plugin_changed) 65 | 66 | def _clear_settings_widgets(self): 67 | for i in range(1, self._splitter.count()): 68 | w = self._splitter.widget(i) 69 | if w is not None: 70 | w.deleteLater() 71 | 72 | def _set_settings_widget(self, settings): 73 | self._clear_settings_widgets() 74 | w = IdaSettingsView(settings, parent=self.parent) 75 | self._splitter.insertWidget(1, w) 76 | 77 | def _handle_plugin_changed(self, current, previous): 78 | plugin_name = str(current.text()) 79 | settings = ida_settings.IDASettings(plugin_name) 80 | self._set_settings_widget(settings) 81 | 82 | 83 | class IdaSettingsView(QtWidgets.QWidget): 84 | def __init__(self, settings, parent=None): 85 | """ 86 | +-----------------------------------------------------------------------+ 87 | | +--- hbox ----------------------------------------------------------+ | 88 | | | +-- list widget--------------+ +- vbox ------------------------+ | | 89 | | | | | | +- QTextEdit ---------------+ | | | 90 | | | | - key | | | | | | | 91 | | | | - key | | | value | | | | 92 | | | | - key | | | | | | | 93 | | | | | | | | | | | 94 | | | | | | | | | | | 95 | | | | | | | | | | | 96 | | | | | | | | | | | 97 | | | | | | | | | | | 98 | | | | | | +---------------------------+ | | | 99 | | | | | | | | | 100 | | | | | | +- QPushButton -------------+ | | | 101 | | | | | | | | | | | 102 | | | | | | | save | | | | 103 | | | | | | | | | | | 104 | | | | | | +---------------------------+ | | | 105 | | | +----------------------------+ +-------------------------------+ | | 106 | | +-------------------------------------------------------------------+ | 107 | +-----------------------------------------------------------------------+ 108 | """ 109 | super(IdaSettingsView, self).__init__(parent=parent) 110 | self._settings = settings 111 | self._current_key = None 112 | self._current_scope = None 113 | 114 | hbox = QtWidgets.QHBoxLayout(self) 115 | self._key_list = QtWidgets.QListWidget() 116 | for scope, keys in ( 117 | ("idb", iter(self._settings.idb.keys())), 118 | ("directory", iter(self._settings.directory.keys())), 119 | ("user", iter(self._settings.user.keys())), 120 | ("system", iter(self._settings.system.keys())), 121 | ): 122 | for key in keys: 123 | self._key_list.addItem("({scope:s}) {key:s}".format(scope=scope, key=key)) 124 | 125 | hbox.addWidget(self._key_list) 126 | 127 | vbox = QtWidgets.QVBoxLayout(self) 128 | self._value_view = QtWidgets.QTextEdit(self) 129 | self._save_button = QtWidgets.QPushButton("save") 130 | vbox.addWidget(self._value_view) 131 | vbox.addWidget(self._save_button) 132 | 133 | hbox.addLayout(vbox) 134 | 135 | self._key_list.currentItemChanged.connect(self._handle_key_changed) 136 | self._save_button.clicked.connect(self._handle_save_value) 137 | 138 | self.setLayout(hbox) 139 | 140 | def _handle_key_changed(self, current, previous): 141 | if current is None: 142 | return 143 | 144 | self._value_view.clear() 145 | scope, _, key = str(current.text()).partition(" ") 146 | scope = scope.lstrip("(").rstrip(")") 147 | self._current_scope = scope 148 | self._current_key = key 149 | 150 | v = getattr(self._settings, scope)[key] 151 | self._value_view.setText(json.dumps(v)) 152 | 153 | def _handle_save_value(self): 154 | v = str(self._value_view.toPlainText()) 155 | s = getattr(self._settings, self._current_scope) 156 | s[self._current_key] = json.loads(v) 157 | 158 | 159 | def main(): 160 | v = IdaSettingsEditor() 161 | v.Show("Plugin Settings Editor") 162 | 163 | 164 | if __name__ == "__main__": 165 | main() 166 | -------------------------------------------------------------------------------- /img/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williballenthin/ida-settings/6f693d74d622cf295591bddcdb302a174d25b920/img/ui.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ida_settings provides a mechanism for settings and fetching 2 | configration values for IDAPython scripts and plugins. 3 | Configurations are namespaced by plugin name, 4 | and scoped to the global system, current user, 5 | working directory, or current IDB file. Configurations 6 | can be exported and imported using an .ini-style intermediate 7 | representation. 8 | 9 | Example fetching a configuration value: 10 | 11 | settings = IDASettings("MSDN-doc") 12 | if settings["verbosity"] == "high": 13 | ... 14 | 15 | Example setting a global configuration value: 16 | 17 | settings = IDASettings("MSDN-doc") 18 | settings.system["verbosity"] = "high" 19 | 20 | Example setting a working directory configuration value: 21 | 22 | settings = IDASettings("MSDN-doc") 23 | settings.directory["verbosity"] = "high" 24 | 25 | Use the properties "system", "user", "directory" and "idb" 26 | to scope configuration accesses and mutations to the global 27 | system, current user, working directory, or current IDB file. 28 | 29 | Plugins that write settings should pick the appropriate 30 | scope for their settings. Plugins that read settings should 31 | fetch them from the default scope. This allows for precedence 32 | of scopes, such as the current IDB over system-wide configuration. 33 | For example: 34 | 35 | settings = IDASettings("MSDN-doc") 36 | # when writing, use a scope: 37 | settings.user["verbosity"] = "high" 38 | 39 | # when reading, use the default scope: 40 | settings["verbosity"] --> "high" 41 | 42 | Generally, treat a settings instance like a dictionary. For example: 43 | 44 | settings = IDASettings("MSDN-doc") 45 | "verbosity" in settings.user --> False 46 | settings.user["verbosity"] = "high" 47 | settings.user["verbosity"] --> "high" 48 | settings.user.keys() --> ["verbosity"] 49 | settings.user.values() --> ["high"] 50 | settings.user.items() --> [("verbosity", "high')] 51 | 52 | The value of a particular settings entry must be a JSON-encodable 53 | value. For example, these are fine: 54 | 55 | settings = IDASettings("MSDN-doc") 56 | settings.user["verbosity"] = "high" 57 | settings.user["count"] = 1 58 | settings.user["percentage"] = 0.75 59 | settings.user["filenames"] = ["a.txt", "b.txt"] 60 | settings.user["aliases"] = {"bp": "breakpoint", "g": "go"} 61 | 62 | and this is not: 63 | 64 | settings.user["object"] = hashlib.md5() # this is not JSON-encodable 65 | 66 | To export the current effective settings, use the `export_settings` 67 | function. For example: 68 | 69 | settings = IDASettings("MSDN-doc") 70 | export_settings(settings, "/home/user/desktop/current.ini") 71 | 72 | To import existing settings into a settings instance, such as 73 | the open IDB, use the `import_settings` function. For example: 74 | 75 | settings = IDASettings("MSDN-doc") 76 | import_settings(settings.idb, "/home/user/desktop/current.ini") 77 | 78 | Enumerate the plugin names for the various levels using the 79 | IDASettings class properties: 80 | 81 | IDASettings.get_system_plugin_names() --> ["plugin-1", "plugin-2"] 82 | IDASettings.get_user_plugin_names() --> ["plugin-3", "plugin-4"] 83 | IDASettings.get_directory_plugin_names() --> ["plugin-5", "plugin-6"] 84 | IDASettings.get_idb_plugin_names() --> ["plugin-7", "plugin-8"] 85 | 86 | This module is a single file that you can include in IDAPython 87 | plugin module or scripts. 88 | 89 | It depends on ida-netnode, which you can download here: 90 | https://github.com/williballenthin/ida-netnode 91 | 92 | This project is licensed under the Apache 2.0 license. 93 | 94 | Author: Willi Ballenthin 95 | 96 | ## settings editor 97 | 98 | Run this script `ida_settings/ui/ida_settings_viewer.py` as an IDAPython script to review, modify, and save settings from the system, user, directory, and IDB scopes on a per-plugin basis. 99 | 100 | ![UI Screenshot](/img/ui.png?raw=true "UI Screenshot") 101 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name="ida-settings", 7 | version="2.1.0", 8 | description="Fetch and set configuration values in IDA Pro IDAPython scripts", 9 | author="Willi Ballenthin", 10 | author_email="william.ballenthin@fireeye.com", 11 | url="https://github.com/williballenthin/ida-settings", 12 | license="Apache License (2.0)", 13 | packages=["ida_settings"], 14 | install_requires=["ida-netnode", "six"], 15 | classifiers=[ 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 2", 18 | "Operating System :: OS Independent", 19 | "License :: OSI Approved :: Apache Software License", 20 | ], 21 | ) 22 | --------------------------------------------------------------------------------