voidaccess 1.9.2__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.2 → voidaccess-1.9.4}/PKG-INFO +1 -1
  2. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/main.py +1 -1
  3. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/entities.py +6 -1
  4. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/investigations.py +3 -2
  5. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/env.py +29 -1
  6. voidaccess-1.9.4/db/migrations/versions/0026_add_entity_investigation_count.py +46 -0
  7. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/models.py +5 -0
  8. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/queries.py +26 -6
  9. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/ioc_package.py +1 -1
  10. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/stix.py +110 -5
  11. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/entity_shape.py +1 -8
  12. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/gazetteer.py +58 -8
  13. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/ner.py +20 -0
  14. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/normalizer.py +16 -1
  15. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/builder.py +16 -0
  16. {voidaccess-1.9.2 → voidaccess-1.9.4}/pyproject.toml +1 -1
  17. {voidaccess-1.9.2 → voidaccess-1.9.4}/search/__init__.py +15 -13
  18. voidaccess-1.9.4/search/config.py +13 -0
  19. {voidaccess-1.9.2 → voidaccess-1.9.4}/search/search.py +15 -13
  20. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/data/onion_seeds.json +11 -1
  21. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/email_reputation.py +4 -2
  22. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_api.py +62 -48
  23. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_confidence_calibration.py +10 -8
  24. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_db.py +1 -0
  25. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_llm.py +10 -0
  26. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_llm_utils.py +1 -1
  27. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_regex_patterns.py +21 -21
  28. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_snort_export.py +28 -28
  29. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_stix_export.py +20 -0
  30. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_yara_export.py +17 -17
  31. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/ioc_freshness.py +13 -4
  32. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/llm.py +15 -10
  33. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/PKG-INFO +1 -1
  34. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/SOURCES.txt +2 -0
  35. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/__init__.py +1 -1
  36. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/adapters/sqlite.py +3 -0
  37. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/browser.py +2 -0
  38. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/export.py +3 -1
  39. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/investigate.py +33 -4
  40. {voidaccess-1.9.2 → voidaccess-1.9.4}/LICENSE +0 -0
  41. {voidaccess-1.9.2 → voidaccess-1.9.4}/README.md +0 -0
  42. {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/__init__.py +0 -0
  43. {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/opsec.py +0 -0
  44. {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/patterns.py +0 -0
  45. {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/temporal.py +0 -0
  46. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/__init__.py +0 -0
  47. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/auth.py +0 -0
  48. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/errors.py +0 -0
  49. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/__init__.py +0 -0
  50. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/actors.py +0 -0
  51. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/admin.py +0 -0
  52. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/auth.py +0 -0
  53. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/export.py +2 -2
  54. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/monitors.py +0 -0
  55. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/search.py +0 -0
  56. {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/settings.py +0 -0
  57. {voidaccess-1.9.2 → voidaccess-1.9.4}/auth/__init__.py +0 -0
  58. {voidaccess-1.9.2 → voidaccess-1.9.4}/auth/token_blacklist.py +0 -0
  59. {voidaccess-1.9.2 → voidaccess-1.9.4}/config.py +0 -0
  60. {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/__init__.py +0 -0
  61. {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/dedup.py +0 -0
  62. {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/frontier.py +0 -0
  63. {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/spider.py +0 -0
  64. {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/utils.py +0 -0
  65. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/__init__.py +0 -0
  66. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/__init__.py +0 -0
  67. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0001_initial_schema.py +0 -0
  68. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
  69. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0002_add_missing_tables.py +0 -0
  70. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
  71. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
  72. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0005_add_extraction_method.py +0 -0
  73. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
  74. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
  75. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0008_add_users_table.py +0 -0
  76. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
  77. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
  78. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
  79. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0013_add_graph_status.py +0 -0
  80. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0015_add_progress_fields.py +0 -0
  81. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
  82. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
  83. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
  84. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
  85. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
  86. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
  87. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
  88. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
  89. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
  90. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
  91. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/search_engine_stats.py +0 -0
  92. {voidaccess-1.9.2 → voidaccess-1.9.4}/db/session.py +0 -0
  93. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/__init__.py +0 -0
  94. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/_entity_loading.py +0 -0
  95. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/misp.py +0 -0
  96. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/relationships.py +0 -0
  97. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/sigma.py +0 -0
  98. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/snort_export.py +0 -0
  99. {voidaccess-1.9.2 → voidaccess-1.9.4}/export/yara_export.py +0 -0
  100. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/__init__.py +0 -0
  101. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/confidence.py +0 -0
  102. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/identity.py +0 -0
  103. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/llm_extract.py +0 -0
  104. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/pipeline.py +0 -0
  105. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/regex_patterns.py +0 -0
  106. {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/relationship_extract.py +0 -0
  107. {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/__init__.py +0 -0
  108. {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/calibration.py +0 -0
  109. {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/profiler.py +0 -0
  110. {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/stylometry.py +0 -0
  111. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/__init__.py +0 -0
  112. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/export.py +0 -0
  113. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/model.py +0 -0
  114. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/queries.py +0 -0
  115. {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/visualize.py +0 -0
  116. {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/__init__.py +0 -0
  117. {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/detect.py +0 -0
  118. {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/query_expand.py +0 -0
  119. {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/translate.py +0 -0
  120. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/__init__.py +0 -0
  121. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/_db.py +0 -0
  122. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/alerts.py +0 -0
  123. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/config.py +0 -0
  124. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/diff.py +0 -0
  125. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/jobs.py +0 -0
  126. {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/scheduler.py +0 -0
  127. {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/__init__.py +0 -0
  128. {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/scrape.py +0 -0
  129. {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/scrape_js.py +0 -0
  130. {voidaccess-1.9.2 → voidaccess-1.9.4}/search/circuit_breaker.py +0 -0
  131. {voidaccess-1.9.2 → voidaccess-1.9.4}/search/engine_catalog.py +0 -0
  132. {voidaccess-1.9.2 → voidaccess-1.9.4}/search/query_builder.py +0 -0
  133. {voidaccess-1.9.2 → voidaccess-1.9.4}/setup.cfg +0 -0
  134. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/__init__.py +0 -0
  135. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/actor_profiles.py +0 -0
  136. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/blockchain.py +0 -0
  137. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/breach_lookup.py +0 -0
  138. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/cache.py +0 -0
  139. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/cisa.py +0 -0
  140. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/data/__init__.py +0 -0
  141. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/dns_enrichment.py +0 -0
  142. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/domain_reputation.py +0 -0
  143. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/engines.py +0 -0
  144. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/enrichment.py +0 -0
  145. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/github_scraper.py +0 -0
  146. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/gitlab_scraper.py +0 -0
  147. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/hash_reputation.py +0 -0
  148. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/historical_intel.py +0 -0
  149. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/infostealer.py +0 -0
  150. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/ip_reputation.py +0 -0
  151. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/nvd.py +0 -0
  152. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/paste_scraper.py +0 -0
  153. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/pastes.py +0 -0
  154. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/proxy_client.py +0 -0
  155. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/rss_scraper.py +0 -0
  156. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/seed_manager.py +0 -0
  157. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/seeds.py +0 -0
  158. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/shodan.py +0 -0
  159. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/telegram.py +0 -0
  160. {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/virustotal.py +0 -0
  161. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_opsec.py +0 -0
  162. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_stylometry.py +0 -0
  163. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_temporal.py +0 -0
  164. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_api_monitors.py +0 -0
  165. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_blockchain.py +0 -0
  166. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_breach_lookup.py +0 -0
  167. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_cli_proxy_config.py +0 -0
  168. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_config.py +0 -0
  169. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_consolidated_contracts.py +0 -0
  170. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_crawler.py +0 -0
  171. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_dns_enrichment.py +0 -0
  172. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_domain_reputation.py +0 -0
  173. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_email_reputation.py +0 -0
  174. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_extractor_llm_pipeline.py +0 -0
  175. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_filter_e2e_manual.py +0 -0
  176. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_filter_parser_manual.py +0 -0
  177. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_fingerprint.py +0 -0
  178. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_github_scraper.py +0 -0
  179. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_gitlab_scraper.py +0 -0
  180. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_graph.py +0 -0
  181. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_graph_integration.py +0 -0
  182. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_hash_reputation.py +0 -0
  183. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_i18n.py +0 -0
  184. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_infostealer.py +0 -0
  185. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_ip_reputation.py +0 -0
  186. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_model_singleton.py +0 -0
  187. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_monitor.py +0 -0
  188. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_monitor_integration.py +0 -0
  189. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_nvd.py +0 -0
  190. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_pagination.py +0 -0
  191. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_paste_scraper.py +0 -0
  192. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_proxy_client.py +0 -0
  193. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_ransomlook.py +0 -0
  194. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_relationship_extract.py +0 -0
  195. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_rss_scraper.py +0 -0
  196. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_scrape_js.py +0 -0
  197. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_settings.py +0 -0
  198. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_sources.py +0 -0
  199. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_sources_enrichment_new.py +0 -0
  200. {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_vector.py +0 -0
  201. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/__init__.py +0 -0
  202. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/async_utils.py +0 -0
  203. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/content_dedup.py +0 -0
  204. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/content_safety.py +0 -0
  205. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/defang.py +0 -0
  206. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/encryption.py +0 -0
  207. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/enrichment_cache.py +0 -0
  208. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/investigation_metrics.py +0 -0
  209. {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/user_keys.py +0 -0
  210. {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/__init__.py +0 -0
  211. {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/embedder.py +0 -0
  212. {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/model_singleton.py +0 -0
  213. {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/search.py +0 -0
  214. {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/store.py +0 -0
  215. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/__init__.py +0 -0
  216. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/config.py +0 -0
  217. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/llm_utils.py +0 -0
  218. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/dependency_links.txt +0 -0
  219. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/entry_points.txt +0 -0
  220. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/requires.txt +0 -0
  221. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/top_level.txt +0 -0
  222. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/adapters/__init__.py +0 -0
  223. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/__init__.py +0 -0
  224. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/actors.py +0 -0
  225. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/configure.py +0 -0
  226. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/enrich.py +0 -0
  227. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/show.py +0 -0
  228. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/config.py +0 -0
  229. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/display.py +0 -0
  230. {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/main.py +0 -0
  231. {voidaccess-1.9.2 → 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.2
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
@@ -40,7 +40,7 @@ logging.basicConfig(level=logging.DEBUG)
40
40
  # Rate limiter setup
41
41
  # ---------------------------------------------------------------------------
42
42
 
43
- DISABLE_RATE_LIMIT = os.getenv("DISABLE_RATE_LIMIT", "false").lower() == "true"
43
+ DISABLE_RATE_LIMIT = (os.getenv("DISABLE_RATE_LIMIT", "false") or "false").lower() == "true"
44
44
 
45
45
  if DISABLE_RATE_LIMIT:
46
46
  limiter = None
@@ -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
 
@@ -537,7 +537,7 @@ async def _check_cancelled(inv_uuid: uuid.UUID, investigation_id: str) -> bool:
537
537
  # Rate limiting (shared key_func with api/main.py; enforcement via app.state.limiter)
538
538
  # ---------------------------------------------------------------------------
539
539
 
540
- _DISABLE_RATE_LIMIT = os.getenv("DISABLE_RATE_LIMIT", "false").lower() == "true"
540
+ _DISABLE_RATE_LIMIT = (os.getenv("DISABLE_RATE_LIMIT", "false") or "false").lower() == "true"
541
541
 
542
542
  if not _DISABLE_RATE_LIMIT:
543
543
  try:
@@ -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.2"
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.
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  import json
19
19
  import logging
20
20
  import os
21
+ import re
21
22
  import warnings
22
23
  from typing import Any, Optional
23
24
  import uuid
@@ -78,6 +79,114 @@ _STIX_PATTERNS: dict[str, str] = {
78
79
  # Entity types that map to STIX Malware objects
79
80
  _MALWARE_TYPES = frozenset({"MALWARE_FAMILY", "RANSOMWARE_GROUP"})
80
81
 
82
+ # STIX 2.1's malware-type vocabulary is intentionally small. Keep the
83
+ # mapping here instead of treating every MALWARE_FAMILY as the generic
84
+ # ``malware`` type: family names and enrichment metadata often carry enough
85
+ # information for SIEM consumers to filter useful categories.
86
+ _STIX_MALWARE_TYPE_ALIASES = {
87
+ "infostealer": "spyware",
88
+ "stealer": "spyware",
89
+ "remote_access_trojan": "remote-access-trojan",
90
+ "remote access trojan": "remote-access-trojan",
91
+ "rat": "remote-access-trojan",
92
+ "coinminer": "resource-exploitation",
93
+ "cryptominer": "resource-exploitation",
94
+ "crypto-miner": "resource-exploitation",
95
+ "miner": "resource-exploitation",
96
+ }
97
+ _STIX_MALWARE_TYPES = frozenset({
98
+ "adware",
99
+ "backdoor",
100
+ "bot",
101
+ "ddos",
102
+ "dropper",
103
+ "exploit-kit",
104
+ "keylogger",
105
+ "ransomware",
106
+ "remote-access-trojan",
107
+ "resource-exploitation",
108
+ "rootkit",
109
+ "screen-capture",
110
+ "spyware",
111
+ "wiper",
112
+ "malware",
113
+ })
114
+
115
+ # These are conservative, high-signal family/name hints. Unknown families
116
+ # retain the valid generic STIX type rather than being assigned a speculative
117
+ # category.
118
+ _MALWARE_NAME_PATTERNS = (
119
+ ("ransomware", re.compile(
120
+ r"\b(?:ransomware|lockbit|blackcat|alphv|clop|conti|ryuk|hive|revil|darkside|wannacry)\b",
121
+ re.IGNORECASE,
122
+ )),
123
+ ("remote-access-trojan", re.compile(
124
+ r"\b(?:remote\s+access\s+trojan|remcos|njrat|nanocore|quasar|darkcomet|xrat)\b",
125
+ re.IGNORECASE,
126
+ )),
127
+ ("spyware", re.compile(
128
+ r"\b(?:spyware|infostealer|stealer|redline|vidar|lumma|raccoon|agent\s+tesla|formbook)\b",
129
+ re.IGNORECASE,
130
+ )),
131
+ ("bot", re.compile(
132
+ r"\b(?:botnet|emotet|qakbot|qbot|trickbot|mirai|zeus|mozi)\b",
133
+ re.IGNORECASE,
134
+ )),
135
+ ("keylogger", re.compile(r"\bkeylogger\b", re.IGNORECASE)),
136
+ ("exploit-kit", re.compile(r"\bexploit[\s-]+kit\b", re.IGNORECASE)),
137
+ ("rootkit", re.compile(r"\brootkit\b", re.IGNORECASE)),
138
+ ("screen-capture", re.compile(r"\bscreen[\s-]+capture\b", re.IGNORECASE)),
139
+ ("dropper", re.compile(r"\bdropper\b", re.IGNORECASE)),
140
+ ("wiper", re.compile(r"\bwiper\b", re.IGNORECASE)),
141
+ ("adware", re.compile(r"\badware\b", re.IGNORECASE)),
142
+ ("resource-exploitation", re.compile(
143
+ r"\b(?:cryptominer|crypto[\s-]+miner|coinminer|cryptojacking)\b",
144
+ re.IGNORECASE,
145
+ )),
146
+ ("backdoor", re.compile(r"\bbackdoor\b", re.IGNORECASE)),
147
+ )
148
+
149
+
150
+ def _entity_attribute(entity: Any, name: str) -> Any:
151
+ """Read optional classification metadata from dicts and ORM-like rows."""
152
+ if isinstance(entity, dict):
153
+ return entity.get(name)
154
+ return getattr(entity, name, None)
155
+
156
+
157
+ def _normalise_malware_type(value: Any) -> Optional[str]:
158
+ if not isinstance(value, str):
159
+ return None
160
+ key = value.strip().lower().replace("_", " ")
161
+ key = _STIX_MALWARE_TYPE_ALIASES.get(key, key)
162
+ key = key.replace(" ", "-")
163
+ return key if key in _STIX_MALWARE_TYPES else None
164
+
165
+
166
+ def _classify_malware(entity: Any) -> list[str]:
167
+ """Return evidence-backed STIX malware types for an entity."""
168
+ entity_type = str(getattr(entity, "entity_type", "") or "").upper()
169
+ if isinstance(entity, dict):
170
+ entity_type = str(entity.get("entity_type") or "").upper()
171
+ if entity_type == "RANSOMWARE_GROUP":
172
+ return ["ransomware"]
173
+
174
+ for field_name in ("malware_types", "malware_type", "malware_category", "category", "family_type"):
175
+ raw = _entity_attribute(entity, field_name)
176
+ values = raw if isinstance(raw, (list, tuple, set)) else [raw]
177
+ classified = [item for item in (_normalise_malware_type(v) for v in values) if item]
178
+ if classified:
179
+ return list(dict.fromkeys(classified))
180
+
181
+ evidence = " ".join(
182
+ str(_entity_attribute(entity, field) or "")
183
+ for field in ("value", "context_snippet")
184
+ )
185
+ for malware_type, pattern in _MALWARE_NAME_PATTERNS:
186
+ if pattern.search(evidence):
187
+ return [malware_type]
188
+ return ["malware"]
189
+
81
190
  # ---------------------------------------------------------------------------
82
191
  # Confidence mapping: VoidAccess float → STIX integer (0-100)
83
192
  # ---------------------------------------------------------------------------
@@ -160,11 +269,7 @@ def entity_to_stix_malware(entity: Any) -> Optional[Any]:
160
269
  malware = stix2.Malware(
161
270
  name=entity.value,
162
271
  is_family=True,
163
- malware_types=(
164
- ["ransomware"]
165
- if entity.entity_type == "RANSOMWARE_GROUP"
166
- else ["malware"]
167
- ),
272
+ malware_types=_classify_malware(entity),
168
273
  confidence=_to_stix_confidence(get_entity_confidence(entity)),
169
274
  allow_custom=True,
170
275
  x_voidaccess_source_quality=getattr(entity, "source_quality", 1.0),
@@ -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.2"
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"