voidaccess 1.9.4__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. {voidaccess-1.9.4/voidaccess.egg-info → voidaccess-2.0.1}/PKG-INFO +4 -1
  2. voidaccess-2.0.1/alembic.ini +58 -0
  3. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/main.py +16 -1
  4. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/entities.py +102 -4
  5. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/export.py +16 -0
  6. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/investigations.py +158 -15
  7. {voidaccess-1.9.4 → voidaccess-2.0.1}/config.py +22 -0
  8. {voidaccess-1.9.4 → voidaccess-2.0.1}/crawler/spider.py +123 -68
  9. voidaccess-2.0.1/data/__init__.py +13 -0
  10. voidaccess-2.0.1/data/common_words_en.txt +370079 -0
  11. voidaccess-2.0.1/data/domain_stopwords_en.txt +192 -0
  12. voidaccess-2.0.1/data/monitors.yaml +1 -0
  13. voidaccess-2.0.1/data/seed_data.json.gz +0 -0
  14. voidaccess-2.0.1/data/threat_gazetteer.json +63847 -0
  15. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/queries.py +5 -2
  16. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/search_engine_stats.py +34 -4
  17. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/ioc_package.py +1 -1
  18. voidaccess-2.0.1/extractor/dependency_relationship.py +314 -0
  19. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/entity_shape.py +31 -3
  20. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/gazetteer.py +76 -9
  21. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/ner.py +18 -1
  22. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/normalizer.py +47 -2
  23. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/pipeline.py +239 -0
  24. voidaccess-2.0.1/extractor/software_suppression.py +293 -0
  25. voidaccess-2.0.1/pacing/__init__.py +579 -0
  26. {voidaccess-1.9.4 → voidaccess-2.0.1}/pyproject.toml +32 -3
  27. {voidaccess-1.9.4 → voidaccess-2.0.1}/scraper/scrape.py +278 -70
  28. voidaccess-2.0.1/scraper/scrape_js.py +457 -0
  29. voidaccess-2.0.1/scraper/tor_pool.py +368 -0
  30. {voidaccess-1.9.4 → voidaccess-2.0.1}/search/__init__.py +40 -41
  31. voidaccess-2.0.1/search/config.py +45 -0
  32. {voidaccess-1.9.4 → voidaccess-2.0.1}/search/search.py +63 -70
  33. voidaccess-2.0.1/search/tor_isolation.py +94 -0
  34. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/blockchain.py +17 -1
  35. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/breach_lookup.py +93 -41
  36. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/data/onion_seeds.json +522 -522
  37. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/dns_enrichment.py +16 -2
  38. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/domain_reputation.py +93 -6
  39. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/engines.py +63 -44
  40. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/enrichment.py +1732 -1620
  41. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/github_scraper.py +46 -15
  42. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/gitlab_scraper.py +36 -16
  43. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/historical_intel.py +13 -1
  44. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/infostealer.py +41 -14
  45. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/ip_reputation.py +97 -4
  46. voidaccess-2.0.1/sources/nvd.py +444 -0
  47. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/paste_scraper.py +19 -3
  48. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/pastes.py +30 -20
  49. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/rss_scraper.py +33 -3
  50. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/seed_manager.py +25 -13
  51. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/shodan.py +2 -1
  52. voidaccess-2.0.1/sources/virustotal.py +168 -0
  53. voidaccess-2.0.1/tests/test_actor_extraction_gap.py +30 -0
  54. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_api.py +54 -0
  55. voidaccess-2.0.1/tests/test_cli_manifest.py +17 -0
  56. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_cli_proxy_config.py +8 -2
  57. voidaccess-2.0.1/tests/test_community_detection_parity.py +201 -0
  58. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_crawler.py +12 -8
  59. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_db.py +58 -0
  60. voidaccess-2.0.1/tests/test_dependency_relationship.py +110 -0
  61. voidaccess-2.0.1/tests/test_entity_priority.py +83 -0
  62. voidaccess-2.0.1/tests/test_gazetteer.py +55 -0
  63. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_github_scraper.py +30 -9
  64. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_gitlab_scraper.py +22 -16
  65. voidaccess-2.0.1/tests/test_heuristic_expansion.py +125 -0
  66. voidaccess-2.0.1/tests/test_ioc_package_export.py +128 -0
  67. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_monitor_integration.py +6 -9
  68. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_nvd.py +30 -0
  69. voidaccess-2.0.1/tests/test_pacing.py +432 -0
  70. voidaccess-2.0.1/tests/test_pacing_enrichment.py +517 -0
  71. voidaccess-2.0.1/tests/test_pacing_quotas.py +790 -0
  72. voidaccess-2.0.1/tests/test_priority_score_cli_api_parity.py +76 -0
  73. voidaccess-2.0.1/tests/test_requirement_bugs.py +76 -0
  74. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_rss_scraper.py +51 -6
  75. voidaccess-2.0.1/tests/test_scrape_isolation.py +239 -0
  76. voidaccess-2.0.1/tests/test_sigma_export.py +85 -0
  77. voidaccess-2.0.1/tests/test_software_suppression.py +84 -0
  78. voidaccess-2.0.1/utils/entity_priority.py +189 -0
  79. {voidaccess-1.9.4 → voidaccess-2.0.1}/vector/store.py +0 -3
  80. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess/llm.py +189 -17
  81. {voidaccess-1.9.4 → voidaccess-2.0.1/voidaccess.egg-info}/PKG-INFO +4 -1
  82. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess.egg-info/SOURCES.txt +29 -0
  83. voidaccess-2.0.1/voidaccess.egg-info/entry_points.txt +2 -0
  84. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess.egg-info/requires.txt +3 -0
  85. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess.egg-info/top_level.txt +2 -0
  86. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/__init__.py +1 -1
  87. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/adapters/sqlite.py +67 -1
  88. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/browser.py +15 -5
  89. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/configure.py +59 -0
  90. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/export.py +13 -3
  91. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/investigate.py +337 -51
  92. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/config.py +30 -0
  93. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/display.py +7 -3
  94. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/main.py +20 -1
  95. voidaccess-1.9.4/scraper/scrape_js.py +0 -270
  96. voidaccess-1.9.4/search/config.py +0 -13
  97. voidaccess-1.9.4/sources/nvd.py +0 -245
  98. voidaccess-1.9.4/sources/virustotal.py +0 -113
  99. voidaccess-1.9.4/voidaccess.egg-info/entry_points.txt +0 -2
  100. {voidaccess-1.9.4 → voidaccess-2.0.1}/LICENSE +0 -0
  101. {voidaccess-1.9.4 → voidaccess-2.0.1}/README.md +0 -0
  102. {voidaccess-1.9.4 → voidaccess-2.0.1}/analysis/__init__.py +0 -0
  103. {voidaccess-1.9.4 → voidaccess-2.0.1}/analysis/opsec.py +0 -0
  104. {voidaccess-1.9.4 → voidaccess-2.0.1}/analysis/patterns.py +0 -0
  105. {voidaccess-1.9.4 → voidaccess-2.0.1}/analysis/temporal.py +0 -0
  106. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/__init__.py +0 -0
  107. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/auth.py +0 -0
  108. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/errors.py +0 -0
  109. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/__init__.py +0 -0
  110. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/actors.py +0 -0
  111. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/admin.py +0 -0
  112. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/auth.py +0 -0
  113. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/monitors.py +0 -0
  114. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/search.py +0 -0
  115. {voidaccess-1.9.4 → voidaccess-2.0.1}/api/routes/settings.py +0 -0
  116. {voidaccess-1.9.4 → voidaccess-2.0.1}/auth/__init__.py +0 -0
  117. {voidaccess-1.9.4 → voidaccess-2.0.1}/auth/token_blacklist.py +0 -0
  118. {voidaccess-1.9.4 → voidaccess-2.0.1}/crawler/__init__.py +0 -0
  119. {voidaccess-1.9.4 → voidaccess-2.0.1}/crawler/dedup.py +0 -0
  120. {voidaccess-1.9.4 → voidaccess-2.0.1}/crawler/frontier.py +0 -0
  121. {voidaccess-1.9.4 → voidaccess-2.0.1}/crawler/utils.py +0 -0
  122. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/__init__.py +0 -0
  123. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/__init__.py +0 -0
  124. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/env.py +0 -0
  125. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0001_initial_schema.py +0 -0
  126. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
  127. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0002_add_missing_tables.py +0 -0
  128. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
  129. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
  130. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0005_add_extraction_method.py +0 -0
  131. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
  132. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
  133. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0008_add_users_table.py +0 -0
  134. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
  135. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
  136. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
  137. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0013_add_graph_status.py +0 -0
  138. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0015_add_progress_fields.py +0 -0
  139. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
  140. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
  141. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
  142. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
  143. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
  144. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
  145. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
  146. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
  147. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
  148. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
  149. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/migrations/versions/0026_add_entity_investigation_count.py +0 -0
  150. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/models.py +0 -0
  151. {voidaccess-1.9.4 → voidaccess-2.0.1}/db/session.py +0 -0
  152. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/__init__.py +0 -0
  153. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/_entity_loading.py +0 -0
  154. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/misp.py +0 -0
  155. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/relationships.py +0 -0
  156. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/sigma.py +0 -0
  157. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/snort_export.py +0 -0
  158. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/stix.py +0 -0
  159. {voidaccess-1.9.4 → voidaccess-2.0.1}/export/yara_export.py +0 -0
  160. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/__init__.py +0 -0
  161. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/confidence.py +0 -0
  162. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/identity.py +0 -0
  163. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/llm_extract.py +0 -0
  164. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/regex_patterns.py +0 -0
  165. {voidaccess-1.9.4 → voidaccess-2.0.1}/extractor/relationship_extract.py +0 -0
  166. {voidaccess-1.9.4 → voidaccess-2.0.1}/fingerprint/__init__.py +0 -0
  167. {voidaccess-1.9.4 → voidaccess-2.0.1}/fingerprint/calibration.py +0 -0
  168. {voidaccess-1.9.4 → voidaccess-2.0.1}/fingerprint/profiler.py +0 -0
  169. {voidaccess-1.9.4 → voidaccess-2.0.1}/fingerprint/stylometry.py +0 -0
  170. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/__init__.py +0 -0
  171. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/builder.py +0 -0
  172. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/export.py +0 -0
  173. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/model.py +0 -0
  174. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/queries.py +0 -0
  175. {voidaccess-1.9.4 → voidaccess-2.0.1}/graph/visualize.py +0 -0
  176. {voidaccess-1.9.4 → voidaccess-2.0.1}/i18n/__init__.py +0 -0
  177. {voidaccess-1.9.4 → voidaccess-2.0.1}/i18n/detect.py +0 -0
  178. {voidaccess-1.9.4 → voidaccess-2.0.1}/i18n/query_expand.py +0 -0
  179. {voidaccess-1.9.4 → voidaccess-2.0.1}/i18n/translate.py +0 -0
  180. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/__init__.py +0 -0
  181. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/_db.py +0 -0
  182. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/alerts.py +0 -0
  183. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/config.py +0 -0
  184. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/diff.py +0 -0
  185. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/jobs.py +0 -0
  186. {voidaccess-1.9.4 → voidaccess-2.0.1}/monitor/scheduler.py +0 -0
  187. {voidaccess-1.9.4 → voidaccess-2.0.1}/scraper/__init__.py +0 -0
  188. {voidaccess-1.9.4 → voidaccess-2.0.1}/search/circuit_breaker.py +0 -0
  189. {voidaccess-1.9.4 → voidaccess-2.0.1}/search/engine_catalog.py +0 -0
  190. {voidaccess-1.9.4 → voidaccess-2.0.1}/search/query_builder.py +0 -0
  191. {voidaccess-1.9.4 → voidaccess-2.0.1}/setup.cfg +0 -0
  192. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/__init__.py +0 -0
  193. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/actor_profiles.py +0 -0
  194. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/cache.py +0 -0
  195. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/cisa.py +0 -0
  196. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/data/__init__.py +0 -0
  197. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/email_reputation.py +0 -0
  198. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/hash_reputation.py +0 -0
  199. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/proxy_client.py +0 -0
  200. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/seeds.py +0 -0
  201. {voidaccess-1.9.4 → voidaccess-2.0.1}/sources/telegram.py +0 -0
  202. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_analysis_opsec.py +0 -0
  203. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_analysis_stylometry.py +0 -0
  204. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_analysis_temporal.py +0 -0
  205. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_api_monitors.py +0 -0
  206. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_blockchain.py +0 -0
  207. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_breach_lookup.py +0 -0
  208. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_confidence_calibration.py +0 -0
  209. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_config.py +0 -0
  210. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_consolidated_contracts.py +0 -0
  211. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_dns_enrichment.py +0 -0
  212. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_domain_reputation.py +0 -0
  213. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_email_reputation.py +0 -0
  214. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_extractor_llm_pipeline.py +0 -0
  215. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_filter_e2e_manual.py +0 -0
  216. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_filter_parser_manual.py +0 -0
  217. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_fingerprint.py +0 -0
  218. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_graph.py +0 -0
  219. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_graph_integration.py +0 -0
  220. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_hash_reputation.py +0 -0
  221. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_i18n.py +0 -0
  222. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_infostealer.py +0 -0
  223. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_ip_reputation.py +0 -0
  224. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_llm.py +0 -0
  225. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_llm_utils.py +0 -0
  226. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_model_singleton.py +0 -0
  227. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_monitor.py +0 -0
  228. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_pagination.py +0 -0
  229. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_paste_scraper.py +0 -0
  230. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_proxy_client.py +0 -0
  231. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_ransomlook.py +0 -0
  232. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_regex_patterns.py +0 -0
  233. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_relationship_extract.py +0 -0
  234. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_scrape_js.py +0 -0
  235. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_settings.py +0 -0
  236. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_snort_export.py +0 -0
  237. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_sources.py +0 -0
  238. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_sources_enrichment_new.py +0 -0
  239. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_stix_export.py +0 -0
  240. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_vector.py +0 -0
  241. {voidaccess-1.9.4 → voidaccess-2.0.1}/tests/test_yara_export.py +0 -0
  242. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/__init__.py +0 -0
  243. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/async_utils.py +0 -0
  244. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/content_dedup.py +0 -0
  245. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/content_safety.py +0 -0
  246. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/defang.py +0 -0
  247. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/encryption.py +0 -0
  248. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/enrichment_cache.py +0 -0
  249. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/investigation_metrics.py +0 -0
  250. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/ioc_freshness.py +0 -0
  251. {voidaccess-1.9.4 → voidaccess-2.0.1}/utils/user_keys.py +0 -0
  252. {voidaccess-1.9.4 → voidaccess-2.0.1}/vector/__init__.py +0 -0
  253. {voidaccess-1.9.4 → voidaccess-2.0.1}/vector/embedder.py +0 -0
  254. {voidaccess-1.9.4 → voidaccess-2.0.1}/vector/model_singleton.py +0 -0
  255. {voidaccess-1.9.4 → voidaccess-2.0.1}/vector/search.py +0 -0
  256. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess/__init__.py +0 -0
  257. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess/config.py +0 -0
  258. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess/llm_utils.py +0 -0
  259. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess.egg-info/dependency_links.txt +0 -0
  260. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/adapters/__init__.py +0 -0
  261. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/__init__.py +0 -0
  262. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/actors.py +0 -0
  263. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/enrich.py +0 -0
  264. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/commands/show.py +0 -0
  265. {voidaccess-1.9.4 → voidaccess-2.0.1}/voidaccess_cli/tor_detect.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidaccess
3
- Version: 1.9.4
3
+ Version: 2.0.1
4
4
  Summary: Dark web OSINT CLI — automated threat intelligence from query to report
5
5
  Author: VoidAccess
6
6
  License-Expression: MIT
@@ -45,6 +45,7 @@ Requires-Dist: networkx==3.4.2
45
45
  Requires-Dist: pydantic==2.13.3
46
46
  Requires-Dist: python-jose[cryptography]==3.5.0
47
47
  Requires-Dist: passlib[bcrypt]==1.7.4
48
+ Requires-Dist: bcrypt<4.1,>=3.1.0
48
49
  Requires-Dist: slowapi==0.1.9
49
50
  Requires-Dist: redis==7.4.0
50
51
  Requires-Dist: pyyaml==6.0.3
@@ -55,6 +56,7 @@ Requires-Dist: langdetect==1.0.9
55
56
  Requires-Dist: pyvis==0.3.2
56
57
  Requires-Dist: urllib3==2.6.3
57
58
  Requires-Dist: trafilatura==2.0.0
59
+ Requires-Dist: lxml_html_clean>=0.4.2
58
60
  Requires-Dist: requests==2.33.1
59
61
  Requires-Dist: python-socks[asyncio]==2.8.1
60
62
  Requires-Dist: tldextract==5.3.1
