voidaccess 1.9.3__tar.gz → 1.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voidaccess-1.9.3 → voidaccess-1.9.4}/PKG-INFO +1 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/entities.py +6 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/investigations.py +2 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/env.py +29 -1
- voidaccess-1.9.4/db/migrations/versions/0026_add_entity_investigation_count.py +46 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/models.py +5 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/queries.py +26 -6
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/ioc_package.py +1 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/entity_shape.py +1 -8
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/gazetteer.py +58 -8
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/ner.py +20 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/normalizer.py +16 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/builder.py +16 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/pyproject.toml +1 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/search/__init__.py +15 -13
- voidaccess-1.9.4/search/config.py +13 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/search/search.py +15 -13
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/data/onion_seeds.json +11 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/ioc_freshness.py +13 -4
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/PKG-INFO +1 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/SOURCES.txt +2 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/__init__.py +1 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/adapters/sqlite.py +3 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/browser.py +2 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/export.py +3 -1
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/investigate.py +33 -4
- {voidaccess-1.9.3 → voidaccess-1.9.4}/LICENSE +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/README.md +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/opsec.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/patterns.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/analysis/temporal.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/auth.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/errors.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/main.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/actors.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/admin.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/auth.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/monitors.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/search.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/api/routes/settings.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/auth/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/auth/token_blacklist.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/dedup.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/frontier.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/spider.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/crawler/utils.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0001_initial_schema.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0002_add_missing_tables.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0005_add_extraction_method.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0008_add_users_table.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0013_add_graph_status.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0015_add_progress_fields.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/search_engine_stats.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/db/session.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/_entity_loading.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/misp.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/relationships.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/sigma.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/snort_export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/stix.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/export/yara_export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/confidence.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/identity.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/llm_extract.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/pipeline.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/regex_patterns.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/extractor/relationship_extract.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/calibration.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/profiler.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/fingerprint/stylometry.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/model.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/queries.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/graph/visualize.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/detect.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/query_expand.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/i18n/translate.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/_db.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/alerts.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/diff.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/jobs.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/monitor/scheduler.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/scrape.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/scraper/scrape_js.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/search/circuit_breaker.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/search/engine_catalog.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/search/query_builder.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/setup.cfg +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/actor_profiles.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/blockchain.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/breach_lookup.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/cache.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/cisa.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/data/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/dns_enrichment.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/domain_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/email_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/engines.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/enrichment.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/github_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/gitlab_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/hash_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/historical_intel.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/infostealer.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/ip_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/nvd.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/paste_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/pastes.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/proxy_client.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/rss_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/seed_manager.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/seeds.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/shodan.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/telegram.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/sources/virustotal.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_opsec.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_stylometry.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_analysis_temporal.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_api.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_api_monitors.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_blockchain.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_breach_lookup.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_cli_proxy_config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_confidence_calibration.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_consolidated_contracts.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_crawler.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_db.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_dns_enrichment.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_domain_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_email_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_extractor_llm_pipeline.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_filter_e2e_manual.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_filter_parser_manual.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_fingerprint.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_github_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_gitlab_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_graph.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_graph_integration.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_hash_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_i18n.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_infostealer.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_ip_reputation.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_llm.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_llm_utils.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_model_singleton.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_monitor.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_monitor_integration.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_nvd.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_pagination.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_paste_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_proxy_client.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_ransomlook.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_regex_patterns.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_relationship_extract.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_rss_scraper.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_scrape_js.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_settings.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_snort_export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_sources.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_sources_enrichment_new.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_stix_export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_vector.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/tests/test_yara_export.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/async_utils.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/content_dedup.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/content_safety.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/defang.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/encryption.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/enrichment_cache.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/investigation_metrics.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/utils/user_keys.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/embedder.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/model_singleton.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/search.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/vector/store.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/llm.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess/llm_utils.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/dependency_links.txt +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/entry_points.txt +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/requires.txt +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess.egg-info/top_level.txt +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/adapters/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/__init__.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/actors.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/configure.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/enrich.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/commands/show.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/config.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/display.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/main.py +0 -0
- {voidaccess-1.9.3 → voidaccess-1.9.4}/voidaccess_cli/tor_detect.py +0 -0
|
@@ -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
|
|
649
|
+
"cross_referenced": (getattr(entity, "investigation_count", 1) or 1) > 1,
|
|
648
650
|
"defanged": defang,
|
|
649
651
|
"blockchain_data": {
|
|
650
652
|
"wallet_type": entity.entity_type if entity.entity_type in ("BITCOIN_ADDRESS", "ETHEREUM_ADDRESS", "MONERO_ADDRESS") else None,
|
|
@@ -811,6 +813,9 @@ def _entity_to_dict(entity) -> dict: # type: ignore[type-arg]
|
|
|
811
813
|
"investigation_id": str(entity.investigation_id) if entity.investigation_id else None,
|
|
812
814
|
"created_at": entity.created_at.isoformat() if entity.created_at else None,
|
|
813
815
|
"extraction_method": getattr(entity, "extraction_method", None),
|
|
816
|
+
"source_count": getattr(entity, "source_count", 1) or 1,
|
|
817
|
+
"investigation_count": getattr(entity, "investigation_count", 1) or 1,
|
|
818
|
+
"corroborating_sources": getattr(entity, "corroborating_sources", None),
|
|
814
819
|
}
|
|
815
820
|
|
|
816
821
|
|
|
@@ -2925,8 +2925,9 @@ async def get_investigation_entities(
|
|
|
2925
2925
|
"freshness_label": freshness_display["label"],
|
|
2926
2926
|
"freshness_color": freshness_display["color"],
|
|
2927
2927
|
"source_count": e.source_count or 1,
|
|
2928
|
+
"investigation_count": getattr(e, "investigation_count", 1) or 1,
|
|
2928
2929
|
"corroborating_sources": json.loads(e.corroborating_sources or '["dark_web_scrape"]'),
|
|
2929
|
-
"cross_referenced": (e
|
|
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
|
-
) ->
|
|
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
|
-
|
|
364
|
-
|
|
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.
|
|
60
|
+
PACKAGE_VERSION = "1.9.4"
|
|
61
61
|
SNORT_SID_BASE = 9000001 # 9xxxxxx reserved for VoidAccess-generated rules
|
|
62
62
|
|
|
63
63
|
# Map extractor upper-case entity types to the per-file bucket they belong to.
|
|
@@ -93,14 +93,7 @@ def _load_words() -> None:
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
# Organisation suffixes that strongly signal a real org name.
|
|
96
|
-
_ORG_SUFFIXES
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
|
@@ -23,10 +23,12 @@ from db.search_engine_stats import (
|
|
|
23
23
|
from search.query_builder import diversify_query
|
|
24
24
|
from utils.async_utils import run_async
|
|
25
25
|
from search.engine_catalog import DEFAULT_SEARCH_ENGINES, SEARCH_ENGINES
|
|
26
|
+
from search import config as search_config
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
# Compatibility exports; runtime reads use the shared config module below.
|
|
31
|
+
ENGINE_TIMEOUT = search_config.ENGINE_TIMEOUT
|
|
30
32
|
|
|
31
33
|
ENGINE_WEIGHTS = {
|
|
32
34
|
"darksearch": 1.0,
|
|
@@ -57,8 +59,8 @@ USER_AGENTS = [
|
|
|
57
59
|
_ONION_URL_RE = re.compile(r'https?:\/\/[a-z0-9.]+\.onion', re.IGNORECASE)
|
|
58
60
|
|
|
59
61
|
MAX_CONCURRENT = 10
|
|
60
|
-
SEARCH_TIMEOUT =
|
|
61
|
-
ENGINE_RETRY_COUNT =
|
|
62
|
+
SEARCH_TIMEOUT = search_config.SEARCH_TIMEOUT
|
|
63
|
+
ENGINE_RETRY_COUNT = search_config.ENGINE_RETRY_COUNT
|
|
62
64
|
|
|
63
65
|
_ENGINE_STATUS: dict[str, dict] = {}
|
|
64
66
|
_LAST_SEARCH_SUMMARY: dict[str, int] = {}
|
|
@@ -109,7 +111,7 @@ def get_search_session() -> aiohttp.ClientSession:
|
|
|
109
111
|
connector = _tor_aiohttp_connector()
|
|
110
112
|
_search_session = aiohttp.ClientSession(
|
|
111
113
|
connector=connector,
|
|
112
|
-
timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT),
|
|
114
|
+
timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT),
|
|
113
115
|
)
|
|
114
116
|
return _search_session
|
|
115
117
|
|
|
@@ -129,7 +131,7 @@ async def fetch_with_timeout(
|
|
|
129
131
|
"""Fetch a URL with timeout using the provided or cached session."""
|
|
130
132
|
if session is None:
|
|
131
133
|
session = get_search_session()
|
|
132
|
-
return await session.get(url, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT))
|
|
134
|
+
return await session.get(url, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT))
|
|
133
135
|
|
|
134
136
|
|
|
135
137
|
async def _fetch_engine(
|
|
@@ -145,12 +147,12 @@ async def _fetch_engine(
|
|
|
145
147
|
headers = {"User-Agent": random.choice(USER_AGENTS)}
|
|
146
148
|
|
|
147
149
|
async with semaphore:
|
|
148
|
-
for attempt in range(ENGINE_RETRY_COUNT + 1):
|
|
150
|
+
for attempt in range(search_config.ENGINE_RETRY_COUNT + 1):
|
|
149
151
|
try:
|
|
150
|
-
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)) as resp:
|
|
152
|
+
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT)) as resp:
|
|
151
153
|
if resp.status != 200:
|
|
152
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
153
|
-
await asyncio.sleep(
|
|
154
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
155
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
154
156
|
continue
|
|
155
157
|
return EngineResult(
|
|
156
158
|
name=name,
|
|
@@ -177,13 +179,13 @@ async def _fetch_engine(
|
|
|
177
179
|
return EngineResult(name=name, links=links)
|
|
178
180
|
|
|
179
181
|
except asyncio.TimeoutError:
|
|
180
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
181
|
-
await asyncio.sleep(
|
|
182
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
183
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
182
184
|
continue
|
|
183
185
|
return EngineResult(name=name, links=[], error="timeout")
|
|
184
186
|
except Exception as e:
|
|
185
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
186
|
-
await asyncio.sleep(
|
|
187
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
188
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
187
189
|
continue
|
|
188
190
|
return EngineResult(name=name, links=[], error=str(e))
|
|
189
191
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared search timeout, retry, and backoff configuration."""
|
|
2
|
+
|
|
3
|
+
# Keep these values in one module so the CLI and API search paths cannot drift.
|
|
4
|
+
# The API path's 30-second timeout is the common baseline.
|
|
5
|
+
ENGINE_TIMEOUT = 30
|
|
6
|
+
SEARCH_TIMEOUT = 30
|
|
7
|
+
ENGINE_RETRY_COUNT = 2
|
|
8
|
+
RETRY_BACKOFF_BASE = 0.5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def retry_backoff(attempt: int) -> float:
|
|
12
|
+
"""Return the delay before retry number *attempt* (zero-based)."""
|
|
13
|
+
return RETRY_BACKOFF_BASE * (attempt + 1)
|
|
@@ -22,10 +22,12 @@ from db.search_engine_stats import (
|
|
|
22
22
|
from search.query_builder import diversify_query
|
|
23
23
|
from search.engine_catalog import SEARCH_ENGINES
|
|
24
24
|
from utils.async_utils import run_async
|
|
25
|
+
from search import config as search_config
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
# Compatibility exports; runtime reads use the shared config module below.
|
|
30
|
+
ENGINE_TIMEOUT = search_config.ENGINE_TIMEOUT
|
|
29
31
|
|
|
30
32
|
ENGINE_WEIGHTS = {
|
|
31
33
|
"darksearch": 1.0,
|
|
@@ -56,8 +58,8 @@ USER_AGENTS = [
|
|
|
56
58
|
_ONION_URL_RE = re.compile(r'https?://[a-z0-9._-]+\.onion(?:/[^\s"\'<>]*)?', re.IGNORECASE)
|
|
57
59
|
|
|
58
60
|
MAX_CONCURRENT = 10
|
|
59
|
-
SEARCH_TIMEOUT =
|
|
60
|
-
ENGINE_RETRY_COUNT =
|
|
61
|
+
SEARCH_TIMEOUT = search_config.SEARCH_TIMEOUT
|
|
62
|
+
ENGINE_RETRY_COUNT = search_config.ENGINE_RETRY_COUNT
|
|
61
63
|
|
|
62
64
|
_ENGINE_STATUS: dict[str, dict] = {}
|
|
63
65
|
_LAST_SEARCH_SUMMARY: dict[str, int] = {}
|
|
@@ -99,7 +101,7 @@ async def fetch_with_timeout(
|
|
|
99
101
|
session: aiohttp.ClientSession,
|
|
100
102
|
) -> aiohttp.ClientResponse:
|
|
101
103
|
"""Fetch a URL with timeout using the provided session."""
|
|
102
|
-
return await session.get(url, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT))
|
|
104
|
+
return await session.get(url, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT))
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
async def _fetch_engine(
|
|
@@ -115,12 +117,12 @@ async def _fetch_engine(
|
|
|
115
117
|
headers = {"User-Agent": random.choice(USER_AGENTS)}
|
|
116
118
|
|
|
117
119
|
async with semaphore:
|
|
118
|
-
for attempt in range(ENGINE_RETRY_COUNT + 1):
|
|
120
|
+
for attempt in range(search_config.ENGINE_RETRY_COUNT + 1):
|
|
119
121
|
try:
|
|
120
|
-
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)) as resp:
|
|
122
|
+
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT)) as resp:
|
|
121
123
|
if resp.status != 200:
|
|
122
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
123
|
-
await asyncio.sleep(
|
|
124
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
125
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
124
126
|
continue
|
|
125
127
|
return EngineResult(
|
|
126
128
|
name=name,
|
|
@@ -147,13 +149,13 @@ async def _fetch_engine(
|
|
|
147
149
|
return EngineResult(name=name, links=links)
|
|
148
150
|
|
|
149
151
|
except asyncio.TimeoutError:
|
|
150
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
151
|
-
await asyncio.sleep(
|
|
152
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
153
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
152
154
|
continue
|
|
153
155
|
return EngineResult(name=name, links=[], error="timeout")
|
|
154
156
|
except Exception as e:
|
|
155
|
-
if attempt < ENGINE_RETRY_COUNT:
|
|
156
|
-
await asyncio.sleep(
|
|
157
|
+
if attempt < search_config.ENGINE_RETRY_COUNT:
|
|
158
|
+
await asyncio.sleep(search_config.retry_backoff(attempt))
|
|
157
159
|
continue
|
|
158
160
|
return EngineResult(name=name, links=[], error=str(e))
|
|
159
161
|
|
|
@@ -269,7 +271,7 @@ async def _search_async(
|
|
|
269
271
|
connector = _tor_aiohttp_connector()
|
|
270
272
|
async with aiohttp.ClientSession(
|
|
271
273
|
connector=connector,
|
|
272
|
-
timeout=aiohttp.ClientTimeout(total=SEARCH_TIMEOUT),
|
|
274
|
+
timeout=aiohttp.ClientTimeout(total=search_config.SEARCH_TIMEOUT),
|
|
273
275
|
) as session:
|
|
274
276
|
|
|
275
277
|
async def run_engine(engine: dict) -> EngineResult:
|