howler-api 3.0.0.dev374__py3-none-any.whl

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.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (198) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +168 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/clue.py +99 -0
  21. howler/api/v1/configs.py +58 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +788 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +416 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +125 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/loader.py +154 -0
  41. howler/common/logging/__init__.py +241 -0
  42. howler/common/logging/audit.py +138 -0
  43. howler/common/logging/format.py +38 -0
  44. howler/common/net.py +79 -0
  45. howler/common/net_static.py +1494 -0
  46. howler/common/random_user.py +316 -0
  47. howler/common/swagger.py +117 -0
  48. howler/config.py +64 -0
  49. howler/cronjobs/__init__.py +29 -0
  50. howler/cronjobs/retention.py +61 -0
  51. howler/cronjobs/rules.py +274 -0
  52. howler/cronjobs/view_cleanup.py +88 -0
  53. howler/datastore/README.md +112 -0
  54. howler/datastore/__init__.py +0 -0
  55. howler/datastore/bulk.py +72 -0
  56. howler/datastore/collection.py +2342 -0
  57. howler/datastore/constants.py +119 -0
  58. howler/datastore/exceptions.py +41 -0
  59. howler/datastore/howler_store.py +105 -0
  60. howler/datastore/migrations/fix_process.py +41 -0
  61. howler/datastore/operations.py +130 -0
  62. howler/datastore/schemas.py +90 -0
  63. howler/datastore/store.py +231 -0
  64. howler/datastore/support/__init__.py +0 -0
  65. howler/datastore/support/build.py +215 -0
  66. howler/datastore/support/schemas.py +90 -0
  67. howler/datastore/types.py +22 -0
  68. howler/error.py +91 -0
  69. howler/external/__init__.py +0 -0
  70. howler/external/generate_mitre.py +96 -0
  71. howler/external/generate_sigma_rules.py +31 -0
  72. howler/external/generate_tlds.py +47 -0
  73. howler/external/reindex_data.py +66 -0
  74. howler/external/wipe_databases.py +58 -0
  75. howler/gunicorn_config.py +25 -0
  76. howler/healthz.py +47 -0
  77. howler/helper/__init__.py +0 -0
  78. howler/helper/azure.py +50 -0
  79. howler/helper/discover.py +59 -0
  80. howler/helper/hit.py +236 -0
  81. howler/helper/oauth.py +247 -0
  82. howler/helper/search.py +92 -0
  83. howler/helper/workflow.py +110 -0
  84. howler/helper/ws.py +378 -0
  85. howler/odm/README.md +102 -0
  86. howler/odm/__init__.py +1 -0
  87. howler/odm/base.py +1543 -0
  88. howler/odm/charter.txt +146 -0
  89. howler/odm/helper.py +416 -0
  90. howler/odm/howler_enum.py +25 -0
  91. howler/odm/models/__init__.py +0 -0
  92. howler/odm/models/action.py +33 -0
  93. howler/odm/models/analytic.py +90 -0
  94. howler/odm/models/assemblyline.py +48 -0
  95. howler/odm/models/aws.py +23 -0
  96. howler/odm/models/azure.py +16 -0
  97. howler/odm/models/cbs.py +44 -0
  98. howler/odm/models/config.py +558 -0
  99. howler/odm/models/dossier.py +33 -0
  100. howler/odm/models/ecs/__init__.py +0 -0
  101. howler/odm/models/ecs/agent.py +17 -0
  102. howler/odm/models/ecs/autonomous_system.py +16 -0
  103. howler/odm/models/ecs/client.py +149 -0
  104. howler/odm/models/ecs/cloud.py +141 -0
  105. howler/odm/models/ecs/code_signature.py +27 -0
  106. howler/odm/models/ecs/container.py +32 -0
  107. howler/odm/models/ecs/dns.py +62 -0
  108. howler/odm/models/ecs/egress.py +10 -0
  109. howler/odm/models/ecs/elf.py +74 -0
  110. howler/odm/models/ecs/email.py +122 -0
  111. howler/odm/models/ecs/error.py +14 -0
  112. howler/odm/models/ecs/event.py +140 -0
  113. howler/odm/models/ecs/faas.py +24 -0
  114. howler/odm/models/ecs/file.py +84 -0
  115. howler/odm/models/ecs/geo.py +30 -0
  116. howler/odm/models/ecs/group.py +18 -0
  117. howler/odm/models/ecs/hash.py +16 -0
  118. howler/odm/models/ecs/host.py +17 -0
  119. howler/odm/models/ecs/http.py +37 -0
  120. howler/odm/models/ecs/ingress.py +12 -0
  121. howler/odm/models/ecs/interface.py +21 -0
  122. howler/odm/models/ecs/network.py +30 -0
  123. howler/odm/models/ecs/observer.py +45 -0
  124. howler/odm/models/ecs/organization.py +12 -0
  125. howler/odm/models/ecs/os.py +21 -0
  126. howler/odm/models/ecs/pe.py +17 -0
  127. howler/odm/models/ecs/process.py +216 -0
  128. howler/odm/models/ecs/registry.py +26 -0
  129. howler/odm/models/ecs/related.py +45 -0
  130. howler/odm/models/ecs/rule.py +51 -0
  131. howler/odm/models/ecs/server.py +24 -0
  132. howler/odm/models/ecs/threat.py +247 -0
  133. howler/odm/models/ecs/tls.py +58 -0
  134. howler/odm/models/ecs/url.py +51 -0
  135. howler/odm/models/ecs/user.py +57 -0
  136. howler/odm/models/ecs/user_agent.py +20 -0
  137. howler/odm/models/ecs/vulnerability.py +41 -0
  138. howler/odm/models/gcp.py +16 -0
  139. howler/odm/models/hit.py +356 -0
  140. howler/odm/models/howler_data.py +328 -0
  141. howler/odm/models/lead.py +24 -0
  142. howler/odm/models/localized_label.py +13 -0
  143. howler/odm/models/overview.py +16 -0
  144. howler/odm/models/pivot.py +40 -0
  145. howler/odm/models/template.py +24 -0
  146. howler/odm/models/user.py +83 -0
  147. howler/odm/models/view.py +34 -0
  148. howler/odm/random_data.py +888 -0
  149. howler/odm/randomizer.py +609 -0
  150. howler/patched.py +5 -0
  151. howler/plugins/__init__.py +25 -0
  152. howler/plugins/config.py +123 -0
  153. howler/remote/__init__.py +0 -0
  154. howler/remote/datatypes/README.md +355 -0
  155. howler/remote/datatypes/__init__.py +98 -0
  156. howler/remote/datatypes/counters.py +63 -0
  157. howler/remote/datatypes/events.py +66 -0
  158. howler/remote/datatypes/hash.py +206 -0
  159. howler/remote/datatypes/lock.py +42 -0
  160. howler/remote/datatypes/queues/__init__.py +0 -0
  161. howler/remote/datatypes/queues/comms.py +59 -0
  162. howler/remote/datatypes/queues/multi.py +32 -0
  163. howler/remote/datatypes/queues/named.py +93 -0
  164. howler/remote/datatypes/queues/priority.py +215 -0
  165. howler/remote/datatypes/set.py +118 -0
  166. howler/remote/datatypes/user_quota_tracker.py +54 -0
  167. howler/security/__init__.py +253 -0
  168. howler/security/socket.py +108 -0
  169. howler/security/utils.py +185 -0
  170. howler/services/__init__.py +0 -0
  171. howler/services/action_service.py +111 -0
  172. howler/services/analytic_service.py +128 -0
  173. howler/services/auth_service.py +323 -0
  174. howler/services/config_service.py +128 -0
  175. howler/services/dossier_service.py +252 -0
  176. howler/services/event_service.py +93 -0
  177. howler/services/hit_service.py +893 -0
  178. howler/services/jwt_service.py +158 -0
  179. howler/services/lucene_service.py +286 -0
  180. howler/services/notebook_service.py +119 -0
  181. howler/services/overview_service.py +44 -0
  182. howler/services/template_service.py +45 -0
  183. howler/services/user_service.py +331 -0
  184. howler/utils/__init__.py +0 -0
  185. howler/utils/annotations.py +28 -0
  186. howler/utils/chunk.py +38 -0
  187. howler/utils/dict_utils.py +200 -0
  188. howler/utils/isotime.py +17 -0
  189. howler/utils/list_utils.py +11 -0
  190. howler/utils/lucene.py +77 -0
  191. howler/utils/path.py +27 -0
  192. howler/utils/socket_utils.py +61 -0
  193. howler/utils/str_utils.py +256 -0
  194. howler/utils/uid.py +47 -0
  195. howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
  196. howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
  197. howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
  198. howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
