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