@@ -62,6 +64,7 @@ Requires-Dist: stix2==3.0.2
62
64
  Provides-Extra: dev
63
65
  Requires-Dist: pytest; extra == "dev"
64
66
  Requires-Dist: pytest-asyncio; extra == "dev"
67
+ Requires-Dist: aioresponses>=0.7.6; extra == "dev"
65
68
  Provides-Extra: nlp
66
69
  Requires-Dist: sentence-transformers>=3.0; extra == "nlp"
67
70
  Requires-Dist: torch>=2.3; extra == "nlp"
@@ -0,0 +1,58 @@
1
+ # Alembic configuration for VoidAccess.
2
+ # Run all alembic commands from the voidaccess/ directory.
3
+ #
4
+ # Common commands:
5
+ # alembic upgrade head — apply all pending migrations
6
+ # alembic downgrade -1 — roll back one migration
7
+ # alembic revision --autogenerate -m "describe change" — generate a new migration
8
+ # alembic history — list applied migrations
9
+
10
+ [alembic]
11
+ script_location = db:migrations
12
+ prepend_sys_path = .
13
+ file_template = %%(rev)s_%%(slug)s
14
+ timezone = UTC
15
+
16
+ # sqlalchemy.url is intentionally NOT set here.
17
+ # It is loaded from the DATABASE_URL environment variable inside db/migrations/env.py.
18
+ # This prevents credentials from ever being committed to version control.
19
+
20
+ [post_write_hooks]
21
+ # hooks = black
22
+ # black.type = console_scripts
23
+ # black.entrypoint = black
24
+ # black.options = -l 88
25
+
26
+ [loggers]
27
+ keys = root,sqlalchemy,alembic
28
+
29
+ [handlers]
30
+ keys = console
31
+
32
+ [formatters]
33
+ keys = generic
34
+
35
+ [logger_root]
36
+ level = WARN
37
+ handlers = console
38
+ qualname =
39
+
40
+ [logger_sqlalchemy]
41
+ level = WARN
42
+ handlers =
43
+ qualname = sqlalchemy.engine
44
+
45
+ [logger_alembic]
46
+ level = INFO
47
+ handlers =
48
+ qualname = alembic
49
+
50
+ [handler_console]
51
+ class = StreamHandler
52
+ args = (sys.stderr,)
53
+ level = NOTSET
54
+ formatter = generic
55
+
56
+ [formatter_generic]
57
+ format = %(levelname)-5.5s [%(name)s] %(message)s
58
+ datefmt = %H:%M:%S
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import asyncio
14
14
  import logging
