howler-api 4.0.0.dev1099__tar.gz → 4.0.0.dev1110__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.
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/PKG-INFO +1 -1
- howler_api-4.0.0.dev1110/howler/api/v2/fuzzy.py +110 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/app.py +2 -0
- howler_api-4.0.0.dev1110/howler/services/fuzzy_service.py +392 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/str_utils.py +1 -1
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/pyproject.toml +1 -1
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/add_label.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/add_to_bundle.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/add_to_case.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/change_field.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/demote.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/example_plugin.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/prioritization.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/promote.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/remove_from_bundle.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/remove_label.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/actions/transition.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/base.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/socket.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/action.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/analytic.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/auth.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/clue.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/configs.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/dossier.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/help.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/hit.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/notebook.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/overview.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/search.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/template.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/tool.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/user.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/utils/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/utils/etag.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/utils/params.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v1/view.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v2/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v2/case.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v2/ingest.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/api/v2/search.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/classification.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/classification.yml +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/exceptions.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/loader.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/logging/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/logging/audit.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/logging/format.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/net.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/net_static.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/random_user.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/common/swagger.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/config.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/cronjobs/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/cronjobs/action_queue_worker.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/cronjobs/correlation.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/cronjobs/retention.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/cronjobs/view_cleanup.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/bulk.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/collection.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/constants.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/exceptions.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/howler_store.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/migrations/fix_process.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/operations.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/schemas.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/store.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/support/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/support/build.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/support/schemas.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/datastore/types.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/error.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/generate_mitre.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/generate_sigma_rules.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/generate_tlds.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/reindex_data.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/external/wipe_databases.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/gunicorn_config.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/healthz.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/azure.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/discover.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/hit.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/oauth.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/search.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/workflow.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/helper/ws.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/base.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/charter.txt +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/constants.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/helper.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/howler_enum.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/mixins.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/action.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/analytic.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/assemblyline.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/aws.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/azure.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/case.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/cbs.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/clue.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/config.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/dossier.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/agent.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/autonomous_system.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/client.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/cloud.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/code_signature.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/container.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/dns.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/egress.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/elf.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/email.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/error.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/event.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/faas.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/file.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/geo.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/group.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/hash.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/host.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/http.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/ingress.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/interface.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/network.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/observer.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/organization.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/os.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/pe.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/process.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/registry.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/related.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/rule.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/server.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/threat.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/tls.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/url.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/user.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/user_agent.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/ecs/vulnerability.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/event.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/gcp.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/hit.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/howler_data.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/lead.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/localized_label.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/overview.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/pivot.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/record.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/template.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/user.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/models/view.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/random_data.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/odm/randomizer.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/patched.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/plugins/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/plugins/config.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/README.md +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/counters.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/events.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/hash.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/lock.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/queues/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/queues/comms.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/queues/multi.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/queues/named.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/queues/priority.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/set.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/remote/datatypes/user_quota_tracker.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/security/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/security/socket.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/security/utils.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/action_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/analytic_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/auth_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/bundle_compat_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/case_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/comms_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/config_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/correlation_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/docs_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/dossier_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/event_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/hit_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/jwt_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/lucene_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/notebook_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/overview_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/search_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/template_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/user_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/services/viewer_service.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/telemetry.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/__init__.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/annotations.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/chunk.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/compat.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/constants.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/dict_utils.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/isotime.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/list_utils.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/lucene.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/path.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/socket_utils.py +0 -0
- {howler_api-4.0.0.dev1099 → howler_api-4.0.0.dev1110}/howler/utils/uid.py +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from flask import request
|
|
2
|
+
|
|
3
|
+
from howler.api import bad_request, make_subapi_blueprint, ok
|
|
4
|
+
from howler.common.logging import get_logger
|
|
5
|
+
from howler.common.swagger import generate_swagger_docs
|
|
6
|
+
from howler.datastore.exceptions import SearchException
|
|
7
|
+
from howler.helper.search import has_access_control
|
|
8
|
+
from howler.security import api_login
|
|
9
|
+
from howler.services import fuzzy_service
|
|
10
|
+
|
|
11
|
+
SUB_API = "fuzzy"
|
|
12
|
+
fuzzy_api = make_subapi_blueprint(SUB_API, api_version=2)
|
|
13
|
+
fuzzy_api._doc = "Perform fuzzy plain-text searches across indexes" # type: ignore
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__file__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@generate_swagger_docs()
|
|
19
|
+
@fuzzy_api.route("/search", methods=["POST"])
|
|
20
|
+
@api_login(required_priv=["R"])
|
|
21
|
+
def fuzzy_search(**kwargs): # noqa: C901
|
|
22
|
+
"""Perform a plain-text fuzzy search across hits, events, and cases.
|
|
23
|
+
|
|
24
|
+
Accepts a plain string (word, IP, domain, URL, username, email, hash) and
|
|
25
|
+
returns ranked results across specified indexes.
|
|
26
|
+
|
|
27
|
+
Variables:
|
|
28
|
+
None
|
|
29
|
+
|
|
30
|
+
Arguments:
|
|
31
|
+
None
|
|
32
|
+
|
|
33
|
+
Data Block:
|
|
34
|
+
{
|
|
35
|
+
"query": "192.168.1.1", # Plain text search query (required)
|
|
36
|
+
"indexes": ["hit", "event", "case"], # Indexes to search (optional, defaults to all)
|
|
37
|
+
"filters": ["howler.status:open"], # Additional filters (optional)
|
|
38
|
+
"offset": 0, # Offset into results (optional, default 0)
|
|
39
|
+
"rows": 100, # Number of results (optional, default 100)
|
|
40
|
+
"track_total_hits": false # Track total hits (optional, default false)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Result Example:
|
|
44
|
+
{
|
|
45
|
+
"total": 42,
|
|
46
|
+
"offset": 0,
|
|
47
|
+
"rows": 42,
|
|
48
|
+
"items": [
|
|
49
|
+
{
|
|
50
|
+
"__index": "hit",
|
|
51
|
+
"_score": 12.5,
|
|
52
|
+
"howler": {"id": "abc123", ...},
|
|
53
|
+
...
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
user = kwargs["user"]
|
|
59
|
+
|
|
60
|
+
req_data = request.get_json(silent=True)
|
|
61
|
+
if not req_data:
|
|
62
|
+
return bad_request(err="Request body is required.")
|
|
63
|
+
|
|
64
|
+
query = req_data.get("query", "").strip()
|
|
65
|
+
if not query:
|
|
66
|
+
return bad_request(err="Search query is required and cannot be empty.")
|
|
67
|
+
|
|
68
|
+
# Parse indexes
|
|
69
|
+
indexes = req_data.get("indexes", ["hit", "event", "case"])
|
|
70
|
+
if isinstance(indexes, str):
|
|
71
|
+
indexes = [idx.strip() for idx in indexes.split(",") if idx.strip()]
|
|
72
|
+
|
|
73
|
+
# Validate indexes
|
|
74
|
+
for idx in indexes:
|
|
75
|
+
if idx not in fuzzy_service.VALID_INDEXES:
|
|
76
|
+
return bad_request(err=f"Invalid index: {idx}. Must be one of: hit, event, case")
|
|
77
|
+
|
|
78
|
+
if not indexes:
|
|
79
|
+
return bad_request(err="At least one index must be specified.")
|
|
80
|
+
|
|
81
|
+
filters = req_data.get("filters", None)
|
|
82
|
+
if isinstance(filters, str):
|
|
83
|
+
filters = [filters]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
offset = int(req_data.get("offset", 0))
|
|
87
|
+
rows = int(req_data.get("rows", 100))
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return bad_request(err="'offset' and 'rows' must be integers.")
|
|
90
|
+
track_total_hits = bool(req_data.get("track_total_hits", False))
|
|
91
|
+
|
|
92
|
+
# Apply access control if indexes are access controlled
|
|
93
|
+
access_control = None
|
|
94
|
+
if has_access_control(indexes):
|
|
95
|
+
access_control = user["access_control"]
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = fuzzy_service.fuzzy_search(
|
|
99
|
+
indexes=indexes,
|
|
100
|
+
query=query,
|
|
101
|
+
filters=filters,
|
|
102
|
+
offset=offset,
|
|
103
|
+
rows=rows,
|
|
104
|
+
track_total_hits=track_total_hits,
|
|
105
|
+
access_control=access_control,
|
|
106
|
+
)
|
|
107
|
+
except SearchException as e:
|
|
108
|
+
return bad_request(err=str(e))
|
|
109
|
+
|
|
110
|
+
return ok(result)
|
|
@@ -49,6 +49,7 @@ from howler.api.v1.user import user_api
|
|
|
49
49
|
from howler.api.v1.view import view_api
|
|
50
50
|
from howler.api.v2 import apiv2
|
|
51
51
|
from howler.api.v2.case import case_api
|
|
52
|
+
from howler.api.v2.fuzzy import fuzzy_api
|
|
52
53
|
from howler.api.v2.ingest import ingest_api
|
|
53
54
|
from howler.api.v2.search import search_api as v2_search_api
|
|
54
55
|
from howler.common.logging import get_logger
|
|
@@ -143,6 +144,7 @@ if HWL_USE_REST_API or DEBUG:
|
|
|
143
144
|
# v2
|
|
144
145
|
app.register_blueprint(apiv2)
|
|
145
146
|
app.register_blueprint(case_api)
|
|
147
|
+
app.register_blueprint(fuzzy_api)
|
|
146
148
|
app.register_blueprint(ingest_api)
|
|
147
149
|
app.register_blueprint(v2_search_api)
|
|
148
150
|
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import elasticsearch
|
|
8
|
+
from elasticsearch import Elasticsearch
|
|
9
|
+
|
|
10
|
+
from howler import odm
|
|
11
|
+
from howler.common.loader import APP_NAME, datastore
|
|
12
|
+
from howler.datastore.exceptions import SearchException, SearchRetryException
|
|
13
|
+
from howler.datastore.types import SearchResult
|
|
14
|
+
from howler.odm.base import (
|
|
15
|
+
DOMAIN_ONLY_REGEX,
|
|
16
|
+
EMAIL_REGEX,
|
|
17
|
+
IP,
|
|
18
|
+
IP_ONLY_REGEX,
|
|
19
|
+
MD5_REGEX,
|
|
20
|
+
SHA1_REGEX,
|
|
21
|
+
SHA256_REGEX,
|
|
22
|
+
List,
|
|
23
|
+
Optional,
|
|
24
|
+
Text,
|
|
25
|
+
_Field,
|
|
26
|
+
)
|
|
27
|
+
from howler.services.search_service import _normalize_indexes
|
|
28
|
+
from howler.utils.str_utils import sanitize_lucene_query
|
|
29
|
+
|
|
30
|
+
DEFAULT_OFFSET = 0
|
|
31
|
+
DEFAULT_ROW_SIZE = 100
|
|
32
|
+
VALID_INDEXES = {"hit", "event", "case"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _escape_query_string(query: str) -> str:
|
|
36
|
+
"""Escape special Lucene characters from a raw query string."""
|
|
37
|
+
return sanitize_lucene_query(query)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Compiled regexes from ODM base for token type detection
|
|
41
|
+
_IP_RE = re.compile(IP_ONLY_REGEX)
|
|
42
|
+
_MD5_RE = re.compile(MD5_REGEX, re.IGNORECASE)
|
|
43
|
+
_SHA1_RE = re.compile(SHA1_REGEX, re.IGNORECASE)
|
|
44
|
+
_SHA256_RE = re.compile(SHA256_REGEX, re.IGNORECASE)
|
|
45
|
+
_EMAIL_RE = re.compile(EMAIL_REGEX)
|
|
46
|
+
_DOMAIN_RE = re.compile(DOMAIN_ONLY_REGEX)
|
|
47
|
+
# FULL_URI is too permissive for token detection (matches bare domains).
|
|
48
|
+
# Use a scheme-prefix check to identify URLs specifically.
|
|
49
|
+
_URL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
# Field boosts by index
|
|
52
|
+
FIELD_BOOSTS: dict[str, dict[str, int]] = {
|
|
53
|
+
"hit": {
|
|
54
|
+
"howler.id": 5,
|
|
55
|
+
"howler.outline.threat": 4,
|
|
56
|
+
"howler.outline.target": 3,
|
|
57
|
+
"related.ip": 4,
|
|
58
|
+
"related.user": 3,
|
|
59
|
+
"related.uri": 3,
|
|
60
|
+
"related.hosts": 3,
|
|
61
|
+
"related.hash": 3,
|
|
62
|
+
"related.signature": 2,
|
|
63
|
+
"message": 1,
|
|
64
|
+
},
|
|
65
|
+
"event": {
|
|
66
|
+
"howler.id": 5,
|
|
67
|
+
"source.ip": 4,
|
|
68
|
+
"destination.ip": 4,
|
|
69
|
+
"dns.domain": 3,
|
|
70
|
+
"url.domain": 3,
|
|
71
|
+
"http.host": 3,
|
|
72
|
+
"related.ip": 2,
|
|
73
|
+
"related.user": 2,
|
|
74
|
+
"related.uri": 2,
|
|
75
|
+
"related.hosts": 2,
|
|
76
|
+
"related.hash": 2,
|
|
77
|
+
"message": 1,
|
|
78
|
+
},
|
|
79
|
+
"case": {
|
|
80
|
+
"case_id": 5,
|
|
81
|
+
"indicators": 4,
|
|
82
|
+
"targets": 4,
|
|
83
|
+
"items.value": 3,
|
|
84
|
+
"items.path": 2,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _detect_token_type(token: str) -> str:
|
|
90
|
+
"""Detect the type of a search token using ODM-defined patterns.
|
|
91
|
+
|
|
92
|
+
Returns one of: 'ip', 'md5', 'sha1', 'sha256', 'email', 'url', 'domain', 'text'
|
|
93
|
+
"""
|
|
94
|
+
if _IP_RE.match(token):
|
|
95
|
+
return "ip"
|
|
96
|
+
if _MD5_RE.match(token):
|
|
97
|
+
return "md5"
|
|
98
|
+
if _SHA1_RE.match(token):
|
|
99
|
+
return "sha1"
|
|
100
|
+
if _SHA256_RE.match(token):
|
|
101
|
+
return "sha256"
|
|
102
|
+
if _EMAIL_RE.match(token):
|
|
103
|
+
return "email"
|
|
104
|
+
if _URL_RE.match(token):
|
|
105
|
+
return "url"
|
|
106
|
+
if _DOMAIN_RE.match(token):
|
|
107
|
+
return "domain"
|
|
108
|
+
return "text"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_fields_for_index(index: str) -> list[str]:
|
|
112
|
+
"""Get boosted field strings for a given index."""
|
|
113
|
+
boosts = FIELD_BOOSTS.get(index, {})
|
|
114
|
+
return [f"{field}^{boost}" for field, boost in boosts.items()]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_field_type(field: _Field) -> type:
|
|
118
|
+
"""Unwrap Optional/List wrappers to get the leaf field type."""
|
|
119
|
+
if isinstance(field, Optional):
|
|
120
|
+
return _resolve_field_type(field.child_type)
|
|
121
|
+
if isinstance(field, List):
|
|
122
|
+
return _resolve_field_type(field.child_type)
|
|
123
|
+
return type(field)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@lru_cache(maxsize=1)
|
|
127
|
+
def _classify_boosted_fields() -> dict[str, str]:
|
|
128
|
+
"""Classify each field in FIELD_BOOSTS by its Elasticsearch type using ODM model introspection.
|
|
129
|
+
|
|
130
|
+
Inspects the flat_fields() of each model to determine the ES mapping type.
|
|
131
|
+
Fields declared as odm.IP() map to ES 'ip' type.
|
|
132
|
+
Fields declared as odm.Text() map to ES 'text' type.
|
|
133
|
+
All others (Keyword and subtypes) map to ES 'keyword' type.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
A dict mapping field name to one of 'ip', 'text', or 'keyword'.
|
|
137
|
+
"""
|
|
138
|
+
from howler.odm.models.case import Case
|
|
139
|
+
from howler.odm.models.event import Event
|
|
140
|
+
from howler.odm.models.hit import Hit
|
|
141
|
+
|
|
142
|
+
model_map: dict[str, type[odm.Model]] = {
|
|
143
|
+
"hit": Hit,
|
|
144
|
+
"event": Event,
|
|
145
|
+
"case": Case,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
field_types: dict[str, str] = {}
|
|
149
|
+
for index, fields in FIELD_BOOSTS.items():
|
|
150
|
+
model_class = model_map.get(index, None)
|
|
151
|
+
if not model_class:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
flat = model_class.flat_fields()
|
|
155
|
+
for field_name in fields:
|
|
156
|
+
if field_name in field_types:
|
|
157
|
+
continue
|
|
158
|
+
field_def = flat.get(field_name)
|
|
159
|
+
if not field_def:
|
|
160
|
+
field_types[field_name] = "keyword"
|
|
161
|
+
continue
|
|
162
|
+
leaf_type = _resolve_field_type(field_def)
|
|
163
|
+
if issubclass(leaf_type, IP):
|
|
164
|
+
field_types[field_name] = "ip"
|
|
165
|
+
elif issubclass(leaf_type, Text):
|
|
166
|
+
field_types[field_name] = "text"
|
|
167
|
+
else:
|
|
168
|
+
field_types[field_name] = "keyword"
|
|
169
|
+
|
|
170
|
+
return field_types
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_ip_typed_fields() -> set[str]:
|
|
174
|
+
"""Return the set of fields classified as IP type."""
|
|
175
|
+
return {f for f, t in _classify_boosted_fields().items() if t == "ip"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_fuzzy_query( # noqa: C901
|
|
179
|
+
query: str,
|
|
180
|
+
indexes: list[str],
|
|
181
|
+
filters: list[str] | None = None,
|
|
182
|
+
access_control: str | None = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Build an Elasticsearch query body for fuzzy/plain-text search.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
q: The search string (plain text, IP, hash, domain, etc.)
|
|
188
|
+
indexes: List of index names to search across.
|
|
189
|
+
filters: Additional filter queries.
|
|
190
|
+
access_control: Access control filter string.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
An Elasticsearch query body dict.
|
|
194
|
+
"""
|
|
195
|
+
query = query.strip()
|
|
196
|
+
token_type = _detect_token_type(query)
|
|
197
|
+
|
|
198
|
+
# Collect all boosted fields across requested indexes
|
|
199
|
+
all_fields: list[str] = []
|
|
200
|
+
for index in indexes:
|
|
201
|
+
all_fields.extend(_get_fields_for_index(index))
|
|
202
|
+
|
|
203
|
+
# De-duplicate fields (keep highest boost if duplicate base field)
|
|
204
|
+
field_boost_map: dict[str, int] = {}
|
|
205
|
+
for field_str in all_fields:
|
|
206
|
+
if "^" in field_str:
|
|
207
|
+
field, boost_str = field_str.rsplit("^", 1)
|
|
208
|
+
boost = int(boost_str)
|
|
209
|
+
else:
|
|
210
|
+
field = field_str
|
|
211
|
+
boost = 1
|
|
212
|
+
if field not in field_boost_map or field_boost_map[field] < boost:
|
|
213
|
+
field_boost_map[field] = boost
|
|
214
|
+
|
|
215
|
+
# Partition fields by ES type using ODM model introspection.
|
|
216
|
+
# - ip: must use term queries (no fuzzy/phrase support)
|
|
217
|
+
# - text: supports all query types including phrase_prefix
|
|
218
|
+
# - keyword: supports best_fields and phrase, but NOT phrase_prefix
|
|
219
|
+
field_classes = _classify_boosted_fields()
|
|
220
|
+
ip_fields: dict[str, int] = {}
|
|
221
|
+
text_fields: dict[str, int] = {}
|
|
222
|
+
keyword_fields: dict[str, int] = {}
|
|
223
|
+
for f, b in field_boost_map.items():
|
|
224
|
+
es_type = field_classes.get(f, "keyword")
|
|
225
|
+
if es_type == "ip":
|
|
226
|
+
ip_fields[f] = b
|
|
227
|
+
elif es_type == "text":
|
|
228
|
+
text_fields[f] = b
|
|
229
|
+
else:
|
|
230
|
+
keyword_fields[f] = b
|
|
231
|
+
|
|
232
|
+
# Fields safe for multi_match best_fields / phrase (text + keyword)
|
|
233
|
+
searchable_field_list = [f"{f}^{b}" for f, b in {**text_fields, **keyword_fields}.items()]
|
|
234
|
+
# Fields safe for phrase_prefix (text only)
|
|
235
|
+
text_field_list = [f"{f}^{b}" for f, b in text_fields.items()]
|
|
236
|
+
|
|
237
|
+
should_clauses: list[dict[str, Any]] = []
|
|
238
|
+
|
|
239
|
+
# Broad catch-all: query_string across all indexed fields at low boost.
|
|
240
|
+
# This ensures every field in the schema is searched (e.g. agent.type),
|
|
241
|
+
# while the boosted multi_match clauses below rank high-value fields higher.
|
|
242
|
+
escaped_q = sanitize_lucene_query(query)
|
|
243
|
+
|
|
244
|
+
if token_type == "ip": # noqa: S105
|
|
245
|
+
# For IP tokens, use term queries on IP fields and best_fields on boosted text/keyword fields
|
|
246
|
+
for field, boost in ip_fields.items():
|
|
247
|
+
should_clauses.append({"term": {field: {"value": query, "boost": boost}}})
|
|
248
|
+
if searchable_field_list:
|
|
249
|
+
should_clauses.append(
|
|
250
|
+
{"multi_match": {"query": query, "fields": searchable_field_list, "type": "best_fields"}}
|
|
251
|
+
)
|
|
252
|
+
should_clauses.append({"query_string": {"query": escaped_q, "default_field": "*", "boost": 0.5}})
|
|
253
|
+
elif token_type in ("md5", "sha1", "sha256"):
|
|
254
|
+
# Exact match for hashes — boosted fields + catch-all
|
|
255
|
+
if searchable_field_list:
|
|
256
|
+
should_clauses.append(
|
|
257
|
+
{"multi_match": {"query": query, "fields": searchable_field_list, "type": "best_fields"}}
|
|
258
|
+
)
|
|
259
|
+
should_clauses.append({"query_string": {"query": escaped_q, "default_field": "*", "boost": 0.5}})
|
|
260
|
+
elif token_type in ("email", "url", "domain"):
|
|
261
|
+
# Use phrase matching on boosted fields + catch-all
|
|
262
|
+
if searchable_field_list:
|
|
263
|
+
should_clauses.append({"multi_match": {"query": query, "fields": searchable_field_list, "type": "phrase"}})
|
|
264
|
+
should_clauses.append(
|
|
265
|
+
{"multi_match": {"query": query, "fields": searchable_field_list, "type": "best_fields"}}
|
|
266
|
+
)
|
|
267
|
+
should_clauses.append({"query_string": {"query": escaped_q, "default_field": "*", "boost": 0.5}})
|
|
268
|
+
else:
|
|
269
|
+
# General text - boosted fuzzy match + catch-all across all fields
|
|
270
|
+
if searchable_field_list:
|
|
271
|
+
should_clauses.append(
|
|
272
|
+
{
|
|
273
|
+
"multi_match": {
|
|
274
|
+
"query": query,
|
|
275
|
+
"fields": searchable_field_list,
|
|
276
|
+
"type": "best_fields",
|
|
277
|
+
"fuzziness": "AUTO",
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
# phrase_prefix only works on text fields, not keyword
|
|
282
|
+
if len(query) >= 3 and text_field_list:
|
|
283
|
+
should_clauses.append(
|
|
284
|
+
{"multi_match": {"query": query, "fields": text_field_list, "type": "phrase_prefix"}}
|
|
285
|
+
)
|
|
286
|
+
should_clauses.append(
|
|
287
|
+
{"query_string": {"query": f"*{escaped_q}*", "default_field": "*", "boost": 0.5, "analyze_wildcard": True}}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Build filter clauses
|
|
291
|
+
filter_clauses: list[dict[str, Any]] = []
|
|
292
|
+
if filters:
|
|
293
|
+
for f in filters:
|
|
294
|
+
filter_clauses.append({"query_string": {"query": f}})
|
|
295
|
+
if access_control:
|
|
296
|
+
filter_clauses.append({"query_string": {"query": access_control}})
|
|
297
|
+
|
|
298
|
+
query_body: dict[str, Any] = {
|
|
299
|
+
"query": {
|
|
300
|
+
"bool": {
|
|
301
|
+
"should": should_clauses,
|
|
302
|
+
"minimum_should_match": 1,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if filter_clauses:
|
|
308
|
+
query_body["query"]["bool"]["filter"] = filter_clauses
|
|
309
|
+
|
|
310
|
+
return query_body
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fuzzy_search(
|
|
314
|
+
indexes: list[str],
|
|
315
|
+
query: str,
|
|
316
|
+
filters: list[str] | None = None,
|
|
317
|
+
offset: int = DEFAULT_OFFSET,
|
|
318
|
+
rows: int = DEFAULT_ROW_SIZE,
|
|
319
|
+
track_total_hits: bool = False,
|
|
320
|
+
access_control: str | None = None,
|
|
321
|
+
) -> SearchResult[dict[str, Any]]:
|
|
322
|
+
"""Perform a fuzzy/plain-text search across multiple indexes.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
indexes: List of index names (hit, event, case).
|
|
326
|
+
q: Plain text search query.
|
|
327
|
+
filters: Additional filter queries.
|
|
328
|
+
offset: Offset into results.
|
|
329
|
+
rows: Number of results to return.
|
|
330
|
+
track_total_hits: Whether to track total hits beyond 10000.
|
|
331
|
+
access_control: Access control filter string.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
A SearchResult containing matched items with __index and _score.
|
|
335
|
+
"""
|
|
336
|
+
# Validate indexes
|
|
337
|
+
for idx in indexes:
|
|
338
|
+
if idx not in VALID_INDEXES:
|
|
339
|
+
raise SearchException(f"Invalid index for fuzzy search: {idx}")
|
|
340
|
+
|
|
341
|
+
client: Elasticsearch = datastore().ds.client
|
|
342
|
+
parsed_indexes = _normalize_indexes(indexes)
|
|
343
|
+
|
|
344
|
+
query_body = build_fuzzy_query(query, indexes, filters, access_control)
|
|
345
|
+
|
|
346
|
+
params: dict[str, Any] = {}
|
|
347
|
+
if track_total_hits:
|
|
348
|
+
params["track_total_hits"] = True
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
result = client.search(
|
|
352
|
+
index=parsed_indexes,
|
|
353
|
+
sort=[{"_score": "desc"}],
|
|
354
|
+
from_=offset,
|
|
355
|
+
size=rows,
|
|
356
|
+
**params,
|
|
357
|
+
**query_body,
|
|
358
|
+
)
|
|
359
|
+
except (elasticsearch.exceptions.ConnectionError, elasticsearch.exceptions.ConnectionTimeout) as error:
|
|
360
|
+
raise SearchRetryException(f"indexes: {parsed_indexes}, query: {query}, error: {str(error)}") from error
|
|
361
|
+
except (elasticsearch.exceptions.TransportError, elasticsearch.exceptions.RequestError) as error:
|
|
362
|
+
raise SearchException(str(error)) from error
|
|
363
|
+
except Exception as error:
|
|
364
|
+
raise SearchException(f"indexes: {parsed_indexes}, query: {query}, error: {str(error)}") from error
|
|
365
|
+
|
|
366
|
+
total = result.get("hits", {}).get("total", {}).get("value", 0)
|
|
367
|
+
hits = result.get("hits", {}).get("hits", [])
|
|
368
|
+
|
|
369
|
+
items = _format_items_with_score(hits)
|
|
370
|
+
|
|
371
|
+
response: SearchResult[dict[str, Any]] = {
|
|
372
|
+
"offset": int(offset),
|
|
373
|
+
"rows": len(items),
|
|
374
|
+
"total": int(total),
|
|
375
|
+
"items": items,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return response
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _format_items_with_score(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
382
|
+
"""Format Elasticsearch hits including _score in the output."""
|
|
383
|
+
items: list[dict[str, Any]] = []
|
|
384
|
+
for hit in hits:
|
|
385
|
+
source = hit.get("_source")
|
|
386
|
+
if source:
|
|
387
|
+
raw_index = hit.get("_index", None)
|
|
388
|
+
if raw_index:
|
|
389
|
+
source["__index"] = raw_index.replace(f"{APP_NAME}-", "").replace("_hot", "")
|
|
390
|
+
source["_score"] = hit.get("_score", 0)
|
|
391
|
+
items.append(source)
|
|
392
|
+
return items
|
|
@@ -203,7 +203,7 @@ def get_parent_key(key: str) -> str:
|
|
|
203
203
|
|
|
204
204
|
def sanitize_lucene_query(query: str):
|
|
205
205
|
"""Take in a given string, and escape it to ensure it is safe to search on via lucene"""
|
|
206
|
-
query = re.sub(r'([\^"~*?:\\/()[\]{}
|
|
206
|
+
query = re.sub(r'([\^"~*?:\\/()[\]{}\-!+])', r"\\\1", query)
|
|
207
207
|
|
|
208
208
|
return query.replace("&&", "\\&&").replace("||", "\\||")
|
|
209
209
|
|
|
@@ -152,7 +152,7 @@ suppress-none-returning = true
|
|
|
152
152
|
[tool.poetry]
|
|
153
153
|
package-mode = true
|
|
154
154
|
name = "howler-api"
|
|
155
|
-
version = "4.0.0.
|
|
155
|
+
version = "4.0.0.dev1110"
|
|
156
156
|
description = "Howler - API server"
|
|
157
157
|
authors = [
|
|
158
158
|
"Canadian Centre for Cyber Security <howler@cyber.gc.ca>",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|