voidaccess 1.9.2__tar.gz → 1.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voidaccess-1.9.2 → voidaccess-1.9.4}/PKG-INFO +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/main.py +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/entities.py +6 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/investigations.py +3 -2
- {voidaccess-1.9.2 → 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.2 → voidaccess-1.9.4}/db/models.py +5 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/queries.py +26 -6
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/ioc_package.py +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/stix.py +110 -5
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/entity_shape.py +1 -8
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/gazetteer.py +58 -8
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/ner.py +20 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/normalizer.py +16 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/builder.py +16 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/pyproject.toml +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/search/__init__.py +15 -13
- voidaccess-1.9.4/search/config.py +13 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/search/search.py +15 -13
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/data/onion_seeds.json +11 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/email_reputation.py +4 -2
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_api.py +62 -48
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_confidence_calibration.py +10 -8
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_db.py +1 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_llm.py +10 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_llm_utils.py +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_regex_patterns.py +21 -21
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_snort_export.py +28 -28
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_stix_export.py +20 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_yara_export.py +17 -17
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/ioc_freshness.py +13 -4
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/llm.py +15 -10
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/PKG-INFO +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/SOURCES.txt +2 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/__init__.py +1 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/adapters/sqlite.py +3 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/browser.py +2 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/export.py +3 -1
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/investigate.py +33 -4
- {voidaccess-1.9.2 → voidaccess-1.9.4}/LICENSE +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/README.md +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/opsec.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/patterns.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/analysis/temporal.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/auth.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/errors.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/actors.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/admin.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/auth.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/export.py +2 -2
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/monitors.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/search.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/api/routes/settings.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/auth/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/auth/token_blacklist.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/dedup.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/frontier.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/spider.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/crawler/utils.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0001_initial_schema.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0002_add_investigation_status_column.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0002_add_missing_tables.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0003_add_canonical_value_and_entity_links.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0004_add_page_posted_at.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0005_add_extraction_method.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0006_add_monitor_alerts.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0007_add_actor_style_profiles.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0008_add_users_table.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0009_add_investigation_id_to_relationships.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0010_add_composite_index_entity_relationships.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0011_add_page_extraction_cache.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0013_add_graph_status.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0015_add_progress_fields.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0016_backfill_graph_status.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0017_add_user_api_keys.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0018_add_user_id_to_investigations.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0019_add_content_safety_log.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0020_add_entity_source_tracking.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0021_add_search_engine_stats.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0022_add_actor_profiles.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0023_add_investigation_metadata.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0024_add_investigation_step_metrics.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/migrations/versions/0025_add_cancellation_requested.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/search_engine_stats.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/db/session.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/_entity_loading.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/misp.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/relationships.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/sigma.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/snort_export.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/export/yara_export.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/confidence.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/identity.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/llm_extract.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/pipeline.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/regex_patterns.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/extractor/relationship_extract.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/calibration.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/profiler.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/fingerprint/stylometry.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/export.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/model.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/queries.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/graph/visualize.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/detect.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/query_expand.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/i18n/translate.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/_db.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/alerts.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/diff.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/jobs.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/monitor/scheduler.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/scrape.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/scraper/scrape_js.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/search/circuit_breaker.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/search/engine_catalog.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/search/query_builder.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/setup.cfg +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/actor_profiles.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/blockchain.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/breach_lookup.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/cache.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/cisa.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/data/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/dns_enrichment.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/domain_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/engines.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/enrichment.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/github_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/gitlab_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/hash_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/historical_intel.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/infostealer.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/ip_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/nvd.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/paste_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/pastes.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/proxy_client.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/rss_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/seed_manager.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/seeds.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/shodan.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/telegram.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/sources/virustotal.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_opsec.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_stylometry.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_analysis_temporal.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_api_monitors.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_blockchain.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_breach_lookup.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_cli_proxy_config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_consolidated_contracts.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_crawler.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_dns_enrichment.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_domain_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_email_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_extractor_llm_pipeline.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_filter_e2e_manual.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_filter_parser_manual.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_fingerprint.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_github_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_gitlab_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_graph.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_graph_integration.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_hash_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_i18n.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_infostealer.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_ip_reputation.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_model_singleton.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_monitor.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_monitor_integration.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_nvd.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_pagination.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_paste_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_proxy_client.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_ransomlook.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_relationship_extract.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_rss_scraper.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_scrape_js.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_settings.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_sources.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_sources_enrichment_new.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/tests/test_vector.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/async_utils.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/content_dedup.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/content_safety.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/defang.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/encryption.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/enrichment_cache.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/investigation_metrics.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/utils/user_keys.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/embedder.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/model_singleton.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/search.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/vector/store.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess/llm_utils.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/dependency_links.txt +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/entry_points.txt +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/requires.txt +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess.egg-info/top_level.txt +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/adapters/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/__init__.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/actors.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/configure.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/enrich.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/commands/show.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/config.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/display.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/main.py +0 -0
- {voidaccess-1.9.2 → voidaccess-1.9.4}/voidaccess_cli/tor_detect.py +0 -0
|
@@ -40,7 +40,7 @@ logging.basicConfig(level=logging.DEBUG)
|
|
|
40
40
|
# Rate limiter setup
|
|
41
41
|
# ---------------------------------------------------------------------------
|
|
42
42
|
|
|
43
|
-
DISABLE_RATE_LIMIT = os.getenv("DISABLE_RATE_LIMIT", "false").lower() == "true"
|
|
43
|
+
DISABLE_RATE_LIMIT = (os.getenv("DISABLE_RATE_LIMIT", "false") or "false").lower() == "true"
|
|
44
44
|
|
|
45
45
|
if DISABLE_RATE_LIMIT:
|
|
46
46
|
limiter = None
|
|
@@ -96,6 +96,7 @@ async def list_entities(
|
|
|
96
96
|
"context_snippet": e.context_snippet,
|
|
97
97
|
"context": e.context,
|
|
98
98
|
"investigation_id": str(e.investigation_id) if e.investigation_id else None,
|
|
99
|
+
"investigation_count": getattr(e, "investigation_count", 1) or 1,
|
|
99
100
|
"created_at": e.created_at.isoformat() if e.created_at else None,
|
|
100
101
|
}
|
|
101
102
|
for e in entities
|
|
@@ -643,8 +644,9 @@ async def get_entity(
|
|
|
643
644
|
"freshness_label": freshness_display["label"],
|
|
644
645
|
"freshness_color": freshness_display["color"],
|
|
645
646
|
"source_count": entity.source_count or 1,
|
|
647
|
+
"investigation_count": getattr(entity, "investigation_count", 1) or 1,
|
|
646
648
|
"corroborating_sources": json.loads(entity.corroborating_sources or '["dark_web_scrape"]'),
|
|
647
|
-
"cross_referenced": (entity
|
|
649
|
+
"cross_referenced": (getattr(entity, "investigation_count", 1) or 1) > 1,
|
|
648
650
|
"defanged": defang,
|
|
649
651
|
"blockchain_data": {
|
|
650
652
|
"wallet_type": entity.entity_type if entity.entity_type in ("BITCOIN_ADDRESS", "ETHEREUM_ADDRESS", "MONERO_ADDRESS") else None,
|
|
@@ -811,6 +813,9 @@ def _entity_to_dict(entity) -> dict: # type: ignore[type-arg]
|
|
|
811
813
|
"investigation_id": str(entity.investigation_id) if entity.investigation_id else None,
|
|
812
814
|
"created_at": entity.created_at.isoformat() if entity.created_at else None,
|
|
813
815
|
"extraction_method": getattr(entity, "extraction_method", None),
|
|
816
|
+
"source_count": getattr(entity, "source_count", 1) or 1,
|
|
817
|
+
"investigation_count": getattr(entity, "investigation_count", 1) or 1,
|
|
818
|
+
"corroborating_sources": getattr(entity, "corroborating_sources", None),
|
|
814
819
|
}
|
|
815
820
|
|
|
816
821
|
|
|
@@ -537,7 +537,7 @@ async def _check_cancelled(inv_uuid: uuid.UUID, investigation_id: str) -> bool:
|
|
|
537
537
|
# Rate limiting (shared key_func with api/main.py; enforcement via app.state.limiter)
|
|
538
538
|
# ---------------------------------------------------------------------------
|
|
539
539
|
|
|
540
|
-
_DISABLE_RATE_LIMIT = os.getenv("DISABLE_RATE_LIMIT", "false").lower() == "true"
|
|
540
|
+
_DISABLE_RATE_LIMIT = (os.getenv("DISABLE_RATE_LIMIT", "false") or "false").lower() == "true"
|
|
541
541
|
|
|
542
542
|
if not _DISABLE_RATE_LIMIT:
|
|
543
543
|
try:
|
|
@@ -2925,8 +2925,9 @@ async def get_investigation_entities(
|
|
|
2925
2925
|
"freshness_label": freshness_display["label"],
|
|
2926
2926
|
"freshness_color": freshness_display["color"],
|
|
2927
2927
|
"source_count": e.source_count or 1,
|
|
2928
|
+
"investigation_count": getattr(e, "investigation_count", 1) or 1,
|
|
2928
2929
|
"corroborating_sources": json.loads(e.corroborating_sources or '["dark_web_scrape"]'),
|
|
2929
|
-
"cross_referenced": (e
|
|
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.
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import json
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
|
+
import re
|
|
21
22
|
import warnings
|
|
22
23
|
from typing import Any, Optional
|
|
23
24
|
import uuid
|
|
@@ -78,6 +79,114 @@ _STIX_PATTERNS: dict[str, str] = {
|
|
|
78
79
|
# Entity types that map to STIX Malware objects
|
|
79
80
|
_MALWARE_TYPES = frozenset({"MALWARE_FAMILY", "RANSOMWARE_GROUP"})
|
|
80
81
|
|
|
82
|
+
# STIX 2.1's malware-type vocabulary is intentionally small. Keep the
|
|
83
|
+
# mapping here instead of treating every MALWARE_FAMILY as the generic
|
|
84
|
+
# ``malware`` type: family names and enrichment metadata often carry enough
|
|
85
|
+
# information for SIEM consumers to filter useful categories.
|
|
86
|
+
_STIX_MALWARE_TYPE_ALIASES = {
|
|
87
|
+
"infostealer": "spyware",
|
|
88
|
+
"stealer": "spyware",
|
|
89
|
+
"remote_access_trojan": "remote-access-trojan",
|
|
90
|
+
"remote access trojan": "remote-access-trojan",
|
|
91
|
+
"rat": "remote-access-trojan",
|
|
92
|
+
"coinminer": "resource-exploitation",
|
|
93
|
+
"cryptominer": "resource-exploitation",
|
|
94
|
+
"crypto-miner": "resource-exploitation",
|
|
95
|
+
"miner": "resource-exploitation",
|
|
96
|
+
}
|
|
97
|
+
_STIX_MALWARE_TYPES = frozenset({
|
|
98
|
+
"adware",
|
|
99
|
+
"backdoor",
|
|
100
|
+
"bot",
|
|
101
|
+
"ddos",
|
|
102
|
+
"dropper",
|
|
103
|
+
"exploit-kit",
|
|
104
|
+
"keylogger",
|
|
105
|
+
"ransomware",
|
|
106
|
+
"remote-access-trojan",
|
|
107
|
+
"resource-exploitation",
|
|
108
|
+
"rootkit",
|
|
109
|
+
"screen-capture",
|
|
110
|
+
"spyware",
|
|
111
|
+
"wiper",
|
|
112
|
+
"malware",
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# These are conservative, high-signal family/name hints. Unknown families
|
|
116
|
+
# retain the valid generic STIX type rather than being assigned a speculative
|
|
117
|
+
# category.
|
|
118
|
+
_MALWARE_NAME_PATTERNS = (
|
|
119
|
+
("ransomware", re.compile(
|
|
120
|
+
r"\b(?:ransomware|lockbit|blackcat|alphv|clop|conti|ryuk|hive|revil|darkside|wannacry)\b",
|
|
121
|
+
re.IGNORECASE,
|
|
122
|
+
)),
|
|
123
|
+
("remote-access-trojan", re.compile(
|
|
124
|
+
r"\b(?:remote\s+access\s+trojan|remcos|njrat|nanocore|quasar|darkcomet|xrat)\b",
|
|
125
|
+
re.IGNORECASE,
|
|
126
|
+
)),
|
|
127
|
+
("spyware", re.compile(
|
|
128
|
+
r"\b(?:spyware|infostealer|stealer|redline|vidar|lumma|raccoon|agent\s+tesla|formbook)\b",
|
|
129
|
+
re.IGNORECASE,
|
|
130
|
+
)),
|
|
131
|
+
("bot", re.compile(
|
|
132
|
+
r"\b(?:botnet|emotet|qakbot|qbot|trickbot|mirai|zeus|mozi)\b",
|
|
133
|
+
re.IGNORECASE,
|
|
134
|
+
)),
|
|
135
|
+
("keylogger", re.compile(r"\bkeylogger\b", re.IGNORECASE)),
|
|
136
|
+
("exploit-kit", re.compile(r"\bexploit[\s-]+kit\b", re.IGNORECASE)),
|
|
137
|
+
("rootkit", re.compile(r"\brootkit\b", re.IGNORECASE)),
|
|
138
|
+
("screen-capture", re.compile(r"\bscreen[\s-]+capture\b", re.IGNORECASE)),
|
|
139
|
+
("dropper", re.compile(r"\bdropper\b", re.IGNORECASE)),
|
|
140
|
+
("wiper", re.compile(r"\bwiper\b", re.IGNORECASE)),
|
|
141
|
+
("adware", re.compile(r"\badware\b", re.IGNORECASE)),
|
|
142
|
+
("resource-exploitation", re.compile(
|
|
143
|
+
r"\b(?:cryptominer|crypto[\s-]+miner|coinminer|cryptojacking)\b",
|
|
144
|
+
re.IGNORECASE,
|
|
145
|
+
)),
|
|
146
|
+
("backdoor", re.compile(r"\bbackdoor\b", re.IGNORECASE)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _entity_attribute(entity: Any, name: str) -> Any:
|
|
151
|
+
"""Read optional classification metadata from dicts and ORM-like rows."""
|
|
152
|
+
if isinstance(entity, dict):
|
|
153
|
+
return entity.get(name)
|
|
154
|
+
return getattr(entity, name, None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _normalise_malware_type(value: Any) -> Optional[str]:
|
|
158
|
+
if not isinstance(value, str):
|
|
159
|
+
return None
|
|
160
|
+
key = value.strip().lower().replace("_", " ")
|
|
161
|
+
key = _STIX_MALWARE_TYPE_ALIASES.get(key, key)
|
|
162
|
+
key = key.replace(" ", "-")
|
|
163
|
+
return key if key in _STIX_MALWARE_TYPES else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _classify_malware(entity: Any) -> list[str]:
|
|
167
|
+
"""Return evidence-backed STIX malware types for an entity."""
|
|
168
|
+
entity_type = str(getattr(entity, "entity_type", "") or "").upper()
|
|
169
|
+
if isinstance(entity, dict):
|
|
170
|
+
entity_type = str(entity.get("entity_type") or "").upper()
|
|
171
|
+
if entity_type == "RANSOMWARE_GROUP":
|
|
172
|
+
return ["ransomware"]
|
|
173
|
+
|
|
174
|
+
for field_name in ("malware_types", "malware_type", "malware_category", "category", "family_type"):
|
|
175
|
+
raw = _entity_attribute(entity, field_name)
|
|
176
|
+
values = raw if isinstance(raw, (list, tuple, set)) else [raw]
|
|
177
|
+
classified = [item for item in (_normalise_malware_type(v) for v in values) if item]
|
|
178
|
+
if classified:
|
|
179
|
+
return list(dict.fromkeys(classified))
|
|
180
|
+
|
|
181
|
+
evidence = " ".join(
|
|
182
|
+
str(_entity_attribute(entity, field) or "")
|
|
183
|
+
for field in ("value", "context_snippet")
|
|
184
|
+
)
|
|
185
|
+
for malware_type, pattern in _MALWARE_NAME_PATTERNS:
|
|
186
|
+
if pattern.search(evidence):
|
|
187
|
+
return [malware_type]
|
|
188
|
+
return ["malware"]
|
|
189
|
+
|
|
81
190
|
# ---------------------------------------------------------------------------
|
|
82
191
|
# Confidence mapping: VoidAccess float → STIX integer (0-100)
|
|
83
192
|
# ---------------------------------------------------------------------------
|
|
@@ -160,11 +269,7 @@ def entity_to_stix_malware(entity: Any) -> Optional[Any]:
|
|
|
160
269
|
malware = stix2.Malware(
|
|
161
270
|
name=entity.value,
|
|
162
271
|
is_family=True,
|
|
163
|
-
malware_types=(
|
|
164
|
-
["ransomware"]
|
|
165
|
-
if entity.entity_type == "RANSOMWARE_GROUP"
|
|
166
|
-
else ["malware"]
|
|
167
|
-
),
|
|
272
|
+
malware_types=_classify_malware(entity),
|
|
168
273
|
confidence=_to_stix_confidence(get_entity_confidence(entity)),
|
|
169
274
|
allow_custom=True,
|
|
170
275
|
x_voidaccess_source_quality=getattr(entity, "source_quality", 1.0),
|
|
@@ -93,14 +93,7 @@ def _load_words() -> None:
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
# Organisation suffixes that strongly signal a real org name.
|
|
96
|
-
_ORG_SUFFIXES
|
|
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",
|