15
15
  import os
16
+ import sys
16
17
  from contextlib import asynccontextmanager
17
18
  from datetime import datetime, timedelta, timezone
18
19
  from typing import Callable, Optional
@@ -331,7 +332,7 @@ async def _periodic_stuck_sweep() -> None:
331
332
  app = FastAPI(
332
333
  title="VoidAccess Intelligence API",
333
334
  description="VoidAccess: Dark Web Intelligence Platform",
334
- version="2.0.0",
335
+ version="2.0.1",
335
336
  docs_url="/docs",
336
337
  redoc_url="/redoc",
337
338
  lifespan=lifespan,
@@ -491,6 +492,13 @@ def _run_migrations() -> None:
491
492
 
492
493
  project_root = pathlib.Path(__file__).resolve().parents[1]
493
494
  ini_path = project_root / "alembic.ini"
495
+ if not ini_path.exists():
496
+ # Wheel data-files are installed at the virtualenv prefix, while
497
+ # api/ and db/ live under site-packages. Locate the shipped
498
+ # config there for non-editable/API deployments.
499
+ ini_path = pathlib.Path(sys.prefix) / "alembic.ini"
500
+ if not ini_path.exists():
501
+ raise FileNotFoundError(f"Alembic config not found: {ini_path}")
494
502
  alembic_cfg = Config(str(ini_path))
495
503
  alembic_cfg.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
496
504
  alembic_cfg.set_main_option("script_location", str(project_root / "db" / "migrations"))
@@ -620,6 +628,13 @@ async def tor_test(_=Depends(get_current_user)) -> dict:
620
628
  import aiohttp # noqa: PLC0415
621
629
  from aiohttp_socks import ProxyConnector # noqa: PLC0415
622
630
 
631
+ # Deliberately NOT the shared pool in scraper/tor_pool.py, and the one
632
+ # justified exception to the "no private Tor connector" rule: this
633
+ # endpoint exists to test whether the Tor transport itself works, so it
634
+ # must not report health through the very session cache whose health is
635
+ # in question — a pooled session would make a stale or broken cached
636
+ # connector look like a working Tor. It also fetches one fixed clearnet
637
+ # URL, so no .onion stream isolation is at stake here.
623
638
  connector = ProxyConnector.from_url(f"socks5://{TOR_PROXY_HOST}:{TOR_PROXY_PORT}")
624
639
  timeout = aiohttp.ClientTimeout(total=10)
625
640
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
@@ -85,6 +85,10 @@ async def list_entities(
85
85
  .limit(limit)
86
86
  .all()
87
87
  )
88
+ # Score each row against its own investigation (cached per
89
+ # investigation id so a page of same-investigation entities builds
90
+ # the map once), matching the CLI export and /entities/{id} exactly.
91
+ priority_cache: dict = {}
88
92
  return {
89
93
  "items": [
90
94
  {
@@ -98,6 +102,7 @@ async def list_entities(
98
102
  "investigation_id": str(e.investigation_id) if e.investigation_id else None,
99
103
  "investigation_count": getattr(e, "investigation_count", 1) or 1,
100
104
  "created_at": e.created_at.isoformat() if e.created_at else None,
105
+ **_entity_priority_fields(session, e, priority_cache),
101
106
  }
102
107
  for e in entities
103
108
  ],
@@ -150,7 +155,10 @@ async def export_entity_stix(
150
155
  })
151
156
  except Exception as exc:
152
157
  logger.warning("STIX export for entity %s failed, falling back to raw JSON: %s", entity_id, exc)
153
- json_str = json.dumps(_entity_to_dict(entity), indent=2)
158
+ json_str = json.dumps(
159
+ _entity_to_dict(entity, _entity_priority_fields(session, entity)),
160
+ indent=2,
161
+ )
154
162
 
155
163
  filename = f"voidaccess_entity_{entity_id}_stix.json"
156
164
  return Response(
@@ -184,7 +192,7 @@ async def export_entity_json(
184
192
  _assert_entity_accessible(session, eid, current_user.user.id)
185
193
 
186
194
  appearances = get_entity_appearances(session, eid, current_user.user.id)
187
- data = _entity_to_dict(entity)
195
+ data = _entity_to_dict(entity, _entity_priority_fields(session, entity))
188
196
  data["appearances"] = appearances
189
197
  json_str = json.dumps(data, indent=2, default=str)
190
198
 
@@ -629,8 +637,10 @@ async def get_entity(
629
637
  if entity.context:
630
638
  display_context = defang_text(entity.context)
631
639
 
640
+ priority = _entity_priority_fields(session, entity)
641
+
632
642
  return {
633
- **_entity_to_dict(entity),
643
+ **_entity_to_dict(entity, priority),
634
644
  "value": display_value,
635
645
  "canonical_value": display_canonical,
636
646
  "context": display_context,
@@ -798,7 +808,90 @@ def _assert_entity_accessible(session, entity_id: uuid.UUID, user_id: int) -> No
798
808
  raise HTTPException(status_code=404, detail="Entity not found")
799
809
 
800
810
 
801
- def _entity_to_dict(entity) -> dict: # type: ignore[type-arg]
811
+ # Default priority fields for an entity we could not (or chose not to) score.
812
+ # Mirrors the zero-defaults the investigation entities endpoint uses so the two
813
+ # API surfaces are shape-identical.
814
+ _EMPTY_PRIORITY: dict = {
815
+ "priority_score": 0.0,
816
+ "priority_score_components": {},
817
+ "priority_score_centrality_contribution": 0.0,
818
+ }
819
+
820
+
821
+ def _investigation_priority_map(session, investigation_id) -> dict:
822
+ """Return ``score_map`` for a whole investigation, scoped identically to the
823
+ investigation entities endpoint and the CLI export.
824
+
825
+ Prioritization is normalized against the *complete* investigation (direct +
826
+ linked entities, all relationships), never a single entity or a filtered
827
+ page — that is what makes the value returned here byte-for-byte comparable
828
+ to ``GET /investigations/{id}/entities`` and to the CLI JSON/CSV/MD export
829
+ for the same entity (see ``api/routes/investigations.py`` and
830
+ ``voidaccess_cli/commands/export.py``).
831
+ """
832
+ from db.models import Entity, EntityRelationship, InvestigationEntityLink # noqa: PLC0415
833
+ import sqlalchemy as sa # noqa: PLC0415
834
+ from utils.entity_priority import score_map # noqa: PLC0415
835
+
836
+ linked_ids = (
837
+ sa.select(InvestigationEntityLink.entity_id)
838
+ .where(InvestigationEntityLink.investigation_id == investigation_id)
839
+ )
840
+ scoped = (
841
+ session.query(Entity)
842
+ .filter(
843
+ (Entity.investigation_id == investigation_id)
844
+ | Entity.id.in_(linked_ids)
845
+ )
846
+ .all()
847
+ )
848
+ relationships = (
849
+ session.query(EntityRelationship)
850
+ .filter(EntityRelationship.investigation_id == investigation_id)
851
+ .all()
852
+ )
853
+ return score_map(scoped, relationships)
854
+
855
+
856
+ def _entity_priority_fields(session, entity, cache: Optional[dict] = None) -> dict:
857
+ """Return the three ``priority_score`` fields for *entity*, computed with the
858
+ same function and the same investigation-wide input set the CLI export uses.
859
+
860
+ ``cache`` (an optional ``dict`` keyed by investigation id) lets a list
861
+ endpoint score many entities from the same investigation without rebuilding
862
+ the score map per row. Any failure degrades to zero defaults rather than
863
+ breaking the response.
864
+ """
865
+ inv_id = getattr(entity, "investigation_id", None)
866
+ if inv_id is None:
867
+ return dict(_EMPTY_PRIORITY)
868
+ try:
869
+ if cache is not None and inv_id in cache:
870
+ pmap = cache[inv_id]
871
+ else:
872
+ pmap = _investigation_priority_map(session, inv_id)
873
+ if cache is not None:
874
+ cache[inv_id] = pmap
875
+ entry = pmap.get(str(entity.id), {})
876
+ return {
877
+ "priority_score": entry.get("priority_score", 0.0),
878
+ "priority_score_components": entry.get("priority_score_components", {}),
879
+ "priority_score_centrality_contribution": entry.get(
880
+ "priority_score_centrality_contribution", 0.0
881
+ ),
882
+ }
883
+ except Exception as exc: # pragma: no cover - defensive
884
+ logger.warning("entity priority computation failed: %s", exc)
885
+ return dict(_EMPTY_PRIORITY)
886
+
887
+
888
+ def _entity_to_dict(entity, priority: Optional[dict] = None) -> dict: # type: ignore[type-arg]
889
+ # ``priority`` carries the three prioritization fields (see
890
+ # ``_entity_priority_fields``). It is passed in by callers that hold an open
891
+ # session so the score matches the CLI export exactly; when absent we emit
892
+ # the same zero defaults as the investigation entities endpoint so the field
893
+ # is always present on the ``/entities`` surface.
894
+ priority = priority or _EMPTY_PRIORITY
802
895
  return {
803
896
  "id": str(entity.id),
804
897
  "entity_type": entity.entity_type,
@@ -816,6 +909,11 @@ def _entity_to_dict(entity) -> dict: # type: ignore[type-arg]
816
909
  "source_count": getattr(entity, "source_count", 1) or 1,
817
910
  "investigation_count": getattr(entity, "investigation_count", 1) or 1,
818
911
  "corroborating_sources": getattr(entity, "corroborating_sources", None),
912
+ "priority_score": priority.get("priority_score", 0.0),
913
+ "priority_score_components": priority.get("priority_score_components", {}),
914
+ "priority_score_centrality_contribution": priority.get(
915
+ "priority_score_centrality_contribution", 0.0
916
+ ),
819
917
  }
820
918
 
821
919
 
@@ -16,6 +16,7 @@ POST /export/{investigation_id}/<fmt>/selected — same exports rest
16
16
  from __future__ import annotations
17
17
 
18
18
  import io
19
+ import json
19
20
  import logging
20
21
  import os
21
22
  import re
@@ -474,6 +475,21 @@ async def export_package(
474
475
  investigation_dict["created_at"] = (
475
476
  inv.created_at.isoformat() if inv.created_at else None
476
477
  )
478
+ # Backfill sources_used from investigation metadata, the
479
+ # same place the CLI export reads it (adapters/sqlite.py
480
+ # ``_investigation_row``). ``metadata_json`` is a dict on
481
+ # Postgres and may be a JSON string on legacy SQLite rows.
482
+ metadata_raw = getattr(inv, "metadata_json", None)
483
+ if isinstance(metadata_raw, str):
484
+ try:
485
+ metadata_parsed = json.loads(metadata_raw) if metadata_raw.strip() else {}
486
+ except (ValueError, TypeError):
487
+ metadata_parsed = {}
488
+ elif isinstance(metadata_raw, dict):
489
+ metadata_parsed = metadata_raw
490
+ else:
491
+ metadata_parsed = {}
492
+ investigation_dict["sources_used"] = metadata_parsed.get("sources_used") or {}
477
493
  except Exception as exc:
478
494
  logger.warning("export_package: investigation lookup failed: %s", exc)
479
495
 
@@ -95,6 +95,10 @@ router = APIRouter()
95
95
  # Populated during the pipeline run; read by the GET detail endpoint.
96
96
  _infra_cluster_cache: dict[str, list] = {}
97
97
 
98
+ # In-process cache: investigation_id (str) → node-id/community-id map.
99
+ # The DB metadata copy is the durable source of truth.
100
+ _communities_cache: dict[str, dict[str, int]] = {}
101
+
98
102
  # In-process cache: investigation_id (str) → sources_used status dict.
99
103
  # Populated during the pipeline run; read by the GET detail endpoint.
100
104
  # Phase 6.1: this is now a *fast-path* cache — the DB metadata column is
@@ -124,6 +128,22 @@ def _set_infra_clusters(investigation_id: str, clusters: list) -> None:
124
128
  {"infrastructure_clusters": clusters},
125
129
  )
126
130
 
131
+
132
+ def _set_communities(investigation_id: str, communities: dict[str, int]) -> None:
133
+ """Cache and persist the server-computed community partition."""
134
+ normalized = {
135
+ str(node_id): int(community_id)
136
+ for node_id, community_id in communities.items()
137
+ }
138
+ _communities_cache[investigation_id] = normalized
139
+ _update_investigation_metadata(
140
+ investigation_id,
141
+ {
142
+ "communities": normalized,
143
+ "community_count": len(set(normalized.values())) if normalized else 0,
144
+ },
145
+ )
146
+
127
147
  # ---------------------------------------------------------------------------
128
148
  # Phase 6.2 — per-phase timeouts (Phase 6.1: caches also persisted to DB)
129
149
  # ---------------------------------------------------------------------------
@@ -411,6 +431,26 @@ async def _build_graph_phase(
411
431
  session.commit()
412
432
  except Exception as e:
413
433
  logger.info("[%s] Edge persistence failed (non-fatal): %s", inv_uuid, e)
434
+
435
+ # Match the CLI's pre-finalization community step. Feed the helper
436
+ # the complete graph, before the graph endpoint's display truncation
437
+ # to 20 nodes/50 edges; using that response slice would create an
438
+ # incomplete partition and break CLI/API parity.
439
+ try:
440
+ communities = await asyncio.to_thread(
441
+ _detect_communities_for_graph, graph_obj
442
+ )
443
+ _set_communities(str(inv_uuid), communities)
444
+ logger.info(
445
+ "[%s] Community detection: %s communities (%s nodes)",
446
+ inv_uuid,
447
+ len(set(communities.values())) if communities else 0,
448
+ len(communities),
449
+ )
450
+ except Exception as exc:
451
+ logger.warning(
452
+ "[%s] Community detection failed (non-fatal): %s", inv_uuid, exc
453
+ )
414
454
  except Exception as exc:
415
455
  logger.exception("[%s] Graph building failed: %s", inv_uuid, str(exc))
416
456
  return None
@@ -725,6 +765,7 @@ def _get_db_investigation(investigation_id: str) -> Any:
725
765
 
726
766
  db_sources_used = db_metadata.get("sources_used") or {}
727
767
  db_infra_clusters = db_metadata.get("infrastructure_clusters") or []
768
+ db_communities = db_metadata.get("communities") or {}
728
769
 
729
770
  sources_used = (
730
771
  _sources_used_cache.get(str(inv.id))
@@ -736,6 +777,15 @@ def _get_db_investigation(investigation_id: str) -> Any:
736
777
  or _infra_cluster_cache.get(investigation_id)
737
778
  or db_infra_clusters
738
779
  )
780
+ communities = (
781
+ _communities_cache.get(str(inv.id))
782
+ or _communities_cache.get(investigation_id)
783
+ or db_communities
784
+ )
785
+ communities = {
786
+ str(node_id): int(community_id)
787
+ for node_id, community_id in communities.items()
788
+ }
739
789
 
740
790
  return {
741
791
  "id": str(inv.id),
@@ -759,6 +809,8 @@ def _get_db_investigation(investigation_id: str) -> Any:
759
809
  "paste_sources_used": paste_sources_used,
760
810
  "infrastructure_clusters": infrastructure_clusters,
761
811
  "sources_used": sources_used,
812
+ "communities": communities,
813
+ "community_count": len(set(communities.values())) if communities else 0,
762
814
  "seeds_discovered": _count_discovered_seeds_for_investigation(str(inv.id)),
763
815
  }
764
816
  except HTTPException:
@@ -912,19 +964,34 @@ async def _get_investigation_model_choice(model: Optional[str]) -> tuple[str, An
912
964
  # ---------------------------------------------------------------------------
913
965
 
914
966
 
967
+ # LLM retry bounds. Unlike an enrichment client's documented interval, 65 s here
968
+ # is a guess ("outlast a 1-minute window"), so it is a fallback rather than a
969
+ # floor — a provider naming a shorter wait is honoured down to the minimum.
970
+ _LLM_RATE_LIMIT_FALLBACK = 65.0
971
+ _LLM_RATE_LIMIT_MIN_WAIT = 5.0
972
+ _LLM_RATE_LIMIT_MAX_WAIT = 300.0
973
+
974
+
915
975
  def _parse_rate_limit_reset(exc: Exception) -> float:
916
- """Extract reset timestamp from a 429 error and return seconds to wait."""
917
- import time
918
- import re
919
- exc_str = str(exc)
920
- # OpenRouter returns X-RateLimit-Reset as epoch milliseconds in metadata
921
- match = re.search(r"'X-RateLimit-Reset':\s*'?(\d{13})'?", exc_str)
922
- if match:
923
- reset_ms = int(match.group(1))
924
- wait = (reset_ms / 1000.0) - time.time() + 1.0 # +1s buffer
925
- return max(wait, 5.0)
926
- # Fallback: 65s to outlast a 60s/1-min rate-limit window
927
- return 65.0
976
+ """
977
+ Seconds to wait after an LLM 429, from the provider's own headers.
978
+
979
+ Delegates to ``pacing.retry_after_seconds`` so there is one implementation
980
+ of "the server told us how long to wait" shared with the enrichment API
981
+ clients, rather than two that can drift. The exception object is passed
982
+ through directly: LangChain buries the 429 headers in the exception message,
983
+ which that helper handles via its string branch.
984
+
985
+ The 65 s fallback outlasts a 60 s/1-min window when no header is present.
986
+ """
987
+ import pacing
988
+
989
+ return pacing.retry_after_seconds(
990
+ exc,
991
+ fallback=_LLM_RATE_LIMIT_FALLBACK,
992
+ floor=_LLM_RATE_LIMIT_MIN_WAIT,
993
+ max_wait=_LLM_RATE_LIMIT_MAX_WAIT,
994
+ )
928
995
 
929
996
 
930
997
  async def _llm_with_backoff(fn, *args, max_retries: int = 4, investigation_id: "uuid.UUID | None" = None, **kwargs):
@@ -2835,7 +2902,7 @@ async def get_investigation_entities(
2835
2902
  raise HTTPException(status_code=503, detail="Database not configured")
2836
2903
  try:
2837
2904
  from db.session import get_session
2838
- from db.models import Entity, InvestigationEntityLink
2905
+ from db.models import Entity, EntityRelationship, InvestigationEntityLink
2839
2906
  from db.queries import get_investigation_by_id_or_run
2840
2907
  from graph.builder import _make_node_id
2841
2908
  from utils.ioc_freshness import get_freshness_tag, get_freshness_display
@@ -2853,10 +2920,24 @@ async def get_investigation_entities(
2853
2920
  sa_select(InvestigationEntityLink.entity_id)
2854
2921
  .where(InvestigationEntityLink.investigation_id == inv.id)
2855
2922
  )
2856
- query = session.query(Entity).filter(
2923
+ scoped_query = session.query(Entity).filter(
2857
2924
  (Entity.investigation_id == inv.id)
2858
2925
  | Entity.id.in_(linked_ids_select)
2859
2926
  )
2927
+ # Scores are normalized against the complete investigation, not
2928
+ # the current page or confidence/type filter. This keeps ranking
2929
+ # stable across pagination and makes it comparable to the CLI
2930
+ # export for the same investigation.
2931
+ score_entities = scoped_query.all()
2932
+ relationships = (
2933
+ session.query(EntityRelationship)
2934
+ .filter(EntityRelationship.investigation_id == inv.id)
2935
+ .all()
2936
+ )
2937
+ from utils.entity_priority import score_map
2938
+ priority_by_id = score_map(score_entities, relationships)
2939
+
2940
+ query = scoped_query
2860
2941
  if entity_type:
2861
2942
  query = query.filter(Entity.entity_type == entity_type)
2862
2943
  if min_confidence > 0.0:
@@ -2929,6 +3010,18 @@ async def get_investigation_entities(
2929
3010
  "corroborating_sources": json.loads(e.corroborating_sources or '["dark_web_scrape"]'),
2930
3011
  "cross_referenced": (getattr(e, "investigation_count", 1) or 1) > 1,
2931
3012
  "graph_node_id": graph_node_id,
3013
+ "priority_score": priority_by_id.get(str(e.id), {}).get(
3014
+ "priority_score", 0.0
3015
+ ),
3016
+ "priority_score_components": priority_by_id.get(
3017
+ str(e.id), {}
3018
+ ).get("priority_score_components", {}),
3019
+ "priority_score_centrality_contribution": priority_by_id.get(
3020
+ str(e.id), {}
3021
+ ).get("priority_score_centrality_contribution", 0.0),
3022
+ "typed_relationship_degree": priority_by_id.get(
3023
+ str(e.id), {}
3024
+ ).get("typed_relationship_degree", 0),
2932
3025
  "defanged": defang,
2933
3026
  }
2934
3027
  )
@@ -2961,8 +3054,9 @@ async def export_investigation_entities_csv(
2961
3054
 
2962
3055
  try:
2963
3056
  from db.session import get_session
2964
- from db.models import Entity, InvestigationEntityLink
3057
+ from db.models import Entity, EntityRelationship, InvestigationEntityLink
2965
3058
  from db.queries import get_investigation_by_id_or_run
3059
+ from utils.entity_priority import score_map
2966
3060
 
2967
3061
  with get_session() as session:
2968
3062
  inv = get_investigation_by_id_or_run(session, inv_uuid)
@@ -2983,6 +3077,12 @@ async def export_investigation_entities_csv(
2983
3077
  )
2984
3078
  .all()
2985
3079
  )
3080
+ relationships = (
3081
+ session.query(EntityRelationship)
3082
+ .filter(EntityRelationship.investigation_id == inv.id)
3083
+ .all()
3084
+ )
3085
+ priority_by_id = score_map(entities, relationships)
2986
3086
 
2987
3087
  output = io.StringIO()
2988
3088
  writer = csv.writer(output)
@@ -2990,6 +3090,7 @@ async def export_investigation_entities_csv(
2990
3090
  "entity_type",
2991
3091
  "canonical_value",
2992
3092
  "confidence",
3093
+ "priority_score",
2993
3094
  "occurrence_count",
2994
3095
  "first_seen_page",
2995
3096
  "context_snippet",
@@ -3011,6 +3112,7 @@ async def export_investigation_entities_csv(
3011
3112
  e.entity_type,
3012
3113
  e.canonical_value or e.value,
3013
3114
  e.confidence,
3115
+ priority_by_id.get(str(e.id), {}).get("priority_score", 0.0),
3014
3116
  1,
3015
3117
  source_url,
3016
3118
  context[:500],
@@ -3080,6 +3182,27 @@ def _rebuild_networkx_graph(nodes: list, edges: list) -> "nx.DiGraph":
3080
3182
  return G
3081
3183
 
3082
3184
 
3185
+ def _detect_communities_for_graph(graph_obj) -> dict[str, int]:
3186
+ """Run the shared detector over the complete API graph.
3187
+
3188
+ ``_rebuild_networkx_graph`` is the API representation adapter. It is
3189
+ correct here because it receives the full ``to_json`` graph, not the
3190
+ endpoint's presentation slice. Its directed graph is equivalent to the
3191
+ CLI helper's simple undirected graph for detection: ``detect_communities``
3192
+ projects either input to an undirected graph before running the algorithm.
3193
+ """
3194
+ from graph.builder import detect_communities
3195
+ from graph.export import to_json
3196
+
3197
+ graph_data = to_json(graph_obj)
3198
+ rebuilt = _rebuild_networkx_graph(graph_data["nodes"], graph_data["edges"])
3199
+ partition = detect_communities(rebuilt)
3200
+ return {
3201
+ str(node_id): int(community_id)
3202
+ for node_id, community_id in partition.items()
3203
+ }
3204
+
3205
+
3083
3206
  @router.get("/{investigation_id}/graph")
3084
3207
  async def get_investigation_graph(
3085
3208
  investigation_id: str,
@@ -3106,6 +3229,20 @@ async def get_investigation_graph(
3106
3229
  if graph_status not in ("built", "complete", "skipped_overflow", "no_data"):
3107
3230
  return {"status": "pending"}
3108
3231
  internal_id = inv.id
3232
+ graph_communities = _communities_cache.get(str(internal_id), {})
3233
+ if not graph_communities:
3234
+ graph_metadata = getattr(inv, "metadata_json", None)
3235
+ if isinstance(graph_metadata, str):
3236
+ try:
3237
+ graph_metadata = json.loads(graph_metadata)
3238
+ except (ValueError, TypeError):
3239
+ graph_metadata = {}
3240
+ if isinstance(graph_metadata, dict):
3241
+ graph_communities = graph_metadata.get("communities") or {}
3242
+ graph_communities = {
3243
+ str(node_id): int(community_id)
3244
+ for node_id, community_id in graph_communities.items()
3245
+ }
3109
3246
 
3110
3247
  graph = build_graph_from_db_cached(investigation_id=internal_id) if not force_rebuild else build_graph_from_db(investigation_id=internal_id)
3111
3248
  graph_data = to_json(graph)
@@ -3119,6 +3256,12 @@ async def get_investigation_graph(
3119
3256
  "summary_stats": summary_stats(graph),
3120
3257
  "nodes": nodes,
3121
3258
  "edges": edges,
3259
+ "communities": graph_communities,
3260
+ "community_count": (
3261
+ len(set(graph_communities.values()))
3262
+ if graph_communities
3263
+ else 0
3264
+ ),
3122
3265
  "visualization_html": viz_html,
3123
3266
  }
3124
3267
  except HTTPException:
@@ -37,6 +37,16 @@ DATABASE_URL = _clean_env("DATABASE_URL")
37
37
  TOR_PROXY_HOST = _clean_env("TOR_PROXY_HOST", "127.0.0.1")
38
38
  TOR_PROXY_PORT = _clean_env("TOR_PROXY_PORT", "9050")
39
39
 
40
+ # Optional second SocksPort configured with `IsolateDestAddr`, used only by the
41
+ # Playwright renderer. Every aiohttp caller isolates by handing Tor a distinct
42
+ # SOCKS credential per target, but Playwright's driver refuses SOCKS5
43
+ # credentials outright, so that path needs Tor to do the keying itself.
44
+ # docker/Dockerfile.tor opens 9250 for exactly this. If the port is absent —
45
+ # system tor, Tor Browser, any deployment not using our container — the JS
46
+ # render path runs un-isolated and says so once; it is never silently assumed to
47
+ # be isolated. Set to an empty value to disable the probe entirely.
48
+ TOR_ISOLATION_SOCKS_PORT = _clean_env("TOR_ISOLATION_SOCKS_PORT", "9250")
49
+
40
50
  # Phase 1D — expanded sources (all optional; missing vars disable that source)
41
51
  DARKSEARCH_API_KEY = _clean_env("DARKSEARCH_API_KEY") # optional free-tier key
42
52
  TELEGRAM_API_ID = _clean_env("TELEGRAM_API_ID") # from my.telegram.org
@@ -80,6 +90,14 @@ else:
80
90
  OTX_API_KEY = _clean_env("OTX_API_KEY", "") # AlienVault OTX — free at otx.alienvault.com
81
91
  VT_API_KEY = _clean_env("VT_API_KEY", "") # VirusTotal — free tier at virustotal.com
82
92
 
93
+ # VirusTotal pacing tier. The public API allows 4 requests/minute (a 15 s gap);
94
+ # premium keys have no per-minute limit at all, and a key string is
95
+ # indistinguishable between the two, so a paying subscriber was being paced 25x
96
+ # slower than their entitlement. Set VT_API_TIER=premium to opt out of
97
+ # free-tier pacing. Unset or unrecognised means "public" — the safe default,
98
+ # since guessing premium wrongly earns a 429.
99
+ VT_API_TIER = (_clean_env("VT_API_TIER", "public") or "public").strip().lower()
100
+
83
101
  # IP Reputation Enrichment (all optional — features degrade gracefully without keys)
84
102
  ABUSEIPDB_API_KEY = _clean_env("ABUSEIPDB_API_KEY", "") # Community IP abuse reports
85
103
  GREYNOISE_API_KEY = _clean_env("GREYNOISE_API_KEY", "") # Suppresses benign scanner IPs
@@ -127,6 +145,10 @@ SCRAPINGANT_API_KEY = _clean_env("SCRAPINGANT_API_KEY", "")
127
145
  # UserApiKey registration on the API surface).
128
146
  SCRAPINGANT_PROXY_TYPE = _clean_env("SCRAPINGANT_PROXY_TYPE", "residential")
129
147
 
148
+ # Shodan InternetDB publishes NO rate limit and needs no API key (verified
149
+ # 2026-07-29) — this is VoidAccess's own courtesy pacing, not a quota. It is
150
+ # still routed through pacing.scale_delay_floor() at the call site in
151
+ # sources/shodan.py so the mechanism matches every other enrichment client.
130
152
  SHODAN_RATE_LIMIT_DELAY = 1.0 # seconds between Shodan requests (InternetDB)
131
153
  MAX_IPS_PER_INVESTIGATION = 50 # max IPs to query Shodan per investigation
132
154
  MAX_HASHES_PER_INVESTIGATION = 20 # max file hashes to query VirusTotal per investigation