howler/__init__.py ADDED
File without changes
@@ -0,0 +1,168 @@
1
+ import importlib
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ from howler.common.logging import get_logger
8
+ from howler.odm.models.user import User
9
+ from howler.plugins import get_plugins
10
+
11
+ logger = get_logger(__file__)
12
+
13
+ PLUGIN_PATH = Path(os.environ.get("HWL_PLUGIN_DIRECTORY", "/etc/howler/plugins"))
14
+
15
+
16
+ def __sanitize_specification(spec: dict[str, Any]) -> dict[str, Any]:
17
+ """Adapt the specification for use in the UI
18
+
19
+ Args:
20
+ spec (dict[str, Any]): The raw specification
21
+
22
+ Returns:
23
+ dict[str, Any]: The sanitized specification for use in the UI
24
+ """
25
+ return {
26
+ **spec,
27
+ "description": {
28
+ **spec["description"],
29
+ "long": re.sub(r"\n +(request_id|query).+", "", spec["description"]["long"])
30
+ .replace("\n ", "\n")
31
+ .replace("Args:", "Args:\n"),
32
+ },
33
+ "steps": [{**step, "args": {k: list(v) for k, v in step["args"].items()}} for step in spec.get("steps", [])],
34
+ }
35
+
36
+
37
+ def __sanitize_report(report: list[dict[str, Any]]) -> list[dict[str, Any]]:
38
+ """Deduplicate identical entries with different queries
39
+
40
+ Args:
41
+ report (list[dict[str, Any]]): The unsanitized, verbose report
42
+
43
+ Returns:
44
+ list[dict[str, Any]]: The sanitized, concise report
45
+ """
46
+ by_message: dict[str, Any] = {}
47
+
48
+ for entry in report:
49
+ # if these three keys match, we should merge the queries that use them both. For example, when multiple hits
50
+ # fail to transition for the same reason.
51
+ key = f"{entry['title']}==={entry['message']}==={entry['outcome']}"
52
+
53
+ if key in by_message:
54
+ by_message[key].append(f'({entry["query"]})')
55
+ else:
56
+ by_message[key] = [f'({entry["query"]})']
57
+
58
+ sanitized: list[dict[str, Any]] = []
59
+ for key, queries in by_message.items():
60
+ (title, message, outcome) = key.split("===")
61
+
62
+ sanitized.append(
63
+ {
64
+ "query": " OR ".join(queries),
65
+ "outcome": outcome,
66
+ "title": title,
67
+ "message": message,
68
+ }
69
+ )
70
+
71
+ return sanitized
72
+
73
+
74
+ def execute(
75
+ operation_id: str,
76
+ query: str,
77
+ user: User | None,
78
+ request_id: Optional[str] = None,
79
+ **kwargs,
80
+ ) -> list[dict[str, Any]]:
81
+ """Execute a specification
82
+
83
+ Args:
84
+ operation_id (str): The id of the operation to run
85
+ query (str): The query to run this action on
86
+ user (dict[str, Any]): The user running this action
87
+ request_id (str, None): A user-provided ID, can be used to track the progress of their excecution via websockets
88
+
89
+ Returns:
90
+ list[dict[str, Any]]: A report on the execution
91
+ """
92
+ operation = None
93
+ try:
94
+ operation = importlib.import_module(f"howler.actions.{operation_id}")
95
+ except ImportError:
96
+ pass
97
+
98
+ if not operation:
99
+ for plugin in get_plugins():
100
+ if not plugin.modules.operations:
101
+ continue
102
+
103
+ operation = next(
104
+ (operation for operation in plugin.modules.operations if operation.OPERATION_ID == operation_id), None
105
+ )
106
+
107
+ if operation:
108
+ break
109
+
110
+ if not operation:
111
+ return [
112
+ {
113
+ "query": query,
114
+ "outcome": "error",
115
+ "title": "Unknown Action",
116
+ "message": f"The operation ID provided ({operation_id}) does not match any enabled operations.",
117
+ }
118
+ ]
119
+
120
+ user_roles: set[str] = set(user["type"] if user else [])
121
+ missing_roles = set(operation.specification()["roles"]) - user_roles
122
+ if missing_roles:
123
+ return [
124
+ {
125
+ "query": query,
126
+ "outcome": "error",
127
+ "title": "Insufficient permissions",
128
+ "message": (
129
+ f"The operation ID provided ({operation_id}) requires permissions you do not have "
130
+ f"({', '.join(missing_roles)}). Contact HOWLER Support for more information."
131
+ ),
132
+ }
133
+ ]
134
+
135
+ report = operation.execute(query=query, request_id=request_id, user=user, **kwargs)
136
+
137
+ return __sanitize_report(report)
138
+
139
+
140
+ def specifications() -> list[dict[str, Any]]:
141
+ """A list of specifications for the available operations
142
+
143
+ Returns:
144
+ list[dict[str, Any]]: A list of specifications
145
+ """
146
+ specifications = []
147
+
148
+ for module in (
149
+ _file
150
+ for _file in Path(__file__).parent.iterdir()
151
+ if _file.suffix == ".py" and _file.name not in ["__init__.py", "example_plugin.py"]
152
+ ):
153
+ try:
154
+ operation = importlib.import_module(f"howler.actions.{module.stem}")
155
+
156
+ specifications.append(__sanitize_specification(operation.specification()))
157
+
158
+ except Exception: # pragma: no cover
159
+ logger.exception("Error when initializing %s", module)
160
+
161
+ for plugin in get_plugins():
162
+ if not plugin.modules.operations:
163
+ continue
164
+
165
+ for operation in plugin.modules.operations:
166
+ specifications.append(__sanitize_specification(operation.specification()))
167
+
168
+ return specifications
@@ -0,0 +1,111 @@
1
+ from typing import Optional
2
+
3
+ from howler.common.loader import datastore
4
+ from howler.datastore.operations import OdmHelper
5
+ from howler.odm.models.action import VALID_TRIGGERS
6
+ from howler.odm.models.hit import Hit
7
+ from howler.odm.models.howler_data import Label
8
+ from howler.utils.str_utils import sanitize_lucene_query
9
+
10
+ hit_helper = OdmHelper(Hit)
11
+
12
+ OPERATION_ID = "add_label"
13
+
14
+ CATEGORIES = list(Label.fields().keys())
15
+
16
+
17
+ def execute(query: str, category: str = "generic", label: Optional[str] = None, **kwargs):
18
+ """Add a label to a hit.
19
+
20
+ Args:
21
+ query (str): The query on which to apply this automation.
22
+ category (str, optional): The category of label to add. Defaults to "generic".
23
+ label (str): The label content. Defaults to None.
24
+ """
25
+ if category not in CATEGORIES:
26
+ return [
27
+ {
28
+ "query": query,
29
+ "outcome": "error",
30
+ "title": "Invalid Category",
31
+ "message": f"'{category}' is not a valid category.",
32
+ }
33
+ ]
34
+
35
+ if not label:
36
+ return [
37
+ {
38
+ "query": query,
39
+ "outcome": "error",
40
+ "title": "Invalid Label",
41
+ "message": "Label cannot be empty.",
42
+ }
43
+ ]
44
+
45
+ report = []
46
+
47
+ ds = datastore()
48
+
49
+ skipped_hits = ds.hit.search(
50
+ f"({query}) AND howler.labels.{category}:{sanitize_lucene_query((label))}",
51
+ fl="howler.id",
52
+ )["items"]
53
+
54
+ if len(skipped_hits) > 0:
55
+ report.append(
56
+ {
57
+ "query": f"howler.id:({' OR '.join(h.howler.id for h in skipped_hits)})",
58
+ "outcome": "skipped",
59
+ "title": "Skipped Hit with Label",
60
+ "message": f"These hits already have the label {label}.",
61
+ }
62
+ )
63
+
64
+ try:
65
+ ds.hit.update_by_query(
66
+ query,
67
+ [hit_helper.list_add(f"howler.labels.{category}", label, if_missing=True)],
68
+ )
69
+
70
+ report.append(
71
+ {
72
+ "query": query,
73
+ "outcome": "success",
74
+ "title": "Executed Successfully",
75
+ "message": f"Label '{label}' added to category '{category}' for all matching hits.",
76
+ }
77
+ )
78
+ except Exception as e:
79
+ report.append(
80
+ {
81
+ "query": query,
82
+ "outcome": "error",
83
+ "title": "Failed to Execute",
84
+ "message": f"Unknown exception occurred: {str(e)}",
85
+ }
86
+ )
87
+
88
+ return report
89
+
90
+
91
+ def specification():
92
+ """Specify various properties of the action, such as title, descriptions, permissions and input steps."""
93
+ return {
94
+ "id": OPERATION_ID,
95
+ "title": "Add Label",
96
+ "priority": 8,
97
+ "i18nKey": "operations.add_label",
98
+ "description": {
99
+ "short": "Add a label to a hit",
100
+ "long": execute.__doc__,
101
+ },
102
+ "roles": ["automation_basic"],
103
+ "steps": [
104
+ {
105
+ "args": {"category": [], "label": []},
106
+ "options": {"category": CATEGORIES},
107
+ "validation": {"warn": {"query": "howler.labels.$category:$label"}},
108
+ }
109
+ ],
110
+ "triggers": VALID_TRIGGERS,
111
+ }
@@ -0,0 +1,159 @@
1
+ from typing import Optional
2
+
3
+ from howler.common.loader import datastore
4
+ from howler.datastore.operations import OdmHelper
5
+ from howler.odm.models.action import VALID_TRIGGERS
6
+ from howler.odm.models.hit import Hit
7
+ from howler.services import hit_service
8
+ from howler.utils.str_utils import sanitize_lucene_query
9
+
10
+ hit_helper = OdmHelper(Hit)
11
+
12
+ OPERATION_ID = "add_to_bundle"
13
+
14
+
15
+ def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
16
+ """Add a set of hits matching the query to the specified bundle.
17
+
18
+ Args:
19
+ query (str): The query containing the matching hits
20
+ bundle_id (str): The `howler.id` of the bundle to add the hits to.
21
+ """
22
+ report = []
23
+
24
+ if not bundle_id:
25
+ return [
26
+ {
27
+ "query": query,
28
+ "outcome": "error",
29
+ "title": "Invalid Bundle ID",
30
+ "message": "Bundle ID cannot be empty.",
31
+ }
32
+ ]
33
+
34
+ try:
35
+ bundle_hit = hit_service.get_hit(bundle_id, as_odm=True)
36
+ if not bundle_hit or not bundle_hit.howler.is_bundle:
37
+ report.append(
38
+ {
39
+ "query": query,
40
+ "outcome": "error",
41
+ "title": "Invalid Bundle",
42
+ "message": f"Either a hit with ID {bundle_id} does not exist, or it is not a bundle.",
43
+ }
44
+ )
45
+ return report
46
+
47
+ ds = datastore()
48
+
49
+ skipped_hits_bundles = ds.hit.search(
50
+ f"({query}) AND howler.is_bundle:true",
51
+ fl="howler.id",
52
+ )["items"]
53
+
54
+ if len(skipped_hits_bundles) > 0:
55
+ report.append(
56
+ {
57
+ "query": f"({query}) AND howler.is_bundle:true",
58
+ "outcome": "skipped",
59
+ "title": "Skipped Bundles",
60
+ "message": "Bundles cannot be added to a bundle.",
61
+ }
62
+ )
63
+
64
+ skipped_hits_already_added = ds.hit.search(
65
+ f"({query}) AND (howler.bundles:{sanitize_lucene_query(bundle_id)})",
66
+ fl="howler.id",
67
+ )["items"]
68
+
69
+ if len(skipped_hits_already_added) > 0:
70
+ report.append(
71
+ {
72
+ "query": f"({query}) AND (howler.bundles:{sanitize_lucene_query(bundle_id)})",
73
+ "outcome": "skipped",
74
+ "title": "Skipped Hits",
75
+ "message": "These hits have already been added to the specified bundle.",
76
+ }
77
+ )
78
+
79
+ safe_query = f"({query}) AND (-howler.bundles:({sanitize_lucene_query(bundle_id)}) AND howler.is_bundle:false)"
80
+ matching_hits = ds.hit.search(safe_query)["items"]
81
+ if len(matching_hits) < 1:
82
+ report.append(
83
+ {
84
+ "query": safe_query,
85
+ "outcome": "skipped",
86
+ "title": "No Matching Hits",
87
+ "message": "There were no hits matching this query.",
88
+ }
89
+ )
90
+ return report
91
+
92
+ ds.hit.update_by_query(
93
+ safe_query,
94
+ [hit_helper.list_add("howler.bundles", sanitize_lucene_query(bundle_id), if_missing=True)],
95
+ )
96
+
97
+ operations = [
98
+ hit_helper.list_add(
99
+ "howler.hits",
100
+ hit["howler"]["id"],
101
+ if_missing=True,
102
+ )
103
+ for hit in matching_hits
104
+ ]
105
+
106
+ operations.append(hit_helper.update("howler.bundle_size", len(operations)))
107
+ hit_service.update_hit(
108
+ bundle_id,
109
+ operations,
110
+ )
111
+ bundle_hit = hit_service.get_hit(bundle_id, as_odm=True)
112
+ report.append(
113
+ {
114
+ "query": safe_query.replace("-howler.bundles", "howler.bundles"),
115
+ "outcome": "success",
116
+ "title": "Executed Successfully",
117
+ "message": "The specified bundle has had all matching hits added.",
118
+ }
119
+ )
120
+ except Exception as e:
121
+ report.append(
122
+ {
123
+ "query": query,
124
+ "outcome": "error",
125
+ "title": "Failed to Execute",
126
+ "message": f"Unknown exception occurred: {str(e)}",
127
+ }
128
+ )
129
+
130
+ return report
131
+
132
+
133
+ def specification():
134
+ """Specify various properties of the action, such as title, descriptions, permissions and input steps."""
135
+ return {
136
+ "id": OPERATION_ID,
137
+ "title": "Add to Bundle",
138
+ "priority": 6,
139
+ "i18nKey": f"operations.{OPERATION_ID}",
140
+ "description": {
141
+ "short": "Add a set of hits to a bundle",
142
+ "long": execute.__doc__,
143
+ },
144
+ "roles": ["automation_basic"],
145
+ "steps": [
146
+ {
147
+ "args": {"bundle_id": []},
148
+ "options": {},
149
+ "validation": {
150
+ "warn": {"query": "howler.bundles:($bundle_id) OR howler.is_bundle:true"},
151
+ "error": {
152
+ "query": "howler.id:$bundle_id AND howler.is_bundle:false",
153
+ "message": "The bundle id given must be a bundle.",
154
+ },
155
+ },
156
+ }
157
+ ],
158
+ "triggers": VALID_TRIGGERS,
159
+ }
@@ -0,0 +1,76 @@
1
+ from howler.common.loader import datastore
2
+ from howler.datastore.operations import OdmHelper
3
+ from howler.odm.models.action import VALID_TRIGGERS
4
+ from howler.odm.models.hit import Hit
5
+
6
+ hit_helper = OdmHelper(Hit)
7
+
8
+ OPERATION_ID = "change_field"
9
+
10
+
11
+ def execute(query: str, field: str, value: str, **kwargs):
12
+ """Change one of the fields of a hit
13
+
14
+ Args:
15
+ query (str): The query to run this action on
16
+ field (str): The field to update.
17
+ value (str): The value to set it to. Must be a string.
18
+ """
19
+ if field not in Hit.flat_fields():
20
+ return [
21
+ {
22
+ "query": query,
23
+ "outcome": "error",
24
+ "title": "Invalid field",
25
+ "message": (f"Field '{field}' does not exist. You must pick a valid entry from the howler index."),
26
+ }
27
+ ]
28
+
29
+ report = []
30
+
31
+ try:
32
+ datastore().hit.update_by_query(
33
+ query,
34
+ [hit_helper.update(field, value)],
35
+ )
36
+
37
+ report.append(
38
+ {
39
+ "query": query,
40
+ "outcome": "success",
41
+ "title": "Executed Successfully",
42
+ "message": f"Field '{field}' updated to value '{value}' for all matching hits.",
43
+ }
44
+ )
45
+ except Exception as e:
46
+ report.append(
47
+ {
48
+ "query": query,
49
+ "outcome": "error",
50
+ "title": "Failed to Execute",
51
+ "message": str(e),
52
+ }
53
+ )
54
+
55
+ return report
56
+
57
+
58
+ def specification():
59
+ """Specify various properties of the action, such as title, descriptions, permissions and input steps."""
60
+ return {
61
+ "id": OPERATION_ID,
62
+ "title": "Change Field",
63
+ "i18nKey": f"operations.{OPERATION_ID}",
64
+ "description": {
65
+ "short": "Change one of the fields of a hit",
66
+ "long": execute.__doc__,
67
+ },
68
+ "roles": ["automation_advanced", "admin"],
69
+ "steps": [
70
+ {
71
+ "args": {"field": [], "value": []},
72
+ "options": {"field": list(Hit.flat_fields().keys())},
73
+ }
74
+ ],
75
+ "triggers": VALID_TRIGGERS,
76
+ }
@@ -0,0 +1,160 @@
1
+ from typing import Optional
2
+
3
+ import howler.helper.hit as hit_helper
4
+ from howler.common.loader import datastore
5
+ from howler.datastore.operations import OdmHelper
6
+ from howler.odm.models.action import VALID_TRIGGERS
7
+ from howler.odm.models.hit import Hit
8
+ from howler.odm.models.howler_data import (
9
+ Assessment,
10
+ AssessmentEscalationMap,
11
+ Escalation,
12
+ HitStatus,
13
+ )
14
+ from howler.odm.models.user import User
15
+ from howler.utils.str_utils import sanitize_lucene_query
16
+
17
+ OPERATION_ID = "demote"
18
+
19
+ ESCALATIONS = [esc for esc in Escalation.list() if esc != Escalation.EVIDENCE]
20
+
21
+ odm_helper = OdmHelper(Hit)
22
+
23
+
24
+ def execute(
25
+ query: str,
26
+ escalation: Escalation = Escalation.HIT,
27
+ assessment: Optional[str] = None,
28
+ rationale: Optional[str] = None,
29
+ user: Optional[User] = None,
30
+ **kwargs,
31
+ ):
32
+ """Demote a hit.
33
+
34
+ Args:
35
+ query (str): The query on which to apply this automation.
36
+ escalation (str, optional): The escalation to demote to. Defaults to "hit".
37
+ assessment (str, optional): The assessment to apply if demoting to miss. Required if escalation is "miss".
38
+ rationale (str, optional): The optional rationale to apply if demoting to miss.
39
+ """
40
+ if escalation not in ESCALATIONS:
41
+ return [
42
+ {
43
+ "query": query,
44
+ "outcome": "error",
45
+ "title": "Invalid Escalation",
46
+ "message": f"'{escalation}' is not a valid escalation.",
47
+ }
48
+ ]
49
+
50
+ report = []
51
+
52
+ ds = datastore()
53
+
54
+ skipped_hits = ds.hit.search(
55
+ f"({query}) AND howler.escalation:{sanitize_lucene_query(escalation)}",
56
+ fl="howler.id",
57
+ )["items"]
58
+
59
+ if len(skipped_hits) > 0:
60
+ report.append(
61
+ {
62
+ "query": f"howler.id:({' OR '.join(h.howler.id for h in skipped_hits)})",
63
+ "outcome": "skipped",
64
+ "title": "Skipped Hit with Escalation",
65
+ "message": f"These hits already have the escalation {escalation}.",
66
+ }
67
+ )
68
+
69
+ try:
70
+ if escalation in [Escalation.HIT, Escalation.ALERT]:
71
+ ds.hit.update_by_query(
72
+ query,
73
+ [
74
+ *hit_helper.demote_hit(escalation=escalation),
75
+ odm_helper.update("howler.assessment", None),
76
+ odm_helper.update("howler.rationale", None),
77
+ ],
78
+ )
79
+ else:
80
+ if not assessment:
81
+ report.append(
82
+ {
83
+ "query": query,
84
+ "outcome": "error",
85
+ "title": "Missing assessment",
86
+ "message": "You must provide as assessment value when demoting to miss.",
87
+ }
88
+ )
89
+ return report
90
+
91
+ ds.hit.update_by_query(
92
+ query,
93
+ [
94
+ *hit_helper.assess_hit(assessment, rationale),
95
+ odm_helper.update(
96
+ "howler.assignment",
97
+ user.get("uname", "automation") if user else "automation",
98
+ ),
99
+ odm_helper.update("howler.status", HitStatus.RESOLVED),
100
+ ],
101
+ )
102
+
103
+ report.append(
104
+ {
105
+ "query": query,
106
+ "outcome": "success",
107
+ "title": "Executed Successfully",
108
+ "message": f"Demoted to '{escalation}' for all matching hits.",
109
+ }
110
+ )
111
+ except Exception as e:
112
+ report.append(
113
+ {
114
+ "query": query,
115
+ "outcome": "error",
116
+ "title": "Failed to Execute",
117
+ "message": f"Unknown exception occurred: {str(e)}",
118
+ }
119
+ )
120
+
121
+ return report
122
+
123
+
124
+ def specification():
125
+ """Specify various properties of the action, such as title, descriptions, permissions and input steps."""
126
+ return {
127
+ "id": OPERATION_ID,
128
+ "title": "Demote Hit",
129
+ "i18nKey": "operations.demote",
130
+ "description": {
131
+ "short": "Demote a hit",
132
+ "long": execute.__doc__,
133
+ },
134
+ "roles": ["automation_basic"],
135
+ "steps": [
136
+ {
137
+ "args": {"escalation": []},
138
+ "options": {"escalation": ESCALATIONS},
139
+ "validation": {"warn": {"query": "howler.escalation:$escalation"}},
140
+ },
141
+ {
142
+ "args": {
143
+ "assessment": ["escalation:miss"],
144
+ "rationale": ["escalation:miss"],
145
+ },
146
+ "options": {
147
+ "assessment": {
148
+ "escalation:miss": [
149
+ assessment
150
+ for assessment in Assessment.list()
151
+ if AssessmentEscalationMap[assessment] == Escalation.MISS
152
+ ],
153
+ "escalation:alert": [],
154
+ "escalation:hit": [],
155
+ }
156
+ },
157
+ },
158
+ ],
159
+ "triggers": VALID_TRIGGERS,
160
+ }