voidaccess 1.8.0__tar.gz → 1.8.2__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 (225) hide show
  1. {voidaccess-1.8.0/voidaccess.egg-info → voidaccess-1.8.2}/PKG-INFO +1 -1
  2. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/models.py +1 -1
  3. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/ioc_package.py +1 -1
  4. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/stix.py +1 -1
  5. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/normalizer.py +29 -0
  6. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/pipeline.py +17 -0
  7. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/relationship_extract.py +131 -11
  8. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/model.py +1 -1
  9. {voidaccess-1.8.0 → voidaccess-1.8.2}/pyproject.toml +1 -1
  10. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/enrichment.py +40 -2
  11. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_db.py +7 -6
  12. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_graph.py +5 -5
  13. voidaccess-1.8.2/tests/test_relationship_extract.py +74 -0
  14. {voidaccess-1.8.0 → voidaccess-1.8.2/voidaccess.egg-info}/PKG-INFO +1 -1
  15. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess.egg-info/SOURCES.txt +1 -0
  16. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/__init__.py +1 -1
  17. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/investigate.py +108 -1
  18. {voidaccess-1.8.0 → voidaccess-1.8.2}/LICENSE +0 -0
  19. {voidaccess-1.8.0 → voidaccess-1.8.2}/README.md +0 -0
  20. {voidaccess-1.8.0 → voidaccess-1.8.2}/analysis/__init__.py +0 -0
  21. {voidaccess-1.8.0 → voidaccess-1.8.2}/analysis/opsec.py +0 -0
  22. {voidaccess-1.8.0 → voidaccess-1.8.2}/analysis/patterns.py +0 -0
  23. {voidaccess-1.8.0 → voidaccess-1.8.2}/analysis/temporal.py +0 -0
  24. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/__init__.py +0 -0
  25. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/auth.py +0 -0
  26. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/errors.py +0 -0
  27. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/main.py +0 -0
  28. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/__init__.py +0 -0
  29. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/actors.py +0 -0
  30. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/admin.py +0 -0
  31. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/auth.py +0 -0
  32. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/entities.py +0 -0
  33. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/export.py +0 -0
  34. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/investigations.py +0 -0
  35. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/monitors.py +0 -0
  36. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/search.py +0 -0
  37. {voidaccess-1.8.0 → voidaccess-1.8.2}/api/routes/settings.py +0 -0
  38. {voidaccess-1.8.0 → voidaccess-1.8.2}/auth/__init__.py +0 -0
  39. {voidaccess-1.8.0 → voidaccess-1.8.2}/auth/token_blacklist.py +0 -0
  40. {voidaccess-1.8.0 → voidaccess-1.8.2}/config.py +0 -0
  41. {voidaccess-1.8.0 → voidaccess-1.8.2}/crawler/__init__.py +0 -0
  42. {voidaccess-1.8.0 → voidaccess-1.8.2}/crawler/dedup.py +0 -0
  43. {voidaccess-1.8.0 → voidaccess-1.8.2}/crawler/frontier.py +0 -0
  44. {voidaccess-1.8.0 → voidaccess-1.8.2}/crawler/spider.py +0 -0
  45. {voidaccess-1.8.0 → voidaccess-1.8.2}/crawler/utils.py +0 -0
  46. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/__init__.py +0 -0
  47. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/__init__.py +0 -0
  48. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/env.py +0 -0
  49. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0001_initial_schema.py +0 -0
  50. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
  51. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0002_add_missing_tables.py +0 -0
  52. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
  53. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
  54. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0005_add_extraction_method.py +0 -0
  55. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
  56. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
  57. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0008_add_users_table.py +0 -0
  58. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
  59. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
  60. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
  61. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0013_add_graph_status.py +0 -0
  62. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0015_add_progress_fields.py +0 -0
  63. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
  64. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
  65. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
  66. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
  67. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
  68. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
  69. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
  70. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
  71. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
  72. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
  73. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/queries.py +0 -0
  74. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/search_engine_stats.py +0 -0
  75. {voidaccess-1.8.0 → voidaccess-1.8.2}/db/session.py +0 -0
  76. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/__init__.py +0 -0
  77. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/misp.py +0 -0
  78. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/sigma.py +0 -0
  79. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/snort_export.py +0 -0
  80. {voidaccess-1.8.0 → voidaccess-1.8.2}/export/yara_export.py +0 -0
  81. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/__init__.py +0 -0
  82. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/confidence.py +0 -0
  83. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/entity_shape.py +0 -0
  84. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/gazetteer.py +0 -0
  85. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/llm_extract.py +0 -0
  86. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/ner.py +0 -0
  87. {voidaccess-1.8.0 → voidaccess-1.8.2}/extractor/regex_patterns.py +0 -0
  88. {voidaccess-1.8.0 → voidaccess-1.8.2}/fingerprint/__init__.py +0 -0
  89. {voidaccess-1.8.0 → voidaccess-1.8.2}/fingerprint/calibration.py +0 -0
  90. {voidaccess-1.8.0 → voidaccess-1.8.2}/fingerprint/profiler.py +0 -0
  91. {voidaccess-1.8.0 → voidaccess-1.8.2}/fingerprint/stylometry.py +0 -0
  92. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/__init__.py +0 -0
  93. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/builder.py +0 -0
  94. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/export.py +0 -0
  95. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/queries.py +0 -0
  96. {voidaccess-1.8.0 → voidaccess-1.8.2}/graph/visualize.py +0 -0
  97. {voidaccess-1.8.0 → voidaccess-1.8.2}/i18n/__init__.py +0 -0
  98. {voidaccess-1.8.0 → voidaccess-1.8.2}/i18n/detect.py +0 -0
  99. {voidaccess-1.8.0 → voidaccess-1.8.2}/i18n/query_expand.py +0 -0
  100. {voidaccess-1.8.0 → voidaccess-1.8.2}/i18n/translate.py +0 -0
  101. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/__init__.py +0 -0
  102. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/_db.py +0 -0
  103. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/alerts.py +0 -0
  104. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/config.py +0 -0
  105. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/diff.py +0 -0
  106. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/jobs.py +0 -0
  107. {voidaccess-1.8.0 → voidaccess-1.8.2}/monitor/scheduler.py +0 -0
  108. {voidaccess-1.8.0 → voidaccess-1.8.2}/scraper/__init__.py +0 -0
  109. {voidaccess-1.8.0 → voidaccess-1.8.2}/scraper/scrape.py +0 -0
  110. {voidaccess-1.8.0 → voidaccess-1.8.2}/scraper/scrape_js.py +0 -0
  111. {voidaccess-1.8.0 → voidaccess-1.8.2}/search/__init__.py +0 -0
  112. {voidaccess-1.8.0 → voidaccess-1.8.2}/search/circuit_breaker.py +0 -0
  113. {voidaccess-1.8.0 → voidaccess-1.8.2}/search/engine_catalog.py +0 -0
  114. {voidaccess-1.8.0 → voidaccess-1.8.2}/search/query_builder.py +0 -0
  115. {voidaccess-1.8.0 → voidaccess-1.8.2}/search/search.py +0 -0
  116. {voidaccess-1.8.0 → voidaccess-1.8.2}/setup.cfg +0 -0
  117. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/__init__.py +0 -0
  118. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/actor_profiles.py +0 -0
  119. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/blockchain.py +0 -0
  120. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/breach_lookup.py +0 -0
  121. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/cache.py +0 -0
  122. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/cisa.py +0 -0
  123. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/data/__init__.py +0 -0
  124. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/data/onion_seeds.json +0 -0
  125. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/dns_enrichment.py +0 -0
  126. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/domain_reputation.py +0 -0
  127. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/email_reputation.py +0 -0
  128. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/engines.py +0 -0
  129. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/github_scraper.py +0 -0
  130. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/gitlab_scraper.py +0 -0
  131. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/hash_reputation.py +0 -0
  132. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/historical_intel.py +0 -0
  133. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/infostealer.py +0 -0
  134. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/ip_reputation.py +0 -0
  135. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/nvd.py +0 -0
  136. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/paste_scraper.py +0 -0
  137. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/pastes.py +0 -0
  138. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/proxy_client.py +0 -0
  139. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/rss_scraper.py +0 -0
  140. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/seed_manager.py +0 -0
  141. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/seeds.py +0 -0
  142. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/shodan.py +0 -0
  143. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/telegram.py +0 -0
  144. {voidaccess-1.8.0 → voidaccess-1.8.2}/sources/virustotal.py +0 -0
  145. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_analysis_opsec.py +0 -0
  146. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_analysis_stylometry.py +0 -0
  147. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_analysis_temporal.py +0 -0
  148. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_api.py +0 -0
  149. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_api_monitors.py +0 -0
  150. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_blockchain.py +0 -0
  151. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_breach_lookup.py +0 -0
  152. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_cli_proxy_config.py +0 -0
  153. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_confidence_calibration.py +0 -0
  154. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_config.py +0 -0
  155. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_crawler.py +0 -0
  156. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_dns_enrichment.py +0 -0
  157. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_domain_reputation.py +0 -0
  158. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_email_reputation.py +0 -0
  159. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_extractor_llm_pipeline.py +0 -0
  160. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_filter_e2e_manual.py +0 -0
  161. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_filter_parser_manual.py +0 -0
  162. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_fingerprint.py +0 -0
  163. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_github_scraper.py +0 -0
  164. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_gitlab_scraper.py +0 -0
  165. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_graph_integration.py +0 -0
  166. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_hash_reputation.py +0 -0
  167. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_i18n.py +0 -0
  168. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_infostealer.py +0 -0
  169. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_ip_reputation.py +0 -0
  170. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_llm.py +0 -0
  171. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_llm_utils.py +0 -0
  172. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_model_singleton.py +0 -0
  173. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_monitor.py +0 -0
  174. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_monitor_integration.py +0 -0
  175. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_nvd.py +0 -0
  176. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_pagination.py +0 -0
  177. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_paste_scraper.py +0 -0
  178. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_proxy_client.py +0 -0
  179. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_ransomlook.py +0 -0
  180. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_regex_patterns.py +0 -0
  181. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_rss_scraper.py +0 -0
  182. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_scrape_js.py +0 -0
  183. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_settings.py +0 -0
  184. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_snort_export.py +0 -0
  185. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_sources.py +0 -0
  186. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_sources_enrichment_new.py +0 -0
  187. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_stix_export.py +0 -0
  188. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_vector.py +0 -0
  189. {voidaccess-1.8.0 → voidaccess-1.8.2}/tests/test_yara_export.py +0 -0
  190. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/__init__.py +0 -0
  191. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/async_utils.py +0 -0
  192. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/content_dedup.py +0 -0
  193. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/content_safety.py +0 -0
  194. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/defang.py +0 -0
  195. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/encryption.py +0 -0
  196. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/enrichment_cache.py +0 -0
  197. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/investigation_metrics.py +0 -0
  198. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/ioc_freshness.py +0 -0
  199. {voidaccess-1.8.0 → voidaccess-1.8.2}/utils/user_keys.py +0 -0
  200. {voidaccess-1.8.0 → voidaccess-1.8.2}/vector/__init__.py +0 -0
  201. {voidaccess-1.8.0 → voidaccess-1.8.2}/vector/embedder.py +0 -0
  202. {voidaccess-1.8.0 → voidaccess-1.8.2}/vector/model_singleton.py +0 -0
  203. {voidaccess-1.8.0 → voidaccess-1.8.2}/vector/search.py +0 -0
  204. {voidaccess-1.8.0 → voidaccess-1.8.2}/vector/store.py +0 -0
  205. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess/__init__.py +0 -0
  206. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess/config.py +0 -0
  207. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess/llm.py +0 -0
  208. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess/llm_utils.py +0 -0
  209. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess.egg-info/dependency_links.txt +0 -0
  210. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess.egg-info/entry_points.txt +0 -0
  211. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess.egg-info/requires.txt +0 -0
  212. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess.egg-info/top_level.txt +0 -0
  213. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/adapters/__init__.py +0 -0
  214. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/adapters/sqlite.py +0 -0
  215. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/browser.py +0 -0
  216. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/__init__.py +0 -0
  217. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/actors.py +0 -0
  218. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/configure.py +0 -0
  219. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/enrich.py +0 -0
  220. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/export.py +0 -0
  221. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/commands/show.py +0 -0
  222. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/config.py +0 -0
  223. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/display.py +0 -0
  224. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/main.py +0 -0
  225. {voidaccess-1.8.0 → voidaccess-1.8.2}/voidaccess_cli/tor_detect.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidaccess
