voidaccess 1.9.3__tar.gz → 1.9.4__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 (231) hide show
  1. {voidaccess-1.9.3 → voidaccess-1.9.4}/PKG-INFO +1 -1
  2. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/entities.py +6 -1
  3. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/investigations.py +2 -1
  4. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/env.py +29 -1
  5. voidaccess-1.9.4/db/migrations/versions/0026_add_entity_investigation_count.py +46 -0
  6. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/models.py +5 -0
  7. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/queries.py +26 -6
  8. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/ioc_package.py +1 -1
  9. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/entity_shape.py +1 -8
  10. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/gazetteer.py +58 -8
  11. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/ner.py +20 -0
  12. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/normalizer.py +16 -1
  13. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/builder.py +16 -0
  14. {voidaccess-1.9.3 → voidaccess-1.9.4}/pyproject.toml +1 -1
  15. {voidaccess-1.9.3 → voidaccess-1.9.4}/search/__init__.py +15 -13
  16. voidaccess-1.9.4/search/config.py +13 -0
  17. {voidaccess-1.9.3 → voidaccess-1.9.4}/search/search.py +15 -13
  18. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/data/onion_seeds.json +11 -1
  19. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/ioc_freshness.py +13 -4
  20. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/PKG-INFO +1 -1
  21. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/SOURCES.txt +2 -0
  22. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/__init__.py +1 -1
  23. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/adapters/sqlite.py +3 -0
  24. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/browser.py +2 -0
  25. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/export.py +3 -1
  26. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/investigate.py +33 -4
  27. {voidaccess-1.9.3 → voidaccess-1.9.4}/LICENSE +0 -0
  28. {voidaccess-1.9.3 → voidaccess-1.9.4}/README.md +0 -0
  29. {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/__init__.py +0 -0
  30. {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/opsec.py +0 -0
  31. {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/patterns.py +0 -0
  32. {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/temporal.py +0 -0
  33. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/__init__.py +0 -0
  34. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/auth.py +0 -0
  35. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/errors.py +0 -0
  36. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/main.py +0 -0
  37. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/__init__.py +0 -0
  38. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/actors.py +0 -0
  39. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/admin.py +0 -0
  40. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/auth.py +0 -0
  41. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/export.py +0 -0
  42. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/monitors.py +0 -0
  43. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/search.py +0 -0
  44. {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/settings.py +0 -0
  45. {voidaccess-1.9.3 → voidaccess-1.9.4}/auth/__init__.py +0 -0
  46. {voidaccess-1.9.3 → voidaccess-1.9.4}/auth/token_blacklist.py +0 -0
  47. {voidaccess-1.9.3 → voidaccess-1.9.4}/config.py +0 -0
  48. {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/__init__.py +0 -0
  49. {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/dedup.py +0 -0
  50. {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/frontier.py +0 -0
  51. {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/spider.py +0 -0
  52. {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/utils.py +0 -0
  53. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/__init__.py +0 -0
  54. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/__init__.py +0 -0
  55. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0001_initial_schema.py +0 -0
  56. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
  57. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0002_add_missing_tables.py +0 -0
  58. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
  59. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
  60. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0005_add_extraction_method.py +0 -0
  61. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
  62. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
  63. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0008_add_users_table.py +0 -0
  64. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
  65. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
  66. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
  67. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0013_add_graph_status.py +0 -0
  68. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0015_add_progress_fields.py +0 -0
  69. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
  70. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
  71. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
  72. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
  73. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
  74. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
  75. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
  76. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
  77. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
  78. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
  79. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/search_engine_stats.py +0 -0
  80. {voidaccess-1.9.3 → voidaccess-1.9.4}/db/session.py +0 -0
  81. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/__init__.py +0 -0
  82. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/_entity_loading.py +0 -0
  83. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/misp.py +0 -0
  84. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/relationships.py +0 -0
  85. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/sigma.py +0 -0
  86. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/snort_export.py +0 -0
  87. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/stix.py +0 -0
  88. {voidaccess-1.9.3 → voidaccess-1.9.4}/export/yara_export.py +0 -0
  89. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/__init__.py +0 -0
  90. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/confidence.py +0 -0
  91. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/identity.py +0 -0
  92. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/llm_extract.py +0 -0
  93. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/pipeline.py +0 -0
  94. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/regex_patterns.py +0 -0
  95. {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/relationship_extract.py +0 -0
  96. {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/__init__.py +0 -0
  97. {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/calibration.py +0 -0
  98. {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/profiler.py +0 -0
  99. {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/stylometry.py +0 -0
  100. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/__init__.py +0 -0
  101. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/export.py +0 -0
  102. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/model.py +0 -0
  103. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/queries.py +0 -0
  104. {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/visualize.py +0 -0
  105. {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/__init__.py +0 -0
  106. {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/detect.py +0 -0
  107. {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/query_expand.py +0 -0
  108. {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/translate.py +0 -0
  109. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/__init__.py +0 -0
  110. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/_db.py +0 -0
  111. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/alerts.py +0 -0
  112. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/config.py +0 -0
  113. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/diff.py +0 -0
  114. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/jobs.py +0 -0
  115. {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/scheduler.py +0 -0
  116. {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/__init__.py +0 -0
  117. {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/scrape.py +0 -0
  118. {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/scrape_js.py +0 -0
  119. {voidaccess-1.9.3 → voidaccess-1.9.4}/search/circuit_breaker.py +0 -0
  120. {voidaccess-1.9.3 → voidaccess-1.9.4}/search/engine_catalog.py +0 -0
  121. {voidaccess-1.9.3 → voidaccess-1.9.4}/search/query_builder.py +0 -0
  122. {voidaccess-1.9.3 → voidaccess-1.9.4}/setup.cfg +0 -0
  123. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/__init__.py +0 -0
  124. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/actor_profiles.py +0 -0
  125. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/blockchain.py +0 -0
  126. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/breach_lookup.py +0 -0
  127. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/cache.py +0 -0
  128. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/cisa.py +0 -0
  129. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/data/__init__.py +0 -0
  130. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/dns_enrichment.py +0 -0
  131. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/domain_reputation.py +0 -0
  132. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/email_reputation.py +0 -0
  133. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/engines.py +0 -0
  134. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/enrichment.py +0 -0
  135. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/github_scraper.py +0 -0
  136. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/gitlab_scraper.py +0 -0
  137. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/hash_reputation.py +0 -0
  138. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/historical_intel.py +0 -0
  139. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/infostealer.py +0 -0
  140. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/ip_reputation.py +0 -0
  141. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/nvd.py +0 -0
  142. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/paste_scraper.py +0 -0
  143. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/pastes.py +0 -0
  144. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/proxy_client.py +0 -0
  145. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/rss_scraper.py +0 -0
  146. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/seed_manager.py +0 -0
  147. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/seeds.py +0 -0
  148. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/shodan.py +0 -0
  149. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/telegram.py +0 -0
  150. {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/virustotal.py +0 -0
  151. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_opsec.py +0 -0
  152. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_stylometry.py +0 -0
  153. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_temporal.py +0 -0
  154. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_api.py +0 -0
  155. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_api_monitors.py +0 -0
  156. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_blockchain.py +0 -0
  157. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_breach_lookup.py +0 -0
  158. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_cli_proxy_config.py +0 -0
  159. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_confidence_calibration.py +0 -0
  160. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_config.py +0 -0
  161. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_consolidated_contracts.py +0 -0
  162. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_crawler.py +0 -0
  163. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_db.py +0 -0
  164. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_dns_enrichment.py +0 -0
  165. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_domain_reputation.py +0 -0
  166. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_email_reputation.py +0 -0
  167. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_extractor_llm_pipeline.py +0 -0
  168. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_filter_e2e_manual.py +0 -0
  169. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_filter_parser_manual.py +0 -0
  170. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_fingerprint.py +0 -0
  171. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_github_scraper.py +0 -0
  172. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_gitlab_scraper.py +0 -0
  173. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_graph.py +0 -0
  174. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_graph_integration.py +0 -0
  175. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_hash_reputation.py +0 -0
  176. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_i18n.py +0 -0
  177. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_infostealer.py +0 -0
  178. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_ip_reputation.py +0 -0
  179. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_llm.py +0 -0
  180. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_llm_utils.py +0 -0
  181. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_model_singleton.py +0 -0
  182. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_monitor.py +0 -0
  183. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_monitor_integration.py +0 -0
  184. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_nvd.py +0 -0
  185. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_pagination.py +0 -0
  186. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_paste_scraper.py +0 -0
  187. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_proxy_client.py +0 -0
  188. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_ransomlook.py +0 -0
  189. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_regex_patterns.py +0 -0
  190. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_relationship_extract.py +0 -0
  191. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_rss_scraper.py +0 -0
  192. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_scrape_js.py +0 -0
  193. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_settings.py +0 -0
  194. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_snort_export.py +0 -0
  195. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_sources.py +0 -0
  196. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_sources_enrichment_new.py +0 -0
  197. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_stix_export.py +0 -0
  198. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_vector.py +0 -0
  199. {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_yara_export.py +0 -0
  200. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/__init__.py +0 -0
  201. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/async_utils.py +0 -0
  202. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/content_dedup.py +0 -0
  203. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/content_safety.py +0 -0
  204. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/defang.py +0 -0
  205. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/encryption.py +0 -0
  206. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/enrichment_cache.py +0 -0
  207. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/investigation_metrics.py +0 -0
  208. {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/user_keys.py +0 -0
  209. {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/__init__.py +0 -0
  210. {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/embedder.py +0 -0
  211. {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/model_singleton.py +0 -0
  212. {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/search.py +0 -0
  213. {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/store.py +0 -0
  214. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/__init__.py +0 -0
  215. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/config.py +0 -0
  216. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/llm.py +0 -0
  217. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/llm_utils.py +0 -0
  218. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/dependency_links.txt +0 -0
  219. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/entry_points.txt +0 -0
  220. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/requires.txt +0 -0
  221. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/top_level.txt +0 -0
  222. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/adapters/__init__.py +0 -0
  223. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/__init__.py +0 -0
  224. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/actors.py +0 -0
  225. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/configure.py +0 -0
  226. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/enrich.py +0 -0
  227. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/show.py +0 -0
  228. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/config.py +0 -0
  229. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/display.py +0 -0
  230. {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/main.py +0 -0
  231. {voidaccess-1.9.3 → voidaccess-1.9.4}/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.3
3
+ Version: 1.9.4
4
4
  Summary: Dark web OSINT CLI — automated threat intelligence from query to report
5
5
  Author: VoidAccess
6
6
  License-Expression: MIT
@@ -96,6 +96,7 @@ async def list_entities(
96
96
  "context_snippet": e.context_snippet,
97
97
  "context": e.context,
98
98
  "investigation_id": str(e.investigation_id) if e.investigation_id else None,
99
+ "investigation_count": getattr(e, "investigation_count", 1) or 1,
99
100
  "created_at": e.created_at.isoformat() if e.created_at else None,
100
101
  }
101
102
  for e in entities
@@ -643,8 +644,9 @@ async def get_entity(
643
644
  "freshness_label": freshness_display["label"],
644
645
  "freshness_color": freshness_display["color"],
645
646
  "source_count": entity.source_count or 1,
647
+ "investigation_count": getattr(entity, "investigation_count", 1) or 1,
646
648
  "corroborating_sources": json.loads(entity.corroborating_sources or '["dark_web_scrape"]'),
647
- "cross_referenced": (entity.source_count or 1) > 1,
649
+ "cross_referenced": (getattr(entity, "investigation_count", 1) or 1) > 1,
648
650
  "defanged": defang,
649
651
  "blockchain_data": {
650
652
  "wallet_type": entity.entity_type if entity.entity_type in ("BITCOIN_ADDRESS", "ETHEREUM_ADDRESS", "MONERO_ADDRESS") else None,
@@ -811,6 +813,9 @@ def _entity_to_dict(entity) -> dict: # type: ignore[type-arg]
811
813
  "investigation_id": str(entity.investigation_id) if entity.investigation_id else None,
812
814
  "created_at": entity.created_at.isoformat() if entity.created_at else None,
813
815
  "extraction_method": getattr(entity, "extraction_method", None),
816
+ "source_count": getattr(entity, "source_count", 1) or 1,
817
+ "investigation_count": getattr(entity, "investigation_count", 1) or 1,
818
+ "corroborating_sources": getattr(entity, "corroborating_sources", None),
814
819
  }
815
820
 
816
821
 
@@ -2925,8 +2925,9 @@ async def get_investigation_entities(
2925
2925
  "freshness_label": freshness_display["label"],
2926
2926
  "freshness_color": freshness_display["color"],
2927
2927
  "source_count": e.source_count or 1,
2928
+ "investigation_count": getattr(e, "investigation_count", 1) or 1,
2928
2929
  "corroborating_sources": json.loads(e.corroborating_sources or '["dark_web_scrape"]'),
2929
- "cross_referenced": (e.source_count or 1) > 1,
2930
+ "cross_referenced": (getattr(e, "investigation_count", 1) or 1) > 1,
2930
2931
  "graph_node_id": graph_node_id,
2931
2932
  "defanged": defang,
2932
2933
  }
@@ -11,7 +11,7 @@ import sys
11
11
  from logging.config import fileConfig
12
12
 
13
13
  from alembic import context
14
- from sqlalchemy import engine_from_config, pool
14
+ from sqlalchemy import engine_from_config, inspect, pool, text
15
15
 
16
16
  # Make sure the project root is on sys.path so `from db.models import Base`
17
17
  # resolves correctly regardless of where alembic is invoked from.
@@ -36,6 +36,33 @@ if config.config_file_name is not None:
36
36
  target_metadata = Base.metadata
37
37
 
38
38
 
39
+ def _ensure_version_column_capacity(connection) -> None:
40
+ """Keep legacy PostgreSQL Alembic tables able to store current revision IDs.
41
+
42
+ The initial Alembic table used VARCHAR(32), but later revision identifiers
43
+ are longer than 32 characters. Widen the metadata column before Alembic
44
+ writes the next revision; this touches only migration bookkeeping, never
45
+ application data.
46
+ """
47
+ if connection.dialect.name != "postgresql":
48
+ return
49
+ inspector = inspect(connection)
50
+ if "alembic_version" not in inspector.get_table_names():
51
+ return
52
+ version_column = next(
53
+ (c for c in inspector.get_columns("alembic_version") if c["name"] == "version_num"),
54
+ None,
55
+ )
56
+ current_length = getattr(version_column.get("type"), "length", None) if version_column else None
57
+ if current_length is not None and current_length < 255:
58
+ connection.execute(
59
+ text(
60
+ "ALTER TABLE alembic_version "
61
+ "ALTER COLUMN version_num TYPE VARCHAR(255)"
62
+ )
63
+ )
64
+
65
+
39
66
  def run_migrations_offline() -> None:
40
67
  """
41
68
  Run migrations without a live DB connection.
@@ -64,6 +91,7 @@ def run_migrations_online() -> None:
64
91
  poolclass=pool.NullPool, # no pooling needed for one-shot migration runs
65
92
  )
66
93
  with connectable.connect() as connection:
94
+ _ensure_version_column_capacity(connection)
67
95
  context.configure(
68
96
  connection=connection,
69
97
  target_metadata=target_metadata,
@@ -0,0 +1,46 @@
1
+ """Track distinct investigations independently from enrichment provenance."""
2
+
3
+ from alembic import op
4
+ import sqlalchemy as sa
5
+
6
+
7
+ revision = "0026_add_entity_investigation_count"
8
+ down_revision = "0025_add_cancellation_requested"
9
+ branch_labels = None
10
+ depends_on = None
11
+
12
+
13
+ def upgrade() -> None:
14
+ op.add_column(
15
+ "entities",
16
+ sa.Column("investigation_count", sa.Integer(), server_default="1", nullable=False),
17
+ )
18
+
19
+ # Backfill from the existing cross-investigation links and include the
20
+ # owner column for rows created before links were consistently recorded.
21
+ conn = op.get_bind()
22
+ entity_rows = conn.execute(
23
+ sa.text("SELECT id, investigation_id FROM entities")
24
+ ).mappings().all()
25
+ link_rows = conn.execute(
26
+ sa.text("SELECT entity_id, investigation_id FROM investigation_entity_links")
27
+ ).mappings().all()
28
+ counts: dict[object, set[object]] = {}
29
+ for row in entity_rows:
30
+ counts[row["id"]] = (
31
+ {row["investigation_id"]}
32
+ if row["investigation_id"] is not None
33
+ else set()
34
+ )
35
+ for row in link_rows:
36
+ counts.setdefault(row["entity_id"], set()).add(row["investigation_id"])
37
+
38
+ for entity_id, investigation_ids in counts.items():
39
+ conn.execute(
40
+ sa.text("UPDATE entities SET investigation_count = :count WHERE id = :id"),
41
+ {"count": max(1, len(investigation_ids)), "id": entity_id},
42
+ )
43
+
44
+
45
+ def downgrade() -> None:
46
+ op.drop_column("entities", "investigation_count")
@@ -470,6 +470,11 @@ class Entity(Base):
470
470
  source_count: Mapped[int] = mapped_column(
471
471
  sa.Integer, server_default="1", default=1
472
472
  )
473
+ # Distinct investigations that independently surfaced this canonical
474
+ # entity. This is separate from enrichment-feed provenance below.
475
+ investigation_count: Mapped[int] = mapped_column(
476
+ sa.Integer, server_default="1", default=1
477
+ )
473
478
  corroborating_sources: Mapped[Optional[str]] = mapped_column(
474
479
  sa.Text, nullable=True
475
480
  )
@@ -281,7 +281,7 @@ def create_entity(
281
281
 
282
282
  def _link_entity_to_investigation(
283
283
  session: Session, entity_id: uuid.UUID, investigation_id: uuid.UUID
284
- ) -> None:
284
+ ) -> bool:
285
285
  """Link an entity to an investigation via InvestigationEntityLink."""
286
286
  from db.models import InvestigationEntityLink
287
287
 
@@ -290,7 +290,7 @@ def _link_entity_to_investigation(
290
290
  entity_id=entity_id, investigation_id=investigation_id
291
291
  ).first()
292
292
  if existing:
293
- return
293
+ return False
294
294
 
295
295
  # Also check pending (unflushed) objects — autoflush=False means they won't
296
296
  # appear in the query above, causing UNIQUE VIOLATION on batch flush.
@@ -300,13 +300,29 @@ def _link_entity_to_investigation(
300
300
  and obj.entity_id == entity_id
301
301
  and obj.investigation_id == investigation_id
302
302
  ):
303
- return
303
+ return False
304
304
 
305
305
  link = InvestigationEntityLink(
306
306
  entity_id=entity_id,
307
307
  investigation_id=investigation_id
308
308
  )
309
309
  session.add(link)
310
+ return True
311
+
312
+
313
+ def _entity_investigation_count(session: Session, entity: Entity) -> int:
314
+ """Count distinct investigations that surfaced *entity*, including owner."""
315
+ from db.models import InvestigationEntityLink
316
+
317
+ ids = {
318
+ row[0]
319
+ for row in session.query(InvestigationEntityLink.investigation_id)
320
+ .filter_by(entity_id=entity.id)
321
+ .all()
322
+ }
323
+ if entity.investigation_id:
324
+ ids.add(entity.investigation_id)
325
+ return max(1, len(ids))
310
326
 
311
327
 
312
328
  def upsert_entity_canonical(
@@ -359,9 +375,12 @@ def upsert_entity_canonical(
359
375
  existing.last_seen = datetime.now(timezone.utc)
360
376
  # Update last_seen_at for freshness tracking
361
377
  existing.last_seen_at = datetime.now(timezone.utc)
362
- # Link to this investigation if not already linked
363
- if existing.investigation_id != investigation_id:
364
- _link_entity_to_investigation(session, existing.id, investigation_id)
378
+ # Link to this investigation if not already linked, including for
379
+ # legacy rows whose owner column predates the link table backfill.
380
+ linked = _link_entity_to_investigation(session, existing.id, investigation_id)
381
+ if linked:
382
+ session.flush()
383
+ existing.investigation_count = _entity_investigation_count(session, existing)
365
384
  return existing, False
366
385
  else:
367
386
  # Create new entity
@@ -380,6 +399,7 @@ def upsert_entity_canonical(
380
399
  session.add(entity)
381
400
  session.flush() # populate entity.id before creating the link
382
401
  _link_entity_to_investigation(session, entity.id, investigation_id)
402
+ entity.investigation_count = 1
383
403
  return entity, True
384
404
 
385
405
 
@@ -57,7 +57,7 @@ logger = logging.getLogger(__name__)
57
57
  # ---------------------------------------------------------------------------
58
58
 
59
59
  PACKAGE_FORMAT = "voidaccess-ioc-v1"
60
- PACKAGE_VERSION = "1.9.3"
60
+ PACKAGE_VERSION = "1.9.4"
61
61
  SNORT_SID_BASE = 9000001 # 9xxxxxx reserved for VoidAccess-generated rules
62
62
 
63
63
  # Map extractor upper-case entity types to the per-file bucket they belong to.
@@ -93,14 +93,7 @@ def _load_words() -> None:
93
93
 
94
94
 
95
95
  # Organisation suffixes that strongly signal a real org name.
96
- _ORG_SUFFIXES: frozenset[str] = frozenset({
97
- "inc", "incorporated", "llc", "ltd", "limited", "corp", "corporation",
98
- "gmbh", "ag", "sa", "srl", "bv", "plc", "co", "company", "group",
99
- "holdings", "technologies", "systems", "solutions", "lab", "labs", "laboratories",
100
- "networks", "software", "security", "capital", "partners", "ventures",
101
- "bank", "university", "institute", "foundation", "agency", "bureau",
102
- "industries", "international", "global", "media", "digital", "consulting",
103
- })
96
+ _ORG_SUFFIXES = gazetteer.ORG_SUFFIXES
104
97
 
105
98
  _TOKEN_RE = re.compile(r"[A-Za-z0-9]+(?:['’][A-Za-z]+)?")
106
99
  _WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
@@ -42,6 +42,24 @@ _DATA_PATH = os.path.join(
42
42
  "data",
43
43
  "threat_gazetteer.json",
44
44
  )
45
+ _DOMAIN_STOPWORDS_PATH = os.path.join(
46
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
47
+ "data",
48
+ "domain_stopwords_en.txt",
49
+ )
50
+
51
+ # Organisation suffixes are also generic name qualifiers when they appear on
52
+ # an actor/malware candidate (for example, ``LockBit Ransomware Group``).
53
+ # Keep this set here so gazetteer matching and shape validation use the same
54
+ # vocabulary without introducing a circular import through entity_shape.
55
+ ORG_SUFFIXES: frozenset[str] = frozenset({
56
+ "inc", "incorporated", "llc", "ltd", "limited", "corp", "corporation",
57
+ "gmbh", "ag", "sa", "srl", "bv", "plc", "co", "company", "group",
58
+ "holdings", "technologies", "systems", "solutions", "lab", "labs", "laboratories",
59
+ "networks", "software", "security", "capital", "partners", "ventures",
60
+ "bank", "university", "institute", "foundation", "agency", "bureau",
61
+ "industries", "international", "global", "media", "digital", "consulting",
62
+ })
45
63
 
46
64
  # Minimum canonical-key length — drops junk snapshot entries like "$$$" (which
47
65
  # canonicalise to "") or single stray characters that would over-match.
@@ -52,6 +70,7 @@ _actors: frozenset[str] = frozenset()
52
70
  _malware: frozenset[str] = frozenset()
53
71
  _ransomware: frozenset[str] = frozenset()
54
72
  _generated_at: Optional[str] = None
73
+ _generic_terms: frozenset[str] = frozenset()
55
74
 
56
75
 
57
76
  def _canonical_key(name: str) -> str:
@@ -77,8 +96,38 @@ def _keyset(names) -> frozenset[str]:
77
96
  return frozenset(out)
78
97
 
79
98
 
99
+ def _load_generic_terms() -> frozenset[str]:
100
+ """Load generic edge qualifiers used for suffix/prefix retry matching."""
101
+ terms = set(ORG_SUFFIXES)
102
+ try:
103
+ with open(_DOMAIN_STOPWORDS_PATH, encoding="utf-8") as fh:
104
+ terms.update(line.strip().lower() for line in fh if line.strip())
105
+ except FileNotFoundError:
106
+ logger.warning("Domain stopword lexicon not found at %s", _DOMAIN_STOPWORDS_PATH)
107
+ return frozenset(terms)
108
+
109
+
110
+ def _lookup_keys(name: str) -> tuple[str, ...]:
111
+ """Return the original key and edge-stripped retry keys for *name*."""
112
+ original = _canonical_key(name)
113
+ if not name:
114
+ return (original,)
115
+
116
+ terms = _generic_terms
117
+ tokens = re.findall(r"[A-Za-z0-9]+", name.lower())
118
+ while tokens and tokens[0] in terms:
119
+ tokens.pop(0)
120
+ while tokens and tokens[-1] in terms:
121
+ tokens.pop()
122
+
123
+ stripped = _canonical_key(" ".join(tokens))
124
+ if stripped and stripped != original:
125
+ return (original, stripped)
126
+ return (original,)
127
+
128
+
80
129
  def _load() -> None:
81
- global _loaded, _actors, _malware, _ransomware, _generated_at
130
+ global _loaded, _actors, _malware, _ransomware, _generated_at, _generic_terms
82
131
  if _loaded:
83
132
  return
84
133
  _loaded = True
@@ -89,6 +138,7 @@ def _load() -> None:
89
138
  _malware = _keyset(data.get("malware"))
90
139
  _ransomware = _keyset(data.get("ransomware"))
91
140
  _generated_at = data.get("generated_at")
141
+ _generic_terms = _load_generic_terms()
92
142
  logger.info(
93
143
  "Gazetteer loaded: %d actors, %d malware, %d ransomware (generated %s)",
94
144
  len(_actors), len(_malware), len(_ransomware), _generated_at,
@@ -106,17 +156,17 @@ def _load() -> None:
106
156
 
107
157
  def is_known_actor(name: str) -> bool:
108
158
  _load()
109
- return _canonical_key(name) in _actors
159
+ return any(key in _actors for key in _lookup_keys(name))
110
160
 
111
161
 
112
162
  def is_known_malware(name: str) -> bool:
113
163
  _load()
114
- return _canonical_key(name) in _malware
164
+ return any(key in _malware for key in _lookup_keys(name))
115
165
 
116
166
 
117
167
  def is_known_ransomware(name: str) -> bool:
118
168
  _load()
119
- return _canonical_key(name) in _ransomware
169
+ return any(key in _ransomware for key in _lookup_keys(name))
120
170
 
121
171
 
122
172
  def is_known(name: str, category: str) -> bool:
@@ -132,12 +182,12 @@ def is_known(name: str, category: str) -> bool:
132
182
  def category_of(name: str) -> Optional[str]:
133
183
  """Return the most specific known category for *name*, or None."""
134
184
  _load()
135
- key = _canonical_key(name)
136
- if key in _ransomware:
185
+ keys = _lookup_keys(name)
186
+ if any(key in _ransomware for key in keys):
137
187
  return "ransomware"
138
- if key in _malware:
188
+ if any(key in _malware for key in keys):
139
189
  return "malware"
140
- if key in _actors:
190
+ if any(key in _actors for key in keys):
141
191
  return "threat_actor"
142
192
  return None
143
193
 
@@ -20,6 +20,7 @@ import logging
20
20
  import re
21
21
 
22
22
  from extractor import entity_shape as _shape
23
+ from extractor import gazetteer
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
@@ -493,12 +494,31 @@ def extract_named_entities(text: str) -> dict[str, list[str]]:
493
494
  for ent in doc.ents:
494
495
  if ent.label_ == "ORG":
495
496
  candidate = ent.text.strip()
497
+ # A known actor can be returned by spaCy as an ORG when
498
+ # the surrounding generic qualifier is included in the
499
+ # span (for example, "LockBit Ransomware Group").
500
+ # Preserve the extracted value but emit the gazetteer
501
+ # classification so downstream normalization does not
502
+ # misfile it as ORGANIZATION_NAME.
503
+ category = gazetteer.category_of(candidate)
504
+ if category == "threat_actor":
505
+ result[THREAT_ACTOR_HANDLE].append(candidate)
506
+ continue
507
+ if category == "ransomware":
508
+ result[RANSOMWARE_GROUP].append(candidate)
509
+ continue
510
+ if category == "malware":
511
+ result[MALWARE_FAMILY].append(candidate)
512
+ continue
496
513
  # Accept only if it has organisation shape (proper-noun
497
514
  # structure, org suffix, brand casing) — not merely
498
515
  # "absent from a denylist".
499
516
  if _shape.evaluate(ORGANIZATION_NAME, candidate).accept:
500
517
  orgs.append(candidate)
501
518
  result[ORGANIZATION_NAME] = _dedup(orgs)
519
+ result[THREAT_ACTOR_HANDLE] = _dedup(result[THREAT_ACTOR_HANDLE])
520
+ result[MALWARE_FAMILY] = _dedup(result[MALWARE_FAMILY])
521
+ result[RANSOMWARE_GROUP] = _dedup(result[RANSOMWARE_GROUP])
502
522
 
503
523
  except Exception:
504
524
  logger.exception("extract_named_entities encountered an unexpected error")
@@ -30,6 +30,7 @@ from urllib.parse import urlparse
30
30
 
31
31
  from extractor import entity_shape as _shape
32
32
  from extractor import confidence as _conf
33
+ from extractor import gazetteer as _gazetteer
33
34
 
34
35
  logger = logging.getLogger(__name__)
35
36
 
@@ -1463,11 +1464,25 @@ def normalize_entities(
1463
1464
 
1464
1465
  noise_count = 0
1465
1466
 
1466
- for entity_type, values in raw_entities.items():
1467
+ for raw_entity_type, values in raw_entities.items():
1467
1468
  for raw_value in values:
1468
1469
  if not raw_value or not raw_value.strip():
1469
1470
  continue
1470
1471
 
1472
+ entity_type = raw_entity_type
1473
+
1474
+ # NER may label a known actor/malware span as ORGANIZATION_NAME
1475
+ # when generic qualifiers are included in the span. Let the
1476
+ # gazetteer classification win after its suffix/prefix retry,
1477
+ # while leaving unrelated organization candidates unchanged.
1478
+ if entity_type == "ORGANIZATION_NAME":
1479
+ known_category = _gazetteer.category_of(raw_value)
1480
+ entity_type = {
1481
+ "threat_actor": "THREAT_ACTOR_HANDLE",
1482
+ "malware": "MALWARE_FAMILY",
1483
+ "ransomware": "RANSOMWARE_GROUP",
1484
+ }.get(known_category, entity_type)
1485
+
1471
1486
  if entity_type in ("FILE_HASH_MD5", "FILE_HASH_SHA1", "FILE_HASH_SHA256"):
1472
1487
  if not _validate_hash_length(entity_type, raw_value):
1473
1488
  logger.debug(
@@ -39,6 +39,22 @@ logger = logging.getLogger(__name__)
39
39
 
40
40
  _CO_OCCURRENCE_MAX_ENTITIES_PER_PAGE = 40
41
41
  _CO_OCCURRENCE_MAX_EDGES_PER_PAGE = 120
42
+
43
+ # ORGANIZATION_NAME is deliberately absent from both sets below, even though it
44
+ # is the largest (or second-largest) bucket in most real runs. These sets were
45
+ # introduced by v1.7.2, the same release that filtered "ORGANIZATION_NAME NER
46
+ # noise ... generic security/programming vocabulary false positives" — the
47
+ # exclusion is that fix, not an oversight. The bucket is still ~25-45% junk on
48
+ # measured runs (values like POST, JavaScript, HTML, HTTPS, IP, SCADA, EDR sit
49
+ # alongside genuine orgs), so admitting it here would attach hundreds of
50
+ # meaningless edges to every real hub and drown the signal the semantic filter
51
+ # exists to preserve.
52
+ #
53
+ # Dependency, not a TODO: genuinely-real organizations *should* eventually be
54
+ # able to participate in co-occurrence edges. That is gated on Phase 2's
55
+ # noise-reduction work actually improving what lands in this bucket — do not
56
+ # widen eligibility before then, and re-measure the bucket's noise rate rather
57
+ # than assuming Phase 2 fixed it.
42
58
  _CO_OCCURRENCE_HUB_TYPES = frozenset({
43
59
  "THREAT_ACTOR_HANDLE",
44
60
  "RANSOMWARE_GROUP",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voidaccess"
7
- version = "1.9.3"
7
+ version = "1.9.4"
8
8
  description = "Dark web OSINT CLI — automated threat intelligence from query to report"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,10 +23,12 @@ from db.search_engine_stats import (
23
23
  from search.query_builder import diversify_query
24
24
  from utils.async_utils import run_async
25
25
  from search.engine_catalog import DEFAULT_SEARCH_ENGINES, SEARCH_ENGINES
26
+ from search import config as search_config
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
29
- ENGINE_TIMEOUT = 45
30
+ # Compatibility exports; runtime reads use the shared config module below.
31
+ ENGINE_TIMEOUT = search_config.ENGINE_TIMEOUT
30
32
 
31
33
  ENGINE_WEIGHTS = {
32
34
  "darksearch": 1.0,
@@ -57,8 +59,8 @@ USER_AGENTS = [
57
59
  _ONION_URL_RE = re.compile(r'https?:\/\/[a-z0-9.]+\.onion', re.IGNORECASE)
58
60
 
59
61
  MAX_CONCURRENT = 10
60
- SEARCH_TIMEOUT = 60
61
- ENGINE_RETRY_COUNT = 2
62
+ SEARCH_TIMEOUT = search_config.SEARCH_TIMEOUT
63
+ ENGINE_RETRY_COUNT = search_config.ENGINE_RETRY_COUNT
62
64
 
63
65
  _ENGINE_STATUS: dict[str, dict] = {}
64
66
  _LAST_SEARCH_SUMMARY: dict[str, int] = {}
@@ -109,7 +111,7 @@ def get_search_session() -> aiohttp.ClientSession:
109
111
  connector = _tor_aiohttp_connector()
110
112
  _search_session = aiohttp.ClientSession(
111
113
  connector=connector,
112
- timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT),
114
+ timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT),
113
115
  )
114
116
  return _search_session
115
117
 
@@ -129,7 +131,7 @@ async def fetch_with_timeout(
129
131
  """Fetch a URL with timeout using the provided or cached session."""
130
132
  if session is None:
131
133
  session = get_search_session()
132
- return await session.get(url, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT))
134
+ return await session.get(url, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT))
133
135
 
134
136
 
135
137
  async def _fetch_engine(
@@ -145,12 +147,12 @@ async def _fetch_engine(
145
147
  headers = {"User-Agent": random.choice(USER_AGENTS)}
146
148
 
147
149
  async with semaphore:
148
- for attempt in range(ENGINE_RETRY_COUNT + 1):
150
+ for attempt in range(search_config.ENGINE_RETRY_COUNT + 1):
149
151
  try:
150
- async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)) as resp:
152
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT)) as resp:
151
153
  if resp.status != 200:
152
- if attempt < ENGINE_RETRY_COUNT:
153
- await asyncio.sleep(0.5 * (attempt + 1))
154
+ if attempt < search_config.ENGINE_RETRY_COUNT:
155
+ await asyncio.sleep(search_config.retry_backoff(attempt))
154
156
  continue
155
157
  return EngineResult(
156
158
  name=name,
@@ -177,13 +179,13 @@ async def _fetch_engine(
177
179
  return EngineResult(name=name, links=links)
178
180
 
179
181
  except asyncio.TimeoutError:
180
- if attempt < ENGINE_RETRY_COUNT:
181
- await asyncio.sleep(0.5 * (attempt + 1))
182
+ if attempt < search_config.ENGINE_RETRY_COUNT:
183
+ await asyncio.sleep(search_config.retry_backoff(attempt))
182
184
  continue
183
185
  return EngineResult(name=name, links=[], error="timeout")
184
186
  except Exception as e:
185
- if attempt < ENGINE_RETRY_COUNT:
186
- await asyncio.sleep(0.5 * (attempt + 1))
187
+ if attempt < search_config.ENGINE_RETRY_COUNT:
188
+ await asyncio.sleep(search_config.retry_backoff(attempt))
187
189
  continue
188
190
  return EngineResult(name=name, links=[], error=str(e))
189
191
 
@@ -0,0 +1,13 @@
1
+ """Shared search timeout, retry, and backoff configuration."""
2
+
3
+ # Keep these values in one module so the CLI and API search paths cannot drift.
4
+ # The API path's 30-second timeout is the common baseline.
5
+ ENGINE_TIMEOUT = 30
6
+ SEARCH_TIMEOUT = 30
7
+ ENGINE_RETRY_COUNT = 2
8
+ RETRY_BACKOFF_BASE = 0.5
9
+
10
+
11
+ def retry_backoff(attempt: int) -> float:
12
+ """Return the delay before retry number *attempt* (zero-based)."""
13
+ return RETRY_BACKOFF_BASE * (attempt + 1)
@@ -22,10 +22,12 @@ from db.search_engine_stats import (
22
22
  from search.query_builder import diversify_query
23
23
  from search.engine_catalog import SEARCH_ENGINES
24
24
  from utils.async_utils import run_async
25
+ from search import config as search_config
25
26
 
26
27
  logger = logging.getLogger(__name__)
27
28
 
28
- ENGINE_TIMEOUT = 30
29
+ # Compatibility exports; runtime reads use the shared config module below.
30
+ ENGINE_TIMEOUT = search_config.ENGINE_TIMEOUT
29
31
 
30
32
  ENGINE_WEIGHTS = {
31
33
  "darksearch": 1.0,
@@ -56,8 +58,8 @@ USER_AGENTS = [
56
58
  _ONION_URL_RE = re.compile(r'https?://[a-z0-9._-]+\.onion(?:/[^\s"\'<>]*)?', re.IGNORECASE)
57
59
 
58
60
  MAX_CONCURRENT = 10
59
- SEARCH_TIMEOUT = 30
60
- ENGINE_RETRY_COUNT = 2
61
+ SEARCH_TIMEOUT = search_config.SEARCH_TIMEOUT
62
+ ENGINE_RETRY_COUNT = search_config.ENGINE_RETRY_COUNT
61
63
 
62
64
  _ENGINE_STATUS: dict[str, dict] = {}
63
65
  _LAST_SEARCH_SUMMARY: dict[str, int] = {}
@@ -99,7 +101,7 @@ async def fetch_with_timeout(
99
101
  session: aiohttp.ClientSession,
100
102
  ) -> aiohttp.ClientResponse:
101
103
  """Fetch a URL with timeout using the provided session."""
102
- return await session.get(url, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT))
104
+ return await session.get(url, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT))
103
105
 
104
106
 
105
107
  async def _fetch_engine(
@@ -115,12 +117,12 @@ async def _fetch_engine(
115
117
  headers = {"User-Agent": random.choice(USER_AGENTS)}
116
118
 
117
119
  async with semaphore:
118
- for attempt in range(ENGINE_RETRY_COUNT + 1):
120
+ for attempt in range(search_config.ENGINE_RETRY_COUNT + 1):
119
121
  try:
120
- async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)) as resp:
122
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT)) as resp:
121
123
  if resp.status != 200:
122
- if attempt < ENGINE_RETRY_COUNT:
123
- await asyncio.sleep(0.5 * (attempt + 1))
124
+ if attempt < search_config.ENGINE_RETRY_COUNT:
125
+ await asyncio.sleep(search_config.retry_backoff(attempt))
124
126
  continue
125
127
  return EngineResult(
126
128
  name=name,
@@ -147,13 +149,13 @@ async def _fetch_engine(
147
149
  return EngineResult(name=name, links=links)
148
150
 
149
151
  except asyncio.TimeoutError:
150
- if attempt < ENGINE_RETRY_COUNT:
151
- await asyncio.sleep(0.5 * (attempt + 1))
152
+ if attempt < search_config.ENGINE_RETRY_COUNT:
153
+ await asyncio.sleep(search_config.retry_backoff(attempt))
152
154
  continue
153
155
  return EngineResult(name=name, links=[], error="timeout")
154
156
  except Exception as e:
155
- if attempt < ENGINE_RETRY_COUNT:
156
- await asyncio.sleep(0.5 * (attempt + 1))
157
+ if attempt < search_config.ENGINE_RETRY_COUNT:
158
+ await asyncio.sleep(search_config.retry_backoff(attempt))
157
159
  continue
158
160
  return EngineResult(name=name, links=[], error=str(e))
159
161
 
@@ -269,7 +271,7 @@ async def _search_async(
269
271
  connector = _tor_aiohttp_connector()
270
272
  async with aiohttp.ClientSession(
271
273
  connector=connector,
272
- timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT),
274
+ timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT),
273
275
  ) as session:
274
276
 
275
277
  async def run_engine(engine: dict) -> EngineResult: