voidaccess 1.7.1__tar.gz → 1.7.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 (205) hide show
  1. {voidaccess-1.7.1 → voidaccess-1.7.2}/PKG-INFO +7 -7
  2. {voidaccess-1.7.1 → voidaccess-1.7.2}/README.md +6 -6
  3. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/actors.py +10 -20
  4. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/entities.py +8 -12
  5. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/investigations.py +34 -36
  6. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/search.py +4 -6
  7. {voidaccess-1.7.1 → voidaccess-1.7.2}/config.py +13 -7
  8. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/queries.py +4 -5
  9. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/ioc_package.py +1 -1
  10. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/misp.py +2 -3
  11. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/sigma.py +2 -3
  12. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/stix.py +10 -0
  13. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/normalizer.py +95 -1
  14. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/pipeline.py +9 -0
  15. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/builder.py +143 -12
  16. {voidaccess-1.7.1 → voidaccess-1.7.2}/pyproject.toml +1 -1
  17. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/actor_profiles.py +2 -2
  18. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess/llm.py +26 -16
  19. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/PKG-INFO +7 -7
  20. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/__init__.py +1 -1
  21. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/adapters/sqlite.py +2 -2
  22. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/investigate.py +20 -17
  23. {voidaccess-1.7.1 → voidaccess-1.7.2}/LICENSE +0 -0
  24. {voidaccess-1.7.1 → voidaccess-1.7.2}/analysis/__init__.py +0 -0
  25. {voidaccess-1.7.1 → voidaccess-1.7.2}/analysis/opsec.py +0 -0
  26. {voidaccess-1.7.1 → voidaccess-1.7.2}/analysis/patterns.py +0 -0
  27. {voidaccess-1.7.1 → voidaccess-1.7.2}/analysis/temporal.py +0 -0
  28. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/__init__.py +0 -0
  29. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/auth.py +0 -0
  30. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/main.py +0 -0
  31. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/__init__.py +0 -0
  32. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/admin.py +0 -0
  33. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/auth.py +0 -0
  34. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/export.py +0 -0
  35. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/monitors.py +0 -0
  36. {voidaccess-1.7.1 → voidaccess-1.7.2}/api/routes/settings.py +0 -0
  37. {voidaccess-1.7.1 → voidaccess-1.7.2}/auth/__init__.py +0 -0
  38. {voidaccess-1.7.1 → voidaccess-1.7.2}/auth/token_blacklist.py +0 -0
  39. {voidaccess-1.7.1 → voidaccess-1.7.2}/crawler/__init__.py +0 -0
  40. {voidaccess-1.7.1 → voidaccess-1.7.2}/crawler/dedup.py +0 -0
  41. {voidaccess-1.7.1 → voidaccess-1.7.2}/crawler/frontier.py +0 -0
  42. {voidaccess-1.7.1 → voidaccess-1.7.2}/crawler/spider.py +0 -0
  43. {voidaccess-1.7.1 → voidaccess-1.7.2}/crawler/utils.py +0 -0
  44. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/__init__.py +0 -0
  45. {voidaccess-1.7.1/voidaccess → voidaccess-1.7.2/db/migrations}/__init__.py +0 -0
  46. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/env.py +0 -0
  47. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0001_initial_schema.py +0 -0
  48. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
  49. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0002_add_missing_tables.py +0 -0
  50. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
  51. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
  52. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0005_add_extraction_method.py +0 -0
  53. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
  54. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
  55. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0008_add_users_table.py +0 -0
  56. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
  57. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
  58. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
  59. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0013_add_graph_status.py +0 -0
  60. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0015_add_progress_fields.py +0 -0
  61. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
  62. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
  63. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
  64. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
  65. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
  66. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
  67. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
  68. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
  69. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/models.py +0 -0
  70. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/search_engine_stats.py +0 -0
  71. {voidaccess-1.7.1 → voidaccess-1.7.2}/db/session.py +0 -0
  72. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/__init__.py +0 -0
  73. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/snort_export.py +0 -0
  74. {voidaccess-1.7.1 → voidaccess-1.7.2}/export/yara_export.py +0 -0
  75. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/__init__.py +0 -0
  76. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/llm_extract.py +0 -0
  77. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/ner.py +0 -0
  78. {voidaccess-1.7.1 → voidaccess-1.7.2}/extractor/regex_patterns.py +0 -0
  79. {voidaccess-1.7.1 → voidaccess-1.7.2}/fingerprint/__init__.py +0 -0
  80. {voidaccess-1.7.1 → voidaccess-1.7.2}/fingerprint/profiler.py +0 -0
  81. {voidaccess-1.7.1 → voidaccess-1.7.2}/fingerprint/stylometry.py +0 -0
  82. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/__init__.py +0 -0
  83. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/export.py +0 -0
  84. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/model.py +0 -0
  85. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/queries.py +0 -0
  86. {voidaccess-1.7.1 → voidaccess-1.7.2}/graph/visualize.py +0 -0
  87. {voidaccess-1.7.1 → voidaccess-1.7.2}/i18n/__init__.py +0 -0
  88. {voidaccess-1.7.1 → voidaccess-1.7.2}/i18n/detect.py +0 -0
  89. {voidaccess-1.7.1 → voidaccess-1.7.2}/i18n/query_expand.py +0 -0
  90. {voidaccess-1.7.1 → voidaccess-1.7.2}/i18n/translate.py +0 -0
  91. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/__init__.py +0 -0
  92. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/_db.py +0 -0
  93. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/alerts.py +0 -0
  94. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/config.py +0 -0
  95. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/diff.py +0 -0
  96. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/jobs.py +0 -0
  97. {voidaccess-1.7.1 → voidaccess-1.7.2}/monitor/scheduler.py +0 -0
  98. {voidaccess-1.7.1/utils → voidaccess-1.7.2/scraper}/__init__.py +0 -0
  99. {voidaccess-1.7.1 → voidaccess-1.7.2}/scraper/scrape.py +0 -0
  100. {voidaccess-1.7.1 → voidaccess-1.7.2}/scraper/scrape_js.py +0 -0
  101. {voidaccess-1.7.1 → voidaccess-1.7.2}/search/__init__.py +0 -0
  102. {voidaccess-1.7.1 → voidaccess-1.7.2}/search/circuit_breaker.py +0 -0
  103. {voidaccess-1.7.1 → voidaccess-1.7.2}/search/query_builder.py +0 -0
  104. {voidaccess-1.7.1 → voidaccess-1.7.2}/search/search.py +0 -0
  105. {voidaccess-1.7.1 → voidaccess-1.7.2}/setup.cfg +0 -0
  106. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/__init__.py +0 -0
  107. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/blockchain.py +0 -0
  108. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/cache.py +0 -0
  109. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/cisa.py +0 -0
  110. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/data/__init__.py +0 -0
  111. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/data/onion_seeds.json +0 -0
  112. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/dns_enrichment.py +0 -0
  113. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/domain_reputation.py +0 -0
  114. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/email_reputation.py +0 -0
  115. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/engines.py +0 -0
  116. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/enrichment.py +0 -0
  117. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/github_scraper.py +0 -0
  118. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/gitlab_scraper.py +0 -0
  119. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/hash_reputation.py +0 -0
  120. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/historical_intel.py +0 -0
  121. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/ip_reputation.py +0 -0
  122. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/paste_scraper.py +0 -0
  123. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/pastes.py +0 -0
  124. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/proxy_client.py +0 -0
  125. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/rss_scraper.py +0 -0
  126. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/seed_manager.py +0 -0
  127. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/seeds.py +0 -0
  128. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/shodan.py +0 -0
  129. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/telegram.py +0 -0
  130. {voidaccess-1.7.1 → voidaccess-1.7.2}/sources/virustotal.py +0 -0
  131. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_analysis_opsec.py +0 -0
  132. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_analysis_stylometry.py +0 -0
  133. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_analysis_temporal.py +0 -0
  134. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_api.py +0 -0
  135. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_api_monitors.py +0 -0
  136. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_blockchain.py +0 -0
  137. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_cli_proxy_config.py +0 -0
  138. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_confidence_calibration.py +0 -0
  139. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_config.py +0 -0
  140. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_crawler.py +0 -0
  141. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_db.py +0 -0
  142. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_dns_enrichment.py +0 -0
  143. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_domain_reputation.py +0 -0
  144. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_email_reputation.py +0 -0
  145. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_filter_e2e_manual.py +0 -0
  146. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_filter_parser_manual.py +0 -0
  147. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_fingerprint.py +0 -0
  148. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_github_scraper.py +0 -0
  149. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_gitlab_scraper.py +0 -0
  150. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_graph.py +0 -0
  151. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_graph_integration.py +0 -0
  152. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_hash_reputation.py +0 -0
  153. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_i18n.py +0 -0
  154. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_ip_reputation.py +0 -0
  155. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_llm.py +0 -0
  156. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_llm_utils.py +0 -0
  157. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_model_singleton.py +0 -0
  158. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_monitor.py +0 -0
  159. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_monitor_integration.py +0 -0
  160. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_pagination.py +0 -0
  161. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_paste_scraper.py +0 -0
  162. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_proxy_client.py +0 -0
  163. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_regex_patterns.py +0 -0
  164. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_rss_scraper.py +0 -0
  165. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_scrape_js.py +0 -0
  166. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_settings.py +0 -0
  167. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_snort_export.py +0 -0
  168. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_sources.py +0 -0
  169. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_sources_enrichment_new.py +0 -0
  170. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_stix_export.py +0 -0
  171. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_vector.py +0 -0
  172. {voidaccess-1.7.1 → voidaccess-1.7.2}/tests/test_yara_export.py +0 -0
  173. {voidaccess-1.7.1/scraper → voidaccess-1.7.2/utils}/__init__.py +0 -0
  174. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/async_utils.py +0 -0
  175. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/content_safety.py +0 -0
  176. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/defang.py +0 -0
  177. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/encryption.py +0 -0
  178. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/enrichment_cache.py +0 -0
  179. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/ioc_freshness.py +0 -0
  180. {voidaccess-1.7.1 → voidaccess-1.7.2}/utils/user_keys.py +0 -0
  181. {voidaccess-1.7.1 → voidaccess-1.7.2}/vector/__init__.py +0 -0
  182. {voidaccess-1.7.1 → voidaccess-1.7.2}/vector/embedder.py +0 -0
  183. {voidaccess-1.7.1 → voidaccess-1.7.2}/vector/model_singleton.py +0 -0
  184. {voidaccess-1.7.1 → voidaccess-1.7.2}/vector/search.py +0 -0
  185. {voidaccess-1.7.1 → voidaccess-1.7.2}/vector/store.py +0 -0
  186. {voidaccess-1.7.1/db/migrations → voidaccess-1.7.2/voidaccess}/__init__.py +0 -0
  187. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess/config.py +0 -0
  188. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess/llm_utils.py +0 -0
  189. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/SOURCES.txt +0 -0
  190. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/dependency_links.txt +0 -0
  191. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/entry_points.txt +0 -0
  192. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/requires.txt +0 -0
  193. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess.egg-info/top_level.txt +0 -0
  194. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/adapters/__init__.py +0 -0
  195. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/browser.py +0 -0
  196. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/__init__.py +0 -0
  197. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/actors.py +0 -0
  198. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/configure.py +0 -0
  199. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/enrich.py +0 -0
  200. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/export.py +0 -0
  201. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/commands/show.py +0 -0
  202. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/config.py +0 -0
  203. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/display.py +0 -0
  204. {voidaccess-1.7.1 → voidaccess-1.7.2}/voidaccess_cli/main.py +0 -0
  205. {voidaccess-1.7.1 → voidaccess-1.7.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.7.1
3
+ Version: 1.7.2
4
4
  Summary: Dark web OSINT CLI — automated threat intelligence from query to report
5
5
  Author: VoidAccess
6
6
  License-Expression: MIT
@@ -104,11 +104,12 @@ Commercial threat intelligence platforms often charge prohibitive annual fees fo
104
104
 
105
105
  ---
106
106
 
107
- ## What's New in v1.7.1
108
- - STIX export now produces real bundles with entities and relationships again.
109
- - Entity extraction on CVE/financial queries now returns the expected entity mix instead of collapsing to organization-name noise.
110
- - Confidence scores now show multi-value spread that reflects source quality, extraction method, and corroboration.
111
- - Entity readback now includes rows linked through `InvestigationEntityLink` and dedupes canonical collisions correctly.
107
+ ## What's New in v1.7.2
108
+ - STIX export relationship generation is bounded and semantic, avoiding page-level cartesian edge explosions at larger entity counts.
109
+ - Repeated NER false positives for threat actor handles and organization names are filtered before persistence.
110
+ - MISP/Sigma/API query paths no longer use the SQLAlchemy subquery-in-`IN()` pattern that produced warnings.
111
+ - Optional configuration warnings are summarized once per process.
112
+ - Refined LLM query output is persisted as a short search string, not explanatory Markdown.
112
113
 
113
114
  ---
114
115
 
@@ -585,4 +586,3 @@ To report a security vulnerability, see [docs/SECURITY.md](docs/SECURITY.md).
585
586
 
586
587
  MIT License. See [LICENSE](LICENSE) for details.
587
588
 
588
-
@@ -19,11 +19,12 @@ Commercial threat intelligence platforms often charge prohibitive annual fees fo
19
19
 
20
20
  ---
21
21
 
22
- ## What's New in v1.7.1
23
- - STIX export now produces real bundles with entities and relationships again.
24
- - Entity extraction on CVE/financial queries now returns the expected entity mix instead of collapsing to organization-name noise.
25
- - Confidence scores now show multi-value spread that reflects source quality, extraction method, and corroboration.
26
- - Entity readback now includes rows linked through `InvestigationEntityLink` and dedupes canonical collisions correctly.
22
+ ## What's New in v1.7.2
23
+ - STIX export relationship generation is bounded and semantic, avoiding page-level cartesian edge explosions at larger entity counts.
24
+ - Repeated NER false positives for threat actor handles and organization names are filtered before persistence.
25
+ - MISP/Sigma/API query paths no longer use the SQLAlchemy subquery-in-`IN()` pattern that produced warnings.
26
+ - Optional configuration warnings are summarized once per process.
27
+ - Refined LLM query output is persisted as a short search string, not explanatory Markdown.
27
28
 
28
29
  ---
29
30
 
@@ -500,4 +501,3 @@ To report a security vulnerability, see [docs/SECURITY.md](docs/SECURITY.md).
500
501
 
501
502
  MIT License. See [LICENSE](LICENSE) for details.
502
503
 
503
-
@@ -22,7 +22,7 @@ from typing import Optional
22
22
 
23
23
  from fastapi import APIRouter, Depends, HTTPException, Query
24
24
  from pydantic import BaseModel, Field
25
- from sqlalchemy import or_
25
+ from sqlalchemy import or_, select
26
26
 
27
27
  from api.auth import CurrentUser, get_current_user
28
28
 
@@ -88,26 +88,22 @@ def _db_required() -> None:
88
88
  raise HTTPException(status_code=503, detail="Database not configured")
89
89
 
90
90
 
91
- def _user_owned_investigation_ids_subq(session, user_id: int):
91
+ def _user_owned_investigation_ids_select(user_id: int):
92
92
  from db.models import Investigation
93
93
 
94
- return (
95
- session.query(Investigation.id)
96
- .filter(Investigation.user_id == user_id)
97
- .subquery()
98
- )
94
+ return select(Investigation.id).where(Investigation.user_id == user_id)
99
95
 
100
96
 
101
97
  def _profile_accessible(session, profile_id: str, user_id: int) -> bool:
102
98
  """True when at least one investigation linked to the profile is owned by the user."""
103
99
  from db.models import ActorAlias, ActorInfrastructure
104
100
 
105
- inv_ids_subq = _user_owned_investigation_ids_subq(session, user_id)
101
+ inv_ids_select = _user_owned_investigation_ids_select(user_id)
106
102
  alias_match = (
107
103
  session.query(ActorAlias.id)
108
104
  .filter(
109
105
  ActorAlias.actor_id == profile_id,
110
- ActorAlias.source_investigation_id.in_(inv_ids_subq),
106
+ ActorAlias.source_investigation_id.in_(inv_ids_select),
111
107
  )
112
108
  .first()
113
109
  )
@@ -117,7 +113,7 @@ def _profile_accessible(session, profile_id: str, user_id: int) -> bool:
117
113
  session.query(ActorInfrastructure.id)
118
114
  .filter(
119
115
  ActorInfrastructure.actor_id == profile_id,
120
- ActorInfrastructure.source_investigation_id.in_(inv_ids_subq),
116
+ ActorInfrastructure.source_investigation_id.in_(inv_ids_select),
121
117
  )
122
118
  .first()
123
119
  )