3
- Version: 1.8.0
3
+ Version: 1.8.2
4
4
  Summary: Dark web OSINT CLI — automated threat intelligence from query to report
5
5
  Author: VoidAccess
6
6
  License-Expression: MIT
@@ -75,7 +75,7 @@ class RelationshipType(str, enum.Enum):
75
75
  LINKED_TO = "LINKED_TO"
76
76
  PAID_TO = "PAID_TO"
77
77
  MEMBER_OF = "MEMBER_OF"
78
- USED = "USED"
78
+ USES = "USES"
79
79
  CLAIMED = "CLAIMED"
80
80
  LIKELY_SAME_ACTOR = "LIKELY_SAME_ACTOR"
81
81
  CONFIRMED_SAME_ACTOR = "CONFIRMED_SAME_ACTOR"
@@ -54,7 +54,7 @@ logger = logging.getLogger(__name__)
54
54
  # ---------------------------------------------------------------------------
55
55
 
56
56
  PACKAGE_FORMAT = "voidaccess-ioc-v1"
57
- PACKAGE_VERSION = "1.8.0"
57
+ PACKAGE_VERSION = "1.8.2"
58
58
  SNORT_SID_BASE = 9000001 # 9xxxxxx reserved for VoidAccess-generated rules
59
59
 
60
60
  # Map extractor upper-case entity types to the per-file bucket they belong to.
@@ -508,7 +508,7 @@ def _edge_type_to_stix(edge_type: str) -> str:
508
508
  "LINKED_TO": "related-to",
509
509
  "PAID_TO": "related-to",
510
510
  "MEMBER_OF": "member-of",
511
- "USED": "uses",
511
+ "USES": "uses",
512
512
  "CLAIMED": "attributed-to",
513
513
  "LIKELY_SAME_ACTOR": "related-to",
514
514
  "CONFIRMED_SAME_ACTOR": "related-to",
@@ -887,6 +887,27 @@ _RFC5737_NETWORKS: list[tuple[str, str]] = [
887
887
  ("203.0.113.0", "255.255.255.0"), # 203.0.113.0/24
888
888
  ]
889
889
 
890
+ # Real, routable IPs that are never a meaningful indicator of compromise:
891
+ # the unspecified/null address, the broadcast address, and the well-known
892
+ # public DNS resolvers that appear constantly in technical content for
893
+ # reasons unrelated to being an IOC. Filtered like the RFC 5737 ranges so a
894
+ # user pulling ip_addresses.txt into a blocklist can't end up blocking a
895
+ # legitimate public resolver or a null route.
896
+ _NON_ACTIONABLE_IPS: frozenset[str] = frozenset({
897
+ "0.0.0.0", # unspecified / null route — never a real host
898
+ "255.255.255.255", # limited broadcast
899
+ "8.8.8.8", "8.8.4.4", # Google Public DNS
900
+ "1.1.1.1", "1.0.0.1", # Cloudflare Public DNS
901
+ "9.9.9.9", "149.112.112.112", # Quad9
902
+ "208.67.222.222", "208.67.220.220", # OpenDNS
903
+ "4.2.2.1", "4.2.2.2", # Level3 legacy resolvers
904
+ })
905
+
906
+
907
+ def _is_non_actionable_ip(value: str) -> bool:
908
+ """Return True for real IPs that are never meaningful IOCs (null route, public DNS)."""
909
+ return value.strip() in _NON_ACTIONABLE_IPS
910
+
890
911
  # RFC 2606 reserved domain names.
891
912
  _RFC2606_DOMAINS: frozenset[str] = frozenset({
892
913
  "example.com",
@@ -1563,6 +1584,14 @@ def normalize_entities(
1563
1584
  )
1564
1585
  continue
1565
1586
 
1587
+ if entity_type == "IP_ADDRESS" and _is_non_actionable_ip(canonical):
1588
+ source_quality = 0.0
1589
+ logger.debug(
1590
+ "Non-actionable IP rejected (null route / broadcast / public DNS resolver): %s",
1591
+ canonical,
1592
+ )
1593
+ continue
1594
+
1566
1595
  if entity_type == "DOMAIN" and _is_rfc2606_domain(canonical):
1567
1596
  source_quality = 0.0
1568
1597
  logger.debug(
@@ -574,6 +574,23 @@ async def extract_entities_from_pages(
574
574
  )
575
575
  page_id_by_url[_url] = _p.get("page_id")
576
576
 
577
+ # Some clearnet/side-source pages are materialized in the DB
578
+ # during entity persistence, after the CLI's initial page-id
579
+ # lookup. Resolve those URLs again before relationship
580
+ # extraction so provenance is never lost to that ordering.
581
+ missing_urls = [url for url, page_id in page_id_by_url.items() if page_id is None]
582
+ if missing_urls:
583
+ from db.models import Page # noqa: PLC0415
584
+
585
+ with get_session() as _page_session:
586
+ persisted_pages = (
587
+ _page_session.query(Page)
588
+ .filter(Page.url.in_(missing_urls))
589
+ .all()
590
+ )
591
+ for _page in persisted_pages:
592
+ page_id_by_url[_page.url] = _page.id
593
+
577
594
  max_rel_pages = int(
578
595
  getattr(_config, "MAX_REL_PAGES_PER_INV", 10) or 10
579
596
  )
@@ -60,8 +60,8 @@ _DEFAULT_MAX_RELATIONSHIPS_PER_PAGE = 60
60
60
  # EDGE_TYPES). Passive forms map to the same stored type but flip the edge
61
61
  # direction (handled below) so the stored edge always reads source -> target.
62
62
  _LLM_REL_VOCAB: dict[str, str] = {
63
- "uses": "USED",
64
- "used": "USED",
63
+ "uses": "USES",
64
+ "used": "USES",
65
65
  "drops": "DROPS",
66
66
  "controls": "CONTROLS",
67
67
  "targets": "TARGETS",
@@ -76,6 +76,77 @@ _PASSIVE_REL_VOCAB: dict[str, str] = {
76
76
  "controlled_by": "CONTROLS",
77
77
  }
78
78
 
79
+ # Endpoint-type validation is deliberately kept in the relationship extractor
80
+ # so the prompt and the independent post-LLM gate share one source of truth.
81
+ # The sets are intentionally permissive for the project's current normalized
82
+ # entity vocabulary; unknown types are not accepted for typed edges.
83
+ _RELATIONSHIP_TYPE_COMPATIBILITY: dict[str, tuple[set[str], set[str]]] = {
84
+ "USES": (
85
+ {"RANSOMWARE_GROUP", "THREAT_ACTOR", "THREAT_ACTOR_HANDLE"},
86
+ {"MALWARE_FAMILY", "MALWARE", "TOOL", "SOFTWARE"},
87
+ ),
88
+ "DROPS": (
89
+ {"MALWARE_FAMILY", "MALWARE"},
90
+ {
91
+ "MALWARE_FAMILY", "MALWARE", "FILE_HASH_MD5", "FILE_HASH_SHA1",
92
+ "FILE_HASH_SHA256",
93
+ },
94
+ ),
95
+ "CONTROLS": (
96
+ {"RANSOMWARE_GROUP", "THREAT_ACTOR", "THREAT_ACTOR_HANDLE"},
97
+ {
98
+ "CRYPTO_WALLET", "BITCOIN_ADDRESS", "MONERO_ADDRESS", "ETH_ADDRESS",
99
+ "ETHEREUM_ADDRESS", "LITECOIN_ADDRESS", "ZCASH_ADDRESS",
100
+ "DOGECOIN_ADDRESS", "XRP_ADDRESS", "SOLANA_ADDRESS", "TRON_ADDRESS",
101
+ "BITCOIN_CASH_ADDRESS", "DASH_ADDRESS", "IP_ADDRESS", "IPV6_ADDRESS",
102
+ "DOMAIN", "ENS_DOMAIN", "ONION_URL",
103
+ },
104
+ ),
105
+ "TARGETS": (
106
+ {"RANSOMWARE_GROUP", "THREAT_ACTOR", "THREAT_ACTOR_HANDLE", "MALWARE_FAMILY", "MALWARE"},
107
+ {"ORGANIZATION_NAME"},
108
+ ),
109
+ "EXPLOITS": (
110
+ {"RANSOMWARE_GROUP", "THREAT_ACTOR", "THREAT_ACTOR_HANDLE", "MALWARE_FAMILY", "MALWARE"},
111
+ {"CVE", "CVE_NUMBER", "EXPLOIT_DB_ID"},
112
+ ),
113
+ "COMMUNICATES_WITH": (
114
+ {"RANSOMWARE_GROUP", "THREAT_ACTOR", "THREAT_ACTOR_HANDLE", "MALWARE_FAMILY", "MALWARE"},
115
+ {
116
+ "IP_ADDRESS", "IPV6_ADDRESS", "DOMAIN", "ENS_DOMAIN", "ONION_URL",
117
+ "TELEGRAM_HANDLE", "DISCORD_HANDLE", "XMPP_JID", "TOX_ID",
118
+ "MATRIX_HANDLE", "WIRE_HANDLE", "ICQ_NUMBER", "WICKR_ID",
119
+ "PHONE_NUMBER", "EMAIL_ADDRESS",
120
+ },
121
+ ),
122
+ }
123
+
124
+
125
+ def _entity_type(entity: dict) -> str:
126
+ return str(entity.get("type", "") or "").strip().upper()
127
+
128
+
129
+ def _is_compatible_relationship(rel_type: str, source: dict, target: dict) -> bool:
130
+ """Validate endpoint types independently of the LLM's claimed verb."""
131
+ allowed = _RELATIONSHIP_TYPE_COMPATIBILITY.get(rel_type)
132
+ if allowed is None:
133
+ return False
134
+ source_types, target_types = allowed
135
+ return _entity_type(source) in source_types and _entity_type(target) in target_types
136
+
137
+
138
+ def _compatibility_guide() -> str:
139
+ lines = []
140
+ for rel_type, (sources, targets) in _RELATIONSHIP_TYPE_COMPATIBILITY.items():
141
+ lines.append(
142
+ f"- {rel_type}: source {{{', '.join(sorted(sources))}}} -> "
143
+ f"target {{{', '.join(sorted(targets))}}}"
144
+ )
145
+ return "\n".join(lines)
146
+
147
+
148
+ _COMPATIBILITY_GUIDE = _compatibility_guide()
149
+
79
150
  _PROMPT_TEMPLATE = (
80
151
  "You are a threat intelligence analyst. Below is a numbered list of "
81
152
  "entities that were already extracted from a single dark-web page, "
@@ -100,6 +171,9 @@ _PROMPT_TEMPLATE = (
100
171
  "Plain co-occurrence is captured elsewhere — omit pairs that have no "
101
172
  "specific relationship.\n"
102
173
  "- Do not output any text outside the JSON object.\n\n"
174
+ "Entity-type compatibility guide (source -> target):\n"
175
+ "{compatibility_guide}\n"
176
+ "Only emit a relationship when BOTH endpoint types match this guide.\n\n"
103
177
  "Entities:\n{entities}\n\n"
104
178
  "Content:\n{content}"
105
179
  )
@@ -144,17 +218,41 @@ def _parse_relationship_json(raw: str) -> list[dict]:
144
218
  return rels if isinstance(rels, list) else []
145
219
 
146
220
 
221
+ class RelationshipLLMCallError(Exception):
222
+ """Raised when the relationship-extraction LLM invocation itself fails.
223
+
224
+ Distinct from a call that *succeeds* and legitimately returns no
225
+ relationships — callers use this to log a visible warning so a broken LLM
226
+ integration can never masquerade as "the LLM found no relationships".
227
+ """
228
+
229
+
147
230
  async def _invoke_llm(prompt: str, llm) -> str:
148
- """Call the LLM with streaming/callbacks disabled (mirrors llm_extract)."""
149
- try:
231
+ """Call the LLM with streaming/callbacks disabled (mirrors llm_extract).
232
+
233
+ Raises :class:`RelationshipLLMCallError` if the underlying invocation
234
+ fails, so the caller can distinguish a *failed* call from a successful
235
+ call that found nothing.
236
+ """
237
+ # ``bind(callbacks=[])`` puts ``callbacks`` into the model's invocation
238
+ # kwargs, which LangChain forwards to ``agenerate_prompt`` as ``**kwargs``
239
+ # — while ``ainvoke`` ALSO passes ``callbacks`` explicitly, raising
240
+ # "agenerate_prompt() got multiple values for keyword argument 'callbacks'".
241
+ # ``with_config`` overrides callbacks for this call WITHOUT adding model
242
+ # kwargs, exactly as extractor/llm_extract._extract_chunk does.
243
+ if hasattr(llm, "with_config"):
244
+ silent_llm = llm.with_config({"callbacks": []})
245
+ else:
150
246
  silent_llm = llm.bind(streaming=False, callbacks=[])
247
+ try:
248
+ # Guard stdout in case a legacy constructor callback still streams
249
+ # partial JSON tokens even with an empty per-call callback list.
151
250
  with redirect_stdout(io.StringIO()):
152
251
  response = await silent_llm.ainvoke(prompt)
153
- content = response.content if hasattr(response, "content") else str(response)
154
- return content.strip()
155
- except Exception as exc: # noqa: BLE001 — never let one page break the pass
156
- logger.warning("Relationship LLM call failed: %s", exc)
157
- return ""
252
+ except Exception as exc: # noqa: BLE001 — surface as a typed failure
253
+ raise RelationshipLLMCallError(str(exc)) from exc
254
+ content = response.content if hasattr(response, "content") else str(response)
255
+ return content.strip()
158
256
 
159
257
 
160
258
  async def extract_relationships_for_page(
@@ -192,9 +290,25 @@ async def extract_relationships_for_page(
192
290
  for i, e in enumerate(trimmed)
193
291
  )
194
292
  content = page_text[:max_chunk_chars]
195
- prompt = _PROMPT_TEMPLATE.format(entities=entity_lines, content=content)
293
+ prompt = _PROMPT_TEMPLATE.format(
294
+ entities=entity_lines,
295
+ content=content,
296
+ compatibility_guide=_COMPATIBILITY_GUIDE,
297
+ )
196
298
 
197
- raw = await _invoke_llm(prompt, llm)
299
+ try:
300
+ raw = await _invoke_llm(prompt, llm)
301
+ except RelationshipLLMCallError as exc:
302
+ # The LLM call itself failed — this is NOT the same as the LLM running
303
+ # successfully and finding no relationship. Log loudly so a broken
304
+ # integration is visible in normal operation instead of silently
305
+ # degrading every page to zero typed relationships.
306
+ logger.warning(
307
+ "Relationship extraction: LLM call FAILED (%s) — this page yielded "
308
+ "NO typed relationships due to an error, not because none exist",
309
+ exc,
310
+ )
311
+ return []
198
312
  rels = _parse_relationship_json(raw)
199
313
  if not rels:
200
314
  return []
@@ -227,6 +341,12 @@ async def extract_relationships_for_page(
227
341
  # its plain co-occurrence edge, generated elsewhere.
228
342
  continue
229
343
 
344
+ if not _is_compatible_relationship(rel_type, a_ent, b_ent):
345
+ # Structural validation is independent of the model's claim. Do
346
+ # not downgrade invalid pairs to co-occurrence: the pair may be
347
+ # spurious even though both entities appeared on the page.
348
+ continue
349
+
230
350
  a_id = a_ent.get("id")
231
351
  b_id = b_ent.get("id")
232
352
  if not a_id or not b_id or a_id == b_id:
@@ -77,7 +77,7 @@ class EDGE_TYPES:
77
77
  POSTED_BY = "POSTED_BY" # content attributed to a handle
78
78
  LINKED_TO = "LINKED_TO" # URL links to URL
79
79
  MEMBER_OF = "MEMBER_OF" # handle to group/forum
80
- USED = "USED" # actor used a malware family
80
+ USES = "USES" # actor uses a malware family
81
81
  CLAIMED = "CLAIMED" # group claimed an attack
82
82
  LIKELY_SAME_ACTOR = "LIKELY_SAME_ACTOR" # inferred, medium confidence
83
83
  CONFIRMED_SAME_ACTOR = "CONFIRMED_SAME_ACTOR" # PGP key match, high confidence
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voidaccess"
7
- version = "1.8.0"
7
+ version = "1.8.2"
8
8
  description = "Dark web OSINT CLI — automated threat intelligence from query to report"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -565,6 +565,42 @@ def abusech_to_pages(
565
565
  _RANSOMWARE_LIVE_BASE = "https://api.ransomware.live/v2"
566
566
  _RANSOMWARE_LIVE_HEADERS = {"User-Agent": "VoidAccess-OSINT/1.0", "Accept": "application/json"}
567
567
 
568
+ # Generic words that carry no group-identity signal in a realistic analyst
569
+ # query ("LockBit ransomware leak site"). Stripped before matching so the
570
+ # meaningful token(s) — e.g. "lockbit" — are what we match on, rather than
571
+ # requiring the whole query string to appear literally in a group name.
572
+ _GROUP_QUERY_STOPWORDS: frozenset[str] = frozenset({
573
+ "ransomware", "ransom", "group", "gang", "crew", "team",
574
+ "leak", "leaks", "leaksite", "site", "blog", "portal",
575
+ "data", "the", "and", "actor", "threat", "malware",
576
+ })
577
+
578
+
579
+ def _significant_group_tokens(query: str) -> list[str]:
580
+ """Meaningful lowercase tokens from a group query, stopwords removed.
581
+
582
+ Falls back to all tokens if every token was a stopword, so a query made
583
+ entirely of generic words still matches on something rather than nothing.
584
+ """
585
+ tokens = re.findall(r"[a-z0-9.]+", (query or "").lower())
586
+ tokens = [t.strip(".") for t in tokens if t.strip(".")]
587
+ significant = [t for t in tokens if t not in _GROUP_QUERY_STOPWORDS and len(t) >= 3]
588
+ return significant or tokens
589
+
590
+
591
+ def _group_name_matches_query(name: str, tokens: list[str]) -> bool:
592
+ """Token-based match of a tracked group name against query tokens.
593
+
594
+ A token matches if it is a substring of the group name (so ``lockbit``
595
+ matches ``lockbit``, ``lockbit2``, ``lockbit3``) or the group name is a
596
+ substring of the token. This is how an analyst actually phrases a query,
597
+ versus the old "entire query string must appear in the name" rule.
598
+ """
599
+ n = (name or "").lower()
600
+ if not n:
601
+ return False
602
+ return any(t and (t in n or n in t) for t in tokens)
603
+
568
604
 
569
605
  def _rl_extract_onion_urls(group: dict) -> list[str]:
570
606
  """Extract .onion leak-site URLs from a group dict (available sites first)."""
@@ -609,10 +645,11 @@ async def fetch_ransomware_live(query: str) -> list[dict]:
609
645
  return []
610
646
  all_groups = await resp.json(content_type=None)
611
647
 
648
+ _tokens = _significant_group_tokens(query)
612
649
  matched_summary: list[dict] = []
613
650
  for g in (all_groups if isinstance(all_groups, list) else []):
614
651
  name = (g.get("name") or "").lower()
615
- if q in name:
652
+ if _group_name_matches_query(name, _tokens):
616
653
  matched_summary.append(g)
617
654
 
618
655
  if not matched_summary:
@@ -904,7 +941,8 @@ async def fetch_ransomlook(query: str) -> list[dict]:
904
941
  all_groups = await resp.json(content_type=None)
905
942
 
906
943
  group_names = [g for g in all_groups if isinstance(g, str)] if isinstance(all_groups, list) else []
907
- matched = [name for name in group_names if q in name.lower()]
944
+ _tokens = _significant_group_tokens(query)
945
+ matched = [name for name in group_names if _group_name_matches_query(name, _tokens)]
908
946
 
909
947
  if not matched:
910
948
  logger.info("ransomlook.io: no groups matched %r", query)
@@ -395,7 +395,7 @@ class TestEntityRelationship:
395
395
  rel = EntityRelationship(
396
396
  entity_a_id=e1.id,
397
397
  entity_b_id=e2.id,
398
- relationship_type=RelationshipType.USED.value,
398
+ relationship_type=RelationshipType.USES.value,
399
399
  source_page_id=page.id,
400
400
  confidence=0.85,
401
401
  )
@@ -404,7 +404,7 @@ class TestEntityRelationship:
404
404
 
405
405
  assert rel.id is not None
406
406
  assert rel.confidence == 0.85
407
- assert rel.relationship_type == "USED"
407
+ assert rel.relationship_type == "USES"
408
408
 
409
409
  def test_bidirectional_orm_access(self, session):
410
410
  from db.models import EntityRelationship
@@ -788,12 +788,12 @@ class TestQueries:
788
788
  session,
789
789
  entity_a_id=e1.id,
790
790
  entity_b_id=e2.id,
791
- relationship_type="USED",
791
+ relationship_type="USES",
792
792
  source_page_id=page.id,
793
793
  confidence=0.9,
794
794
  )
795
795
  assert rel.id is not None
796
- assert rel.relationship_type == "USED"
796
+ assert rel.relationship_type == "USES"
797
797
  assert rel.confidence == 0.9
798
798
 
799
799
  def test_get_relationships_for_entity(self, session):
@@ -809,7 +809,7 @@ class TestQueries:
809
809
  e2 = create_entity(session, page_id=page.id, entity_type="malware", value="Malware_Q")
810
810
  e3 = create_entity(session, page_id=page.id, entity_type="handle", value="alias_q")
811
811
 
812
- create_entity_relationship(session, e1.id, e2.id, "USED", page.id)
812
+ create_entity_relationship(session, e1.id, e2.id, "USES", page.id)
813
813
  create_entity_relationship(session, e3.id, e1.id, "LIKELY_SAME_ACTOR", page.id)
814
814
 
815
815
  rels = get_relationships_for_entity(session, e1.id)
@@ -849,8 +849,9 @@ class TestEnums:
849
849
  from db.models import RelationshipType
850
850
  expected = {
851
851
  "CO_APPEARED_ON", "POSTED_BY", "LINKED_TO", "PAID_TO",
852
- "MEMBER_OF", "USED", "CLAIMED", "LIKELY_SAME_ACTOR",
852
+ "MEMBER_OF", "USES", "CLAIMED", "LIKELY_SAME_ACTOR",
853
853
  "CONFIRMED_SAME_ACTOR", "FUNDED_BY", "POSSIBLE_SAME_AUTHOR",
854
+ "DROPS", "CONTROLS", "TARGETS", "EXPLOITS", "COMMUNICATES_WITH",
854
855
  }
855
856
  actual = {r.value for r in RelationshipType}
856
857
  assert expected == actual
@@ -61,7 +61,7 @@ def _make_graph_with_nodes() -> nx.MultiDiGraph:
61
61
  # Edges
62
62
  g.add_edge("actor1", "wallet1", edge_type="CO_APPEARED_ON", confidence=1.0,
63
63
  source_url="http://forum1.onion/page", timestamp=now, metadata={})
64
- g.add_edge("actor1", "malware1", edge_type="USED", confidence=0.9,
64
+ g.add_edge("actor1", "malware1", edge_type="USES", confidence=0.9,
65
65
  source_url="http://forum1.onion/page", timestamp=now, metadata={})
66
66
  g.add_edge("actor1", "forum1", edge_type="MEMBER_OF", confidence=1.0,
67
67
  source_url="", timestamp=now, metadata={})
@@ -122,7 +122,7 @@ class TestModel(unittest.TestCase):
122
122
  def test_edge_types_are_strings(self):
123
123
  et = self.m.EDGE_TYPES
124
124
  for attr in ("CO_APPEARED_ON", "POSTED_BY", "LINKED_TO", "MEMBER_OF",
125
- "USED", "CLAIMED", "LIKELY_SAME_ACTOR", "CONFIRMED_SAME_ACTOR"):
125
+ "USES", "CLAIMED", "LIKELY_SAME_ACTOR", "CONFIRMED_SAME_ACTOR"):
126
126
  self.assertIsInstance(getattr(et, attr), str, msg=f"EDGE_TYPES.{attr} is not str")
127
127
 
128
128
  def test_node_types_no_duplicates(self):
@@ -138,7 +138,7 @@ class TestModel(unittest.TestCase):
138
138
  et = self.m.EDGE_TYPES
139
139
  values = [
140
140
  et.CO_APPEARED_ON, et.POSTED_BY, et.LINKED_TO, et.MEMBER_OF,
141
- et.USED, et.CLAIMED, et.LIKELY_SAME_ACTOR, et.CONFIRMED_SAME_ACTOR,
141
+ et.USES, et.CLAIMED, et.LIKELY_SAME_ACTOR, et.CONFIRMED_SAME_ACTOR,
142
142
  ]
143
143
  self.assertEqual(len(values), len(set(values)), "Duplicate EDGE_TYPES values detected")
144
144
 
@@ -700,9 +700,9 @@ class TestQueries(unittest.TestCase):
700
700
  def test_get_neighbors_edge_type_filter(self):
701
701
  """get_neighbors with edge_type filter only traverses matching edges."""
702
702
  from graph.queries import get_neighbors
703
- result = get_neighbors(self.graph, "actor1", hops=2, edge_types=["USED"])
703
+ result = get_neighbors(self.graph, "actor1", hops=2, edge_types=["USES"])
704
704
 
705
- # Only malware1 should be reachable via USED edge
705
+ # Only malware1 should be reachable via USES edge
706
706
  hop1_ids = {n.node_id for n in result.get("1", [])}
707
707
  self.assertIn("malware1", hop1_ids)
708
708
  # wallet1 should NOT be in hop 1 (it's connected via CO_APPEARED_ON)
@@ -0,0 +1,74 @@
1
+ import pytest
2
+
3
+ from extractor.relationship_extract import (
4
+ _COMPATIBILITY_GUIDE,
5
+ extract_relationships_for_page,
6
+ extract_relationships_from_results,
7
+ )
8
+
9
+
10
+ class _Response:
11
+ content = '{"relationships": [{"source": 0, "target": 1, "type": "uses", "confidence": 0.9}, {"source": 0, "target": 2, "type": "uses", "confidence": 0.9}]}'
12
+
13
+
14
+ class _LLM:
15
+ def __init__(self):
16
+ self.prompt = ""
17
+
18
+ def with_config(self, _config):
19
+ return self
20
+
21
+ async def ainvoke(self, prompt):
22
+ self.prompt = prompt
23
+ return _Response()
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_prompt_contains_shared_compatibility_guide_and_invalid_pair_is_dropped():
28
+ llm = _LLM()
29
+ entities = [
30
+ {"id": "actor", "type": "THREAT_ACTOR_HANDLE", "value": "@actor"},
31
+ {"id": "tool", "type": "TOOL", "value": "Rclone"},
32
+ {"id": "hash", "type": "FILE_HASH_SHA256", "value": "abc"},
33
+ ]
34
+
35
+ relationships = await extract_relationships_for_page("actor uses rclone", entities, llm)
36
+
37
+ assert relationships == [
38
+ {
39
+ "entity_a_id": "actor",
40
+ "entity_b_id": "tool",
41
+ "relationship_type": "USES",
42
+ "confidence": 0.9,
43
+ }
44
+ ]
45
+ assert _COMPATIBILITY_GUIDE in llm.prompt
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_relationship_provenance_uses_page_manifest_id():
50
+ class _Result:
51
+ page_url = "https://example.test/page"
52
+ entity_ids = ["actor", "org"]
53
+ entities = [
54
+ type("Entity", (), {"entity_type": "THREAT_ACTOR_HANDLE", "value": "@actor", "confidence": 1.0})(),
55
+ type("Entity", (), {"entity_type": "ORGANIZATION_NAME", "value": "Acme", "confidence": 1.0})(),
56
+ ]
57
+
58
+ class _TargetLLM(_LLM):
59
+ async def ainvoke(self, prompt):
60
+ self.prompt = prompt
61
+ return type(
62
+ "Response",
63
+ (),
64
+ {"content": '{"relationships": [{"source": 0, "target": 1, "type": "targets", "confidence": 0.8}]}'},
65
+ )()
66
+
67
+ relationships = await extract_relationships_from_results(
68
+ [_Result()],
69
+ {"https://example.test/page": "actor targets Acme"},
70
+ {"https://example.test/page": "page-123"},
71
+ _TargetLLM(),
72
+ )
73
+
74
+ assert relationships[0]["source_page_id"] == "page-123"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidaccess
3
- Version: 1.8.0
3
+ Version: 1.8.2
4
4
  Summary: Dark web OSINT CLI — automated threat intelligence from query to report
5
5
  Author: VoidAccess
6
6
  License-Expression: MIT
@@ -171,6 +171,7 @@ tests/test_paste_scraper.py
171
171
  tests/test_proxy_client.py
172
172
  tests/test_ransomlook.py
173
173
  tests/test_regex_patterns.py
174
+ tests/test_relationship_extract.py
174
175
  tests/test_rss_scraper.py
175
176
  tests/test_scrape_js.py
176
177
  tests/test_settings.py
@@ -1,3 +1,3 @@
1
1
  """voidaccess CLI — dark-web OSINT command-line interface."""
2
2
 
3
- __version__ = "1.8.0"
3
+ __version__ = "1.8.2"