@@ -146,8 +142,6 @@ async def list_actors(
146
142
  users since the seed is public data.
147
143
  """
148
144
  _db_required()
149
- from sqlalchemy import select
150
-
151
145
  from db.models import (
152
146
  ActorProfile,
153
147
  ActorAlias,
@@ -156,9 +150,7 @@ async def list_actors(
156
150
 
157
151
  try:
158
152
  with _session() as session:
159
- user_inv_ids = _user_owned_investigation_ids_subq(
160
- session, current_user.user.id
161
- )
153
+ user_inv_ids = _user_owned_investigation_ids_select(current_user.user.id)
162
154
 
163
155
  # Subquery of profile IDs visible to this user
164
156
  visible_alias = select(ActorAlias.actor_id).where(
@@ -176,13 +168,13 @@ async def list_actors(
176
168
  )
177
169
  if search:
178
170
  pattern = f"%{search.lower()}%"
179
- alias_subq = select(ActorAlias.actor_id).where(
171
+ alias_select = select(ActorAlias.actor_id).where(
180
172
  ActorAlias.alias_value.ilike(pattern)
181
173
  )
182
174
  q = q.filter(
183
175
  or_(
184
176
  ActorProfile.canonical_handle.ilike(pattern),
185
- ActorProfile.id.in_(alias_subq),
177
+ ActorProfile.id.in_(alias_select),
186
178
  )
187
179
  )
188
180
 
@@ -313,9 +305,7 @@ async def get_actor_investigations(
313
305
 
314
306
  try:
315
307
  with _session() as session:
316
- user_inv_ids = _user_owned_investigation_ids_subq(
317
- session, current_user.user.id
318
- )
308
+ user_inv_ids = _user_owned_investigation_ids_select(current_user.user.id)
319
309
  all_ids = [
320
310
  uuid_value(s)
321
311
  for s in profile.get("investigation_ids", [])
@@ -57,14 +57,12 @@ async def list_entities(
57
57
 
58
58
  with get_session() as session:
59
59
  user_inv_ids = (
60
- session.query(Investigation.id)
61
- .filter(Investigation.user_id == current_user.user.id)
62
- .subquery()
60
+ sa.select(Investigation.id)
61
+ .where(Investigation.user_id == current_user.user.id)
63
62
  )
64
63
  linked_entity_ids = (
65
- session.query(InvestigationEntityLink.entity_id)
66
- .filter(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
67
- .subquery()
64
+ sa.select(InvestigationEntityLink.entity_id)
65
+ .where(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
68
66
  )
69
67
  q = session.query(Entity).filter(
70
68
  sa.or_(
@@ -749,14 +747,12 @@ def _assert_entity_accessible(session, entity_id: uuid.UUID, user_id: int) -> No
749
747
  from db.models import Entity, Investigation, InvestigationEntityLink # noqa: PLC0415
750
748
 
751
749
  user_inv_ids = (
752
- session.query(Investigation.id)
753
- .filter(Investigation.user_id == user_id)
754
- .subquery()
750
+ sa.select(Investigation.id)
751
+ .where(Investigation.user_id == user_id)
755
752
  )
756
753
  linked_entity_ids = (
757
- session.query(InvestigationEntityLink.entity_id)
758
- .filter(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
759
- .subquery()
754
+ sa.select(InvestigationEntityLink.entity_id)
755
+ .where(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
760
756
  )
761
757
  accessible = (
762
758
  session.query(Entity.id)
@@ -672,30 +672,30 @@ def _get_db_investigation(investigation_id: str) -> Any:
672
672
  )
673
673
 
674
674
  # Entity IDs for this investigation = own entities + junction-table links
675
- linked_ids_subq = (
676
- session.query(InvestigationEntityLink.entity_id)
677
- .filter(InvestigationEntityLink.investigation_id == inv.id)
678
- .subquery()
679
- )
680
- entity_subq = (
681
- session.query(Entity.id)
682
- .filter(
683
- (Entity.investigation_id == inv.id)
684
- | Entity.id.in_(linked_ids_subq)
685
- )
686
- .subquery()
687
- )
688
- entity_count = int(
689
- session.query(func.count()).select_from(entity_subq).scalar() or 0
690
- )
691
- relationship_count = int(
692
- session.query(func.count(EntityRelationship.id))
693
- .filter(
694
- (EntityRelationship.entity_a_id.in_(entity_subq))
695
- | (EntityRelationship.entity_b_id.in_(entity_subq))
696
- )
697
- .scalar()
698
- or 0
675
+ linked_ids_select = (
676
+ sa_select(InvestigationEntityLink.entity_id)
677
+ .where(InvestigationEntityLink.investigation_id == inv.id)
678
+ )
679
+ entity_subq = (
680
+ session.query(Entity.id)
681
+ .filter(
682
+ (Entity.investigation_id == inv.id)
683
+ | Entity.id.in_(linked_ids_select)
684
+ )
685
+ .subquery()
686
+ )
687
+ entity_ids_select = sa_select(entity_subq.c.id)
688
+ entity_count = int(
689
+ session.query(func.count()).select_from(entity_subq).scalar() or 0
690
+ )
691
+ relationship_count = int(
692
+ session.query(func.count(EntityRelationship.id))
693
+ .filter(
694
+ (EntityRelationship.entity_a_id.in_(entity_ids_select))
695
+ | (EntityRelationship.entity_b_id.in_(entity_ids_select))
696
+ )
697
+ .scalar()
698
+ or 0
699
699
  )
700
700
 
701
701
  # Phase 6.1 — sources_used / infrastructure_clusters from DB metadata.
@@ -2673,14 +2673,13 @@ async def get_investigation_entities(
2673
2673
  if str(inv.user_id) != str(current_user.user.id):
2674
2674
  raise HTTPException(status_code=403, detail="Forbidden")
2675
2675
 
2676
- linked_ids_subq = (
2677
- session.query(InvestigationEntityLink.entity_id)
2678
- .filter(InvestigationEntityLink.investigation_id == inv.id)
2679
- .subquery()
2680
- )
2676
+ linked_ids_select = (
2677
+ sa_select(InvestigationEntityLink.entity_id)
2678
+ .where(InvestigationEntityLink.investigation_id == inv.id)
2679
+ )
2681
2680
  query = session.query(Entity).filter(
2682
2681
  (Entity.investigation_id == inv.id)
2683
- | Entity.id.in_(linked_ids_subq)
2682
+ | Entity.id.in_(linked_ids_select)
2684
2683
  )
2685
2684
  if entity_type:
2686
2685
  query = query.filter(Entity.entity_type == entity_type)
@@ -2800,16 +2799,15 @@ async def export_investigation_entities_csv(
2800
2799
  if str(inv.user_id) != str(current_user.user.id):
2801
2800
  raise HTTPException(status_code=403, detail="Forbidden")
2802
2801
 
2803
- linked_ids_subq = (
2804
- session.query(InvestigationEntityLink.entity_id)
2805
- .filter(InvestigationEntityLink.investigation_id == inv.id)
2806
- .subquery()
2807
- )
2802
+ linked_ids_select = (
2803
+ sa_select(InvestigationEntityLink.entity_id)
2804
+ .where(InvestigationEntityLink.investigation_id == inv.id)
2805
+ )
2808
2806
  entities = (
2809
2807
  session.query(Entity)
2810
2808
  .filter(
2811
2809
  (Entity.investigation_id == inv.id)
2812
- | Entity.id.in_(linked_ids_subq)
2810
+ | Entity.id.in_(linked_ids_select)
2813
2811
  )
2814
2812
  .all()
2815
2813
  )
@@ -138,14 +138,12 @@ async def search_entities(
138
138
 
139
139
  with get_session() as session:
140
140
  user_inv_ids = (
141
- session.query(Investigation.id)
142
- .filter(Investigation.user_id == current_user.user.id)
143
- .subquery()
141
+ sa.select(Investigation.id)
142
+ .where(Investigation.user_id == current_user.user.id)
144
143
  )
145
144
  linked_entity_ids = (
146
- session.query(InvestigationEntityLink.entity_id)
147
- .filter(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
148
- .subquery()
145
+ sa.select(InvestigationEntityLink.entity_id)
146
+ .where(InvestigationEntityLink.investigation_id.in_(user_inv_ids))
149
147
  )
150
148
  q = session.query(Entity).filter(
151
149
  sa.or_(
@@ -6,6 +6,7 @@ from dotenv import load_dotenv
6
6
  load_dotenv()
7
7
 
8
8
  logger = logging.getLogger(__name__)
9
+ _OPTIONAL_CONFIG_WARNING_SHOWN = False
9
10
 
10
11
 
11
12
  def _clean_env(name, default=None):
@@ -183,19 +184,24 @@ OPTIONAL_KEYS = [
183
184
 
184
185
 
185
186
  def validate_config():
187
+ global _OPTIONAL_CONFIG_WARNING_SHOWN
186
188
  missing_required = []
187
189
  for key in REQUIRED_KEYS:
188
190
  if _clean_env(key) is None:
189
191
  missing_required.append(key)
190
192
  if missing_required:
191
193
  raise RuntimeError(f"Missing required configuration keys: {', '.join(missing_required)}")
192
- for key in OPTIONAL_KEYS:
193
- val = _clean_env(key)
194
- if val is None or val == "":
195
- logger.warning(
196
- "Optional configuration key '%s' is not set - related features will be disabled",
197
- key,
198
- )
194
+ missing_optional = [
195
+ key for key in OPTIONAL_KEYS
196
+ if (_clean_env(key) is None or _clean_env(key) == "")
197
+ ]
198
+ if missing_optional and not _OPTIONAL_CONFIG_WARNING_SHOWN:
199
+ logger.warning(
200
+ "Optional configuration keys not set (%d): %s - related features will be disabled",
201
+ len(missing_optional),
202
+ ", ".join(missing_optional),
203
+ )
204
+ _OPTIONAL_CONFIG_WARNING_SHOWN = True
199
205
 
200
206
 
201
207
  validate_config()
@@ -69,16 +69,15 @@ def count_distinct_pages_for_investigation(
69
69
  junction table (deduped entities from previous investigations re-linked here).
70
70
  """
71
71
  from db.models import InvestigationEntityLink # noqa: PLC0415
72
- linked_ids_subq = (
73
- session.query(InvestigationEntityLink.entity_id)
74
- .filter(InvestigationEntityLink.investigation_id == investigation_id)
75
- .subquery()
72
+ linked_ids_select = (
73
+ sa.select(InvestigationEntityLink.entity_id)
74
+ .where(InvestigationEntityLink.investigation_id == investigation_id)
76
75
  )
77
76
  n = (
78
77
  session.query(sa.func.count(sa.distinct(Entity.page_id)))
79
78
  .filter(
80
79
  (Entity.investigation_id == investigation_id)
81
- | Entity.id.in_(linked_ids_subq)
80
+ | Entity.id.in_(linked_ids_select)
82
81
  )
83
82
  .scalar()
84
83
  )
@@ -54,7 +54,7 @@ logger = logging.getLogger(__name__)
54
54
  # ---------------------------------------------------------------------------
55
55
 
56
56
  PACKAGE_FORMAT = "voidaccess-ioc-v1"
57
- PACKAGE_VERSION = "1.7.1"
57
+ PACKAGE_VERSION = "1.7.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.
@@ -192,16 +192,15 @@ def _load_investigation_and_entities(
192
192
  if investigation is None:
193
193
  return None, []
194
194
 
195
- linked_ids_subq = (
195
+ linked_ids_select = (
196
196
  session.query(InvestigationEntityLink.entity_id)
197
197
  .filter(InvestigationEntityLink.investigation_id == investigation.id)
198
- .subquery()
199
198
  )
200
199
  db_entities = (
201
200
  session.query(Entity)
202
201
  .filter(
203
202
  (Entity.investigation_id == investigation.id)
204
- | Entity.id.in_(linked_ids_subq)
203
+ | Entity.id.in_(linked_ids_select)
205
204
  )
206
205
  .all()
207
206
  )
@@ -294,16 +294,15 @@ def _load_entities_for_investigation(investigation_id: Any) -> list[Any]:
294
294
  if inv is None:
295
295
  return []
296
296
 
297
- linked_ids_subq = (
297
+ linked_ids_select = (
298
298
  session.query(InvestigationEntityLink.entity_id)
299
299
  .filter(InvestigationEntityLink.investigation_id == inv.id)
300
- .subquery()
301
300
  )
302
301
  db_entities = (
303
302
  session.query(Entity)
304
303
  .filter(
305
304
  (Entity.investigation_id == inv.id)
306
- | Entity.id.in_(linked_ids_subq)
305
+ | Entity.id.in_(linked_ids_select)
307
306
  )
308
307
  .all()
309
308
  )
@@ -63,6 +63,9 @@ _STIX_PATTERNS: dict[str, str] = {
63
63
  "ETHEREUM_ADDRESS": "[cryptocurrency-wallet:address = '{value}']",
64
64
  "MONERO_ADDRESS": "[cryptocurrency-wallet:address = '{value}']",
65
65
  "EMAIL_ADDRESS": "[email-message:from_ref.value = '{value}']",
66
+ "FILE_HASH_MD5": "[file:hashes.MD5 = '{value}']",
67
+ "FILE_HASH_SHA1": "[file:hashes.'SHA-1' = '{value}']",
68
+ "FILE_HASH_SHA256": "[file:hashes.'SHA-256' = '{value}']",
66
69
  "ONION_URL": "[url:value = '{value}']",
67
70
  "IP_ADDRESS": "[ipv4-addr:value = '{value}']",
68
71
  "CVE_NUMBER": "[vulnerability:name = '{value}']",
@@ -111,6 +114,8 @@ def entity_to_stix_indicator(entity: Any) -> Optional[Any]:
111
114
  indicator_types = ["malicious-activity"]
112
115
  elif etype in ("BITCOIN_ADDRESS", "ETHEREUM_ADDRESS", "MONERO_ADDRESS"):
113
116
  indicator_types = ["malicious-activity"]
117
+ elif etype in ("FILE_HASH_MD5", "FILE_HASH_SHA1", "FILE_HASH_SHA256"):
118
+ indicator_types = ["malicious-activity"]
114
119
  elif etype in ("IP_ADDRESS", "ONION_URL"):
115
120
  indicator_types = ["malicious-activity"]
116
121
  elif etype == "CVE_NUMBER":
@@ -424,6 +429,7 @@ def _build_stix_relationships(
424
429
  graph = build_graph_from_db(investigation_id=inv_uuid)
425
430
 
426
431
  relationships: list[Any] = []
432
+ seen_relationships: set[tuple[str, str, str]] = set()
427
433
  for source_node, target_node, data in graph.edges(data=True):
428
434
  src_stix_id = stix_id_map.get(source_node)
429
435
  tgt_stix_id = stix_id_map.get(target_node)
@@ -432,6 +438,10 @@ def _build_stix_relationships(
432
438
  edge_type = data.get("edge_type", "related-to")
433
439
  # Map VoidAccess edge types to STIX relationship types
434
440
  rel_type = _edge_type_to_stix(edge_type)
441
+ relationship_key = (rel_type, src_stix_id, tgt_stix_id)
442
+ if relationship_key in seen_relationships:
443
+ continue
444
+ seen_relationships.add(relationship_key)
435
445
  try:
436
446
  rel = stix2.Relationship(
437
447
  relationship_type=rel_type,
@@ -623,6 +623,11 @@ ENTITY_BLOCKLIST: frozenset[str] = frozenset({
623
623
  "moderator", "administrator", "operator", "staff", "support",
624
624
  "customer", "vendor", "buyer", "seller", "trader",
625
625
  "dropper", "loader", "stager", "payload", "beacon",
626
+ # Audit-confirmed NER false positives (1.6.2, 1.7.0, 1.7.1).
627
+ "asks", "keyword", "sends", "input", "content", "delivery", "into",
628
+ "submits", "advertising", "attempts", "pass", "consent", "devices",
629
+ "stdin", "stdout", "detect", "uri_regex:1", "intel", "passive",
630
+ "commands",
626
631
  })
627
632
 
628
633
  KNOWN_TOOLS: frozenset[str] = frozenset({
@@ -633,7 +638,15 @@ KNOWN_TOOLS: frozenset[str] = frozenset({
633
638
  "kali", "parrot", "blackarch", "backtrack",
634
639
  })
635
640
 
641
+ COMMON_THREAT_TERMS: frozenset[str] = frozenset({
642
+ "apt41", "apt28", "apt29", "fin7", "fin8", "lockbitsupp",
643
+ "scatteredspider", "lazarus", "sandworm", "blackcat", "alphv",
644
+ })
645
+
636
646
  LEET_GENERIC = re.compile(r"^h[4a][ck]+[3e]?r?$")
647
+ HANDLE_SHAPE_RE = re.compile(
648
+ r"^(?:[A-Z]{2,}\d{1,3}|[A-Za-z]+[A-Z][A-Za-z0-9]*|[A-Za-z0-9]+[_\-.][A-Za-z0-9_.-]+|[A-Za-z]+\d+[A-Za-z0-9]*)$"
649
+ )
637
650
 
638
651
 
639
652
  ENTITY_MIN_LENGTH: dict[str, int] = {
@@ -723,6 +736,13 @@ def is_blocked_entity(entity_type: str, entity_value: str) -> bool:
723
736
  return True
724
737
  if LEET_GENERIC.match(value_lower):
725
738
  return True
739
+ if value_lower in COMMON_THREAT_TERMS:
740
+ return False
741
+ if re.fullmatch(r"[a-z]+", value_lower) and not HANDLE_SHAPE_RE.match(entity_value.strip()):
742
+ return True
743
+
744
+ if entity_type == "ORGANIZATION_NAME" and value_lower in ENTITY_BLOCKLIST:
745
+ return True
726
746
 
727
747
  min_len = ENTITY_MIN_LENGTH.get(entity_type, 3)
728
748
  if len(value_lower) < min_len:
@@ -868,13 +888,87 @@ _ORG_NOISE_RE = re.compile(
868
888
  r'|[<>{}\[\]|\\]',
869
889
  re.IGNORECASE,
870
890
  )
891
+ _HTML_ENTITY_RE = re.compile(r"&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]+);?", re.IGNORECASE)
892
+ _DECODED_HTML_UI_RE = re.compile(r"[\u2190-\u21ff\u00ae\u00a9\u2122]")
893
+ _ORG_SENTENCE_WORDS: frozenset[str] = frozenset({
894
+ "the", "a", "an", "how", "cancel", "reply", "post", "posts", "latest",
895
+ "popular", "tutorial", "tutorials", "guide", "home", "news", "security",
896
+ "advertisement", "advertising", "speaking", "subscribe", "mailing",
897
+ "list", "leave", "recent", "author", "actively", "exploited", "analyzing",
898
+ "targeted", "intrusions", "through", "stay", "upcoming", "seizes",
899
+ "related", "articles", "dummies", "vulnerability", "date", "added",
900
+ "expands", "leadership", "approach", "radar", "supply", "chain",
901
+ "compromise", "commercial", "live", "proxies", "software", "office",
902
+ "oauth", "os", "application", "server", "protect", "sharepoint",
903
+ "tv", "stealer", "stack", "protection", "organization", "redacted",
904
+ "organizations", "are", "building", "achieves", "patches", "moves",
905
+ "contact", "rule", "addition", "activated", "scale", "automation",
906
+ "algorithmic", "payload", "decomposition", "third-party", "third",
907
+ "party", "additional", "resources", "learn", "join", "dll",
908
+ "sideloading", "factory", "malware", "threat", "research", "tags",
909
+ "copilot", "next-gen", "xmrig", "authenticode", "updatefactor",
910
+ "coldfusion", "falcon", "openid", "overwatch", "analysis", "table",
911
+ "antimalware", "scan", "interface", "bypass", "static", "file-size",
912
+ "inflation", "loaders", "data", "exe", "getassertion",
913
+ "getcredentials", "imphash", "cluster", "role", "remote", "desktop",
914
+ "protocol", "sha-256", "webauthn", "yubikey", "systemagentservice",
915
+ "chromium", "clientdatahash", "gift", "card", "fraud", "macos",
916
+ "linux", "tools", "typescript", "webassembly", "webhid",
917
+ "ai-hallucinated", "hallucinated", "domains", "comparison", "llm",
918
+ "dataset", "jaccard", "multi-signal", "risk", "classification",
919
+ "share", "executive", "summary", "temperature", "configuration",
920
+ "account", "numbers", "balanced", "llm2", "briefs",
921
+ })
922
+ _ORG_ALLOWED_CONNECTORS: frozenset[str] = frozenset({
923
+ "and", "of", "for", "to", "in", "on", "with",
924
+ })
925
+ _ORG_SUFFIX_HINTS: frozenset[str] = frozenset({
926
+ "agency", "bank", "bureau", "center", "centre", "committee", "company",
927
+ "corp", "corporation", "council", "department", "directorate", "division",
928
+ "foundation", "gmbh", "group", "inc", "institute", "labs", "limited",
929
+ "llc", "ltd", "networks", "team", "technologies", "university", "union",
930
+ })
871
931
 
872
932
 
873
933
  def _is_valid_org_name(value: str) -> bool:
874
934
  v = value.strip()
935
+ v_lower = v.lower()
936
+ if v_lower in ENTITY_BLOCKLIST:
937
+ return False
875
938
  if len(v) < 3 or len(v) > 60:
876
939
  return False
877
- if len(v.split()) > 5:
940
+ if re.search(r"[:,/()]", v):
941
+ return False
942
+ if re.search(r"\.[a-z0-9]{2,5}\b", v_lower):
943
+ return False
944
+ if re.search(r"\d{1,2}/\d{1,2}/\d{2,4}", v):
945
+ return False
946
+ if _HTML_ENTITY_RE.search(v) or _DECODED_HTML_UI_RE.search(v):
947
+ return False
948
+ if re.search(r"\b[a-z]+['\u2019]\w*\b", v_lower):
949
+ return False
950
+ tokens = re.findall(r"[a-z0-9]+(?:[-.][a-z0-9]+)*", v_lower)
951
+ if not tokens:
952
+ return False
953
+ if any(token.isdigit() for token in tokens):
954
+ return False
955
+ if any(token == "cve" or token.startswith("cve-") for token in tokens):
956
+ return False
957
+ if len(tokens) > 4:
958
+ return False
959
+ if "-" in v and not any(token in _ORG_SUFFIX_HINTS for token in tokens):
960
+ return False
961
+ if any(token in _ORG_SENTENCE_WORDS for token in tokens):
962
+ return False
963
+ word_tokens = [token for token in tokens if re.search(r"[a-z]", token)]
964
+ if len(word_tokens) > 1:
965
+ generic_words = [
966
+ token for token in word_tokens
967
+ if token in ENTITY_BLOCKLIST or token in _ORG_ALLOWED_CONNECTORS
968
+ ]
969
+ if len(generic_words) >= len(word_tokens):
970
+ return False
971
+ if re.fullmatch(r"[A-Z_]+:?\d*", v) and v_lower not in COMMON_THREAT_TERMS:
878
972
  return False
879
973
  if _ORG_NOISE_RE.search(v):
880
974
  return False
@@ -19,6 +19,7 @@ ExtractionResult is a dataclass exported through extractor/__init__.py.
19
19
  from __future__ import annotations
20
20
 
21
21
  import asyncio
22
+ import html
22
23
  import logging
23
24
  from dataclasses import dataclass, field
24
25
  from typing import Any, Optional
@@ -175,6 +176,13 @@ def _type_priority(entity_type: str) -> int:
175
176
  return priority
176
177
  return 99
177
178
 
179
+
180
+ def _clean_text_for_extraction(text: str) -> str:
181
+ """Decode HTML entities before regex/NER/LLM extraction sees page text."""
182
+ if not text:
183
+ return ""
184
+ return html.unescape(text)
185
+
178
186
  # ---------------------------------------------------------------------------
179
187
  # Result dataclass
180
188
  # ---------------------------------------------------------------------------
@@ -231,6 +239,7 @@ async def extract_entities_from_page(
231
239
  by regex, or below the top-N priority cut).
232
240
  """
233
241
  errors: list[str] = []
242
+ page_text = _clean_text_for_extraction(page_text or "")
234
243
 
235
244
  # -----------------------------------------------------------------------
236
245
  # Stage 1 — Regex