psengine 2.8.5__tar.gz → 2.10.0__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.
Files changed (191) hide show
  1. {psengine-2.8.5 → psengine-2.10.0}/PKG-INFO +2 -1
  2. {psengine-2.8.5 → psengine-2.10.0}/README.md +1 -0
  3. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/note_mgr.py +5 -0
  4. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/classic_alert.py +2 -0
  5. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/markdown/markdown.py +10 -0
  6. {psengine-2.8.5 → psengine-2.10.0}/psengine/config/config.py +33 -13
  7. {psengine-2.8.5 → psengine-2.10.0}/psengine/constants.py +2 -0
  8. {psengine-2.8.5 → psengine-2.10.0}/psengine/endpoints.py +32 -0
  9. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/models/soar.py +0 -1
  10. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_lists/__init__.py +15 -1
  11. psengine-2.10.0/psengine/entity_lists/constants.py +90 -0
  12. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_lists/entity_list.py +109 -1
  13. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_lists/models.py +83 -1
  14. {psengine-2.8.5 → psengine-2.10.0}/psengine/helpers/helpers.py +38 -5
  15. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/malware_intel.py +1 -1
  16. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/models.py +2 -2
  17. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/__init__.py +2 -0
  18. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/constants.py +17 -3
  19. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/errors.py +4 -0
  20. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/mappings.py +8 -0
  21. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown.py +17 -0
  22. psengine-2.10.0/psengine/playbook_alerts/markdown/markdown_compromised_bank_checks.py +187 -0
  23. psengine-2.10.0/psengine/playbook_alerts/markdown/markdown_malicious_sites.py +147 -0
  24. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/__init__.py +10 -0
  25. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/__init__.py +96 -0
  26. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/code_repo.py +48 -0
  27. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/common.py +111 -0
  28. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/domain_abuse.py +82 -0
  29. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/generic.py +73 -0
  30. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/geopolitics.py +47 -0
  31. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/malicious_sites.py +141 -0
  32. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/panel_log_v2.py +127 -0
  33. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/tpr.py +43 -0
  34. psengine-2.10.0/psengine/playbook_alerts/models/panel_log/vulnerability.py +38 -0
  35. psengine-2.10.0/psengine/playbook_alerts/models/pba_compromised_bank_checks.py +66 -0
  36. psengine-2.10.0/psengine/playbook_alerts/models/pba_malicious_sites.py +224 -0
  37. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/pa_category.py +2 -0
  38. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/playbook_alert_mgr.py +102 -7
  39. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/playbook_alerts.py +248 -1
  40. psengine-2.10.0/psengine/sandbox/__init__.py +29 -0
  41. psengine-2.10.0/psengine/sandbox/client.py +225 -0
  42. psengine-2.10.0/psengine/sandbox/constants.py +25 -0
  43. psengine-2.10.0/psengine/sandbox/errors.py +103 -0
  44. psengine-2.10.0/psengine/sandbox/models/__init__.py +13 -0
  45. psengine-2.10.0/psengine/sandbox/models/analysis.py +70 -0
  46. psengine-2.10.0/psengine/sandbox/models/behavioral_report.py +212 -0
  47. psengine-2.10.0/psengine/sandbox/models/overview_report.py +185 -0
  48. psengine-2.10.0/psengine/sandbox/models/static_report.py +144 -0
  49. psengine-2.10.0/psengine/sandbox/sandbox.py +493 -0
  50. psengine-2.10.0/psengine/sandbox/sandbox_mgr.py +1462 -0
  51. psengine-2.10.0/pyproject.toml +121 -0
  52. psengine-2.8.5/pyproject.toml → psengine-2.10.0/pyproject.toml.orig +2 -2
  53. psengine-2.8.5/psengine/entity_lists/constants.py +0 -19
  54. psengine-2.8.5/psengine/playbook_alerts/models/panel_log.py +0 -328
  55. psengine-2.8.5/psengine/py.typed +0 -0
  56. {psengine-2.8.5 → psengine-2.10.0}/psengine/__init__.py +0 -0
  57. {psengine-2.8.5 → psengine-2.10.0}/psengine/_sdk_id.py +0 -0
  58. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/__init__.py +0 -0
  59. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/constants.py +0 -0
  60. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/errors.py +0 -0
  61. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/helpers.py +0 -0
  62. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/markdown.py +0 -0
  63. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/models.py +0 -0
  64. {psengine-2.8.5 → psengine-2.10.0}/psengine/analyst_notes/note.py +0 -0
  65. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/__init__.py +0 -0
  66. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/asi.py +0 -0
  67. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/asi_mgr.py +0 -0
  68. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/client.py +0 -0
  69. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/constants.py +0 -0
  70. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/errors.py +0 -0
  71. {psengine-2.8.5 → psengine-2.10.0}/psengine/asi/models.py +0 -0
  72. {psengine-2.8.5 → psengine-2.10.0}/psengine/base_http_client.py +0 -0
  73. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/__init__.py +0 -0
  74. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/classic_alert_mgr.py +0 -0
  75. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/constants.py +0 -0
  76. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/errors.py +0 -0
  77. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/helpers.py +0 -0
  78. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/markdown/__init__.py +0 -0
  79. {psengine-2.8.5 → psengine-2.10.0}/psengine/classic_alerts/models.py +0 -0
  80. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/__init__.py +0 -0
  81. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/collective_insights.py +0 -0
  82. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/constants.py +0 -0
  83. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/errors.py +0 -0
  84. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/insight.py +0 -0
  85. {psengine-2.8.5 → psengine-2.10.0}/psengine/collective_insights/models.py +0 -0
  86. {psengine-2.8.5 → psengine-2.10.0}/psengine/common_models.py +0 -0
  87. {psengine-2.8.5 → psengine-2.10.0}/psengine/config/__init__.py +0 -0
  88. {psengine-2.8.5 → psengine-2.10.0}/psengine/config/errors.py +0 -0
  89. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/__init__.py +0 -0
  90. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/detection_mgr.py +0 -0
  91. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/detection_rule.py +0 -0
  92. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/errors.py +0 -0
  93. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/helpers.py +0 -0
  94. {psengine-2.8.5 → psengine-2.10.0}/psengine/detection/models.py +0 -0
  95. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/__init__.py +0 -0
  96. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/constants.py +0 -0
  97. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/errors.py +0 -0
  98. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/lookup.py +0 -0
  99. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/lookup_mgr.py +0 -0
  100. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/models/__init__.py +0 -0
  101. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/models/base_enriched_entity.py +0 -0
  102. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/models/lookup.py +0 -0
  103. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/soar.py +0 -0
  104. {psengine-2.8.5 → psengine-2.10.0}/psengine/enrich/soar_mgr.py +0 -0
  105. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_lists/entity_list_mgr.py +0 -0
  106. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_lists/errors.py +0 -0
  107. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_match/__init__.py +0 -0
  108. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_match/entity_match.py +0 -0
  109. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_match/entity_match_mgr.py +0 -0
  110. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_match/errors.py +0 -0
  111. {psengine-2.8.5 → psengine-2.10.0}/psengine/entity_match/models.py +0 -0
  112. {psengine-2.8.5 → psengine-2.10.0}/psengine/errors.py +0 -0
  113. {psengine-2.8.5 → psengine-2.10.0}/psengine/fusion/__init__.py +0 -0
  114. {psengine-2.8.5 → psengine-2.10.0}/psengine/fusion/errors.py +0 -0
  115. {psengine-2.8.5 → psengine-2.10.0}/psengine/fusion/fusion_mgr.py +0 -0
  116. {psengine-2.8.5 → psengine-2.10.0}/psengine/fusion/models.py +0 -0
  117. {psengine-2.8.5 → psengine-2.10.0}/psengine/helpers/__init__.py +0 -0
  118. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/__init__.py +0 -0
  119. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/constants.py +0 -0
  120. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/errors.py +0 -0
  121. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/identity.py +0 -0
  122. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/identity_mgr.py +0 -0
  123. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/models/__init__.py +0 -0
  124. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/models/common_models.py +0 -0
  125. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/models/detections.py +0 -0
  126. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/models/incident_report.py +0 -0
  127. {psengine-2.8.5 → psengine-2.10.0}/psengine/identity/models/lookup.py +0 -0
  128. {psengine-2.8.5 → psengine-2.10.0}/psengine/links/__init__.py +0 -0
  129. {psengine-2.8.5 → psengine-2.10.0}/psengine/links/errors.py +0 -0
  130. {psengine-2.8.5 → psengine-2.10.0}/psengine/links/links.py +0 -0
  131. {psengine-2.8.5 → psengine-2.10.0}/psengine/links/links_mgr.py +0 -0
  132. {psengine-2.8.5 → psengine-2.10.0}/psengine/links/models.py +0 -0
  133. {psengine-2.8.5 → psengine-2.10.0}/psengine/logger/__init__.py +0 -0
  134. {psengine-2.8.5 → psengine-2.10.0}/psengine/logger/constants.py +0 -0
  135. {psengine-2.8.5 → psengine-2.10.0}/psengine/logger/errors.py +0 -0
  136. {psengine-2.8.5 → psengine-2.10.0}/psengine/logger/rf_logger.py +0 -0
  137. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/__init__.py +0 -0
  138. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/auto_sigma_mgr.py +0 -0
  139. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/auto_yara_mgr.py +0 -0
  140. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/constants.py +0 -0
  141. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/errors.py +0 -0
  142. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/helpers.py +0 -0
  143. {psengine-2.8.5 → psengine-2.10.0}/psengine/malware_intel/malware_intel_mgr.py +0 -0
  144. {psengine-2.8.5 → psengine-2.10.0}/psengine/markdown/__init__.py +0 -0
  145. {psengine-2.8.5 → psengine-2.10.0}/psengine/markdown/markdown.py +0 -0
  146. {psengine-2.8.5 → psengine-2.10.0}/psengine/markdown/markdown_strings.py +0 -0
  147. {psengine-2.8.5 → psengine-2.10.0}/psengine/markdown/models.py +0 -0
  148. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/helpers.py +0 -0
  149. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/__init__.py +0 -0
  150. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_code_repo.py +0 -0
  151. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py +0 -0
  152. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_domain_abuse.py +0 -0
  153. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_geopolitics_facility.py +0 -0
  154. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_identity_exposure.py +0 -0
  155. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_malware_report.py +0 -0
  156. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/markdown/markdown_third_party_risk.py +0 -0
  157. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/common_models.py +0 -0
  158. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/panel_status.py +0 -0
  159. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_code_repo_leak.py +0 -0
  160. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_cyber_vulnerability.py +0 -0
  161. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_domain_abuse.py +0 -0
  162. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_geopolitics_facility.py +0 -0
  163. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_identity_exposures.py +0 -0
  164. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_malware_report.py +0 -0
  165. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/pba_third_party_risk.py +0 -0
  166. {psengine-2.8.5 → psengine-2.10.0}/psengine/playbook_alerts/models/search_endpoint.py +0 -0
  167. {psengine-2.8.5 → psengine-2.10.0}/psengine/rf_client.py +0 -0
  168. {psengine-2.8.5 → psengine-2.10.0}/psengine/risk_history/__init__.py +0 -0
  169. {psengine-2.8.5 → psengine-2.10.0}/psengine/risk_history/errors.py +0 -0
  170. {psengine-2.8.5 → psengine-2.10.0}/psengine/risk_history/models.py +0 -0
  171. {psengine-2.8.5 → psengine-2.10.0}/psengine/risk_history/risk_history_mgr.py +0 -0
  172. {psengine-2.8.5 → psengine-2.10.0}/psengine/risklists/__init__.py +0 -0
  173. {psengine-2.8.5 → psengine-2.10.0}/psengine/risklists/constants.py +0 -0
  174. {psengine-2.8.5 → psengine-2.10.0}/psengine/risklists/errors.py +0 -0
  175. {psengine-2.8.5 → psengine-2.10.0}/psengine/risklists/models.py +0 -0
  176. {psengine-2.8.5 → psengine-2.10.0}/psengine/risklists/risklist_mgr.py +0 -0
  177. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/__init__.py +0 -0
  178. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/base_stix_entity.py +0 -0
  179. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/complex_entity.py +0 -0
  180. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/constants.py +0 -0
  181. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/enriched_indicator.py +0 -0
  182. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/errors.py +0 -0
  183. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/helpers.py +0 -0
  184. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/rf_bundle.py +0 -0
  185. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/simple_entity.py +0 -0
  186. {psengine-2.8.5 → psengine-2.10.0}/psengine/stix2/util.py +0 -0
  187. {psengine-2.8.5 → psengine-2.10.0}/psengine/threat_maps/__init__.py +0 -0
  188. {psengine-2.8.5 → psengine-2.10.0}/psengine/threat_maps/errors.py +0 -0
  189. {psengine-2.8.5 → psengine-2.10.0}/psengine/threat_maps/models.py +0 -0
  190. {psengine-2.8.5 → psengine-2.10.0}/psengine/threat_maps/threat_map.py +0 -0
  191. {psengine-2.8.5 → psengine-2.10.0}/psengine/threat_maps/threat_map_mgr.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: psengine
3
- Version: 2.8.5
3
+ Version: 2.10.0
4
4
  Summary: psengine is a simple, yet elegant, library for rapid development of integrations with Recorded Future.
5
5
  Keywords: API,Recorded Future,Cyber Security Engineering,Threat Intelligence
6
6
  Author: Moise Medici, Patrick Kinsella, Ernest Bartosevic
@@ -76,6 +76,7 @@ It can easily interact with the following Recorded Future datasets:
76
76
  - On demand IOC enrichment
77
77
  - Risklists
78
78
  - Risk History
79
+ - Sandbox Detonation of files and URLs
79
80
  - STIX conversion
80
81
 
81
82
 
@@ -43,6 +43,7 @@ It can easily interact with the following Recorded Future datasets:
43
43
  - On demand IOC enrichment
44
44
  - Risklists
45
45
  - Risk History
46
+ - Sandbox Detonation of files and URLs
46
47
  - STIX conversion
47
48
 
48
49
 
@@ -92,6 +92,11 @@ class AnalystNoteMgr:
92
92
 
93
93
  `max_results` is the maximum number of references, not notes.
94
94
 
95
+ Note:
96
+ Search returns a reduced `AnalystNote` projection. Fields such as
97
+ `diamond_model`, `events`, and `labels` are not populated and must be
98
+ retrieved per note via `lookup()`.
99
+
95
100
  Endpoint:
96
101
  `/analystnote/search`
97
102
 
@@ -208,6 +208,7 @@ class ClassicAlert(RFBaseModel):
208
208
  html_tags: Annotated[bool, Doc('Include HTML tags in the markdown.')] = False,
209
209
  character_limit: Annotated[int | None, Doc('Character limit for the markdown.')] = None,
210
210
  defang_iocs: Annotated[bool, Doc('Defang IOCs in hits.')] = False,
211
+ comment: Annotated[bool, Doc('Include reviewer note in the markdown.')] = False,
211
212
  ) -> Annotated[str, Doc('Markdown representation of the alert.')]:
212
213
  """Return a markdown string representation of the `ClassicAlert` instance.
213
214
 
@@ -229,6 +230,7 @@ class ClassicAlert(RFBaseModel):
229
230
  html_tags=html_tags,
230
231
  character_limit=character_limit,
231
232
  defang_iocs=defang_iocs,
233
+ comment=comment,
232
234
  )
233
235
 
234
236
  @property
@@ -306,6 +306,13 @@ def _add_hits_section_if_no_enriched_entities(
306
306
  )
307
307
 
308
308
 
309
+ def _add_comment_section(
310
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', comment: bool
311
+ ) -> None:
312
+ if comment and classic_alert.review.note:
313
+ md_maker.add_section('Reviewer Note', classic_alert.review.note)
314
+
315
+
309
316
  def _markdown_alert(
310
317
  classic_alert: 'ClassicAlert',
311
318
  owner_org: bool = False,
@@ -315,6 +322,7 @@ def _markdown_alert(
315
322
  html_tags: bool = False,
316
323
  character_limit: int = None,
317
324
  defang_iocs: bool = False,
325
+ comment: bool = False,
318
326
  ) -> str:
319
327
  """Returns a markdown string representation of the `ClassicAlert` instance.
320
328
 
@@ -331,6 +339,7 @@ def _markdown_alert(
331
339
  html_tags (bool, optional): Include HTML tags in the markdown. Defaults to False.
332
340
  character_limit (int, optional): Character limit for the markdown. Defaults to None.
333
341
  defang_iocs (bool, optional): Defang IOCs in hits. Defaults to False.
342
+ comment (bool, optional): Include reviewer note in the markdown. Defaults to False.
334
343
 
335
344
  Raises:
336
345
  AlertMarkdownError: If fields are not available.
@@ -351,6 +360,7 @@ def _markdown_alert(
351
360
  _add_summary_section(md_maker, classic_alert)
352
361
  _add_owner_org_section(md_maker, classic_alert, owner_org)
353
362
  _add_ai_insights_section(md_maker, classic_alert, ai_insights)
363
+ _add_comment_section(md_maker, classic_alert, comment)
354
364
 
355
365
  if classic_alert.enriched_entities:
356
366
  _add_enriched_entities_sections(md_maker, classic_alert, triggered_by, html_tags)
@@ -18,7 +18,7 @@ from copy import deepcopy
18
18
  from pathlib import Path
19
19
  from typing import Annotated
20
20
 
21
- from pydantic import Field, Secret, field_validator, validate_call
21
+ from pydantic import Field, Secret, ValidationInfo, field_validator, validate_call
22
22
  from pydantic_settings import (
23
23
  BaseSettings,
24
24
  DotEnvSettingsSource,
@@ -32,6 +32,7 @@ from typing_extensions import Doc
32
32
 
33
33
  from ..constants import (
34
34
  ASI_TOKEN_ENV_VAR,
35
+ ASI_TOKEN_VALIDATION_REGEX,
35
36
  BACKOFF_FACTOR,
36
37
  POOL_MAX_SIZE,
37
38
  REQUEST_TIMEOUT,
@@ -39,6 +40,8 @@ from ..constants import (
39
40
  RF_TOKEN_ENV_VAR,
40
41
  RF_TOKEN_VALIDATION_REGEX,
41
42
  ROOT_DIR,
43
+ SANDBOX_TOKEN_ENV_VAR,
44
+ SANDBOX_TOKEN_VALIDATION_REGEX,
42
45
  SSL_VERIFY,
43
46
  STATUS_FORCELIST,
44
47
  )
@@ -48,6 +51,20 @@ from .errors import ConfigFileError
48
51
  PLAT_REGEX = r'^([A-Z]|[a-z])+(\/\d+)?((\.\d+)*?)$'
49
52
  APP_ID_REGEX = r'^\S+\/\d+((\.\d+)*?)$'
50
53
 
54
+ TOKEN_CONFIG = {
55
+ 'rf_token': (RF_TOKEN_ENV_VAR, RF_TOKEN_VALIDATION_REGEX, 'Recorded Future'),
56
+ 'asi_token': (
57
+ ASI_TOKEN_ENV_VAR,
58
+ ASI_TOKEN_VALIDATION_REGEX,
59
+ 'Recorded Future Attack Surface Intelligence',
60
+ ),
61
+ 'sandbox_token': (
62
+ SANDBOX_TOKEN_ENV_VAR,
63
+ SANDBOX_TOKEN_VALIDATION_REGEX,
64
+ 'Recorded Future Sandbox',
65
+ ),
66
+ }
67
+
51
68
 
52
69
  class RFToken(Secret[str]):
53
70
  """Recorded Future token mask."""
@@ -93,6 +110,7 @@ class ConfigModel(BaseSettings):
93
110
  app_id: str | None = Field(default=None, pattern=APP_ID_REGEX, examples=['get-alerts/1.0.0'])
94
111
  rf_token: RFToken | None = Field(default=os.environ.get(RF_TOKEN_ENV_VAR, ''))
95
112
  asi_token: RFToken | None = Field(default=os.environ.get(ASI_TOKEN_ENV_VAR, ''))
113
+ sandbox_token: RFToken | None = Field(default=os.environ.get(SANDBOX_TOKEN_ENV_VAR, ''))
96
114
  http_proxy: str | None = None
97
115
  https_proxy: str | None = None
98
116
  client_ssl_verify: bool | None = SSL_VERIFY
@@ -159,28 +177,28 @@ class ConfigModel(BaseSettings):
159
177
  raise ValueError('The config file extension must be .toml or .json or .env')
160
178
  return tuple(sources)
161
179
 
162
- @field_validator('rf_token', mode='before')
180
+ @field_validator('rf_token', 'asi_token', 'sandbox_token', mode='before')
163
181
  @classmethod
164
182
  def validate_token(
165
183
  cls,
166
- rf_token: Annotated[str, Doc('Recorded Future token.')],
184
+ token: Annotated[str, Doc('API token value.')],
185
+ info: ValidationInfo,
167
186
  ) -> Annotated[str, Doc('Validated token.')]:
168
- """Validate a Recorded Future token.
187
+ """Validate a Recorded Future API token (`rf_token`, `asi_token`, or `sandbox_token`).
169
188
 
170
189
  Raises:
171
- ValueError: When the token is not 32 alphanumeric characters in the `[a-f][0-9]` range.
190
+ ValueError: When the token does not match the expected format for its field.
172
191
  """
173
- rf_token = rf_token or os.environ.get(RF_TOKEN_ENV_VAR)
174
- if not rf_token:
192
+ env_var, regex, label = TOKEN_CONFIG[info.field_name]
193
+ token = token or os.environ.get(env_var)
194
+ if not token:
175
195
  # Edge case: when RF_RF_TOKEN env var is set, it is used and RF_TOKEN is ignored
176
196
  # So we check if RF_TOKEN is set and validate it
177
197
  return ''
178
- if not re.match(RF_TOKEN_VALIDATION_REGEX, rf_token):
179
- raise ValueError(
180
- f'Invalid Recorded Future API token.must match regex {RF_TOKEN_VALIDATION_REGEX}'
181
- )
198
+ if not re.fullmatch(regex, token):
199
+ raise ValueError(f'Invalid {label} API token. Must match regex {regex}')
182
200
 
183
- return rf_token
201
+ return token
184
202
 
185
203
  @validate_call
186
204
  def save_config(
@@ -198,7 +216,7 @@ class ConfigModel(BaseSettings):
198
216
  """
199
217
  directory = Path(directory)
200
218
  log = logging.getLogger(__name__)
201
- data = self.model_dump_json(exclude=['rf_token', 'asi_token'], indent=4)
219
+ data = self.model_dump_json(exclude=['rf_token', 'asi_token', 'sandbox_token'], indent=4)
202
220
  OSHelpers.mkdir(directory)
203
221
  config_path = directory / file
204
222
  log.info(f'Saving config in {config_path.as_posix()}')
@@ -245,6 +263,8 @@ class Config:
245
263
  platform_id (str): Name & version of the tool this integrates with, example: ES/8.0.0.
246
264
  app_id (str): Name & version of the integration itself, example: get-alerts/1.0.0.
247
265
  rf_token (str): Recorded Future API token.
266
+ asi_token (str): Recorded Future Attack Surface Intelligence API token.
267
+ sandbox_token (str): Recorded Future Sandbox API token.
248
268
  http_proxy (str): HTTP proxy.
249
269
  https_proxy (str): HTTPS proxy.
250
270
  client_ssl_verify (bool): SSL verification. Default is True.
@@ -26,6 +26,8 @@ RF_TOKEN_ENV_VAR = 'RF_TOKEN' # noqa: S105
26
26
  RF_TOKEN_VALIDATION_REGEX = r'^[a-z0-9]{32}$' # noqa: S105
27
27
  ASI_TOKEN_ENV_VAR = 'RF_ASI_TOKEN' # noqa: S105
28
28
  ASI_TOKEN_VALIDATION_REGEX = r'^[a-zA-Z0-9-_]{32}$' # noqa: S105
29
+ SANDBOX_TOKEN_ENV_VAR = 'RF_SANDBOX_TOKEN' # noqa: S105
30
+ SANDBOX_TOKEN_VALIDATION_REGEX = r'^[a-f0-9]{40}$' # noqa: S105
29
31
 
30
32
 
31
33
  #####################
@@ -53,10 +53,13 @@ EP_PLAYBOOK_ALERT = BASE_URL + '/playbook-alert'
53
53
  EP_PLAYBOOK_ALERT_SEARCH = EP_PLAYBOOK_ALERT + '/search'
54
54
  EP_PLAYBOOK_ALERT_COMMON = EP_PLAYBOOK_ALERT + '/common'
55
55
  EP_PLAYBOOK_ALERT_CODE_REPO_LEAKAGE = EP_PLAYBOOK_ALERT + '/code_repo_leakage'
56
+ EP_PLAYBOOK_ALERT_COMPROMISED_BANK_CHECKS = EP_PLAYBOOK_ALERT + '/compromised_bank_checks'
56
57
  EP_PLAYBOOK_ALERT_CYBER_VULNERABILITY = EP_PLAYBOOK_ALERT + '/vulnerability'
57
58
  EP_PLAYBOOK_ALERT_DOMAIN_ABUSE = EP_PLAYBOOK_ALERT + '/domain_abuse'
58
59
  EP_PLAYBOOK_ALERT_GEOPOLITICS_FACILITY = EP_PLAYBOOK_ALERT + '/geopolitics_facility'
59
60
  EP_PLAYBOOK_ALERT_IDENTITY_NOVEL_EXPOSURES = EP_PLAYBOOK_ALERT + '/identity_novel_exposures'
61
+ EP_PLAYBOOK_ALERT_MALICIOUS_SITES = EP_PLAYBOOK_ALERT + '/malicious_sites'
62
+ EP_PLAYBOOK_ALERT_MALICIOUS_SITES_CREATE = EP_PLAYBOOK_ALERT_MALICIOUS_SITES + '/create'
60
63
  EP_PLAYBOOK_ALERT_THIRD_PARTY_RISK = EP_PLAYBOOK_ALERT + '/third_party_risk'
61
64
  EP_PLAYBOOK_ALERT_MALWARE_REPORT = EP_PLAYBOOK_ALERT + '/malware_report'
62
65
 
@@ -72,6 +75,8 @@ EP_ENTITY_LOOKUP = BASE_URL + '/entity-match/entity/{}'
72
75
  EP_LIST = BASE_URL + '/list'
73
76
  EP_CREATE_LIST = EP_LIST + '/create'
74
77
  EP_SEARCH_LIST = EP_LIST + '/search'
78
+ EP_LIST_ENTITIES_WITH_TAGS = EP_LIST + '/{}/entitiesWithTags'
79
+ EP_LIST_ENTITY_TAGS = EP_LIST + '/{}/entity/tags'
75
80
 
76
81
  ###############################################################################
77
82
  # SOAR Endpoints
@@ -158,6 +163,33 @@ EP_ASI_ASSETS_SEARCH = f'{EP_ASI_ASSETS}/_search'
158
163
  EP_ASI_EXPOSURES = f'{EP_ASI_PROJECTS}/{{}}/exposures'
159
164
  EP_ASI_EXPOSURES_BY_SIGNATURE = f'{EP_ASI_EXPOSURES}/{{}}'
160
165
 
166
+ ###############################################################################
167
+ # Recorded Future Sandbox API Base URLs and Endpoints
168
+ ###############################################################################
169
+ SANDBOX_API_VERSION = 'v0'
170
+
171
+ # Sandbox base URLs by region
172
+ SANDBOX_BASE_URLS = {
173
+ 'eu': 'https://sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
174
+ 'usa': 'https://us-sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
175
+ 'apj': 'https://apj-sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
176
+ 'public': 'https://tria.ge/api/' + SANDBOX_API_VERSION,
177
+ 'private': 'https://private.tria.ge/api/' + SANDBOX_API_VERSION,
178
+ }
179
+
180
+ # Sandbox Sample Endpoints (using {base_url} as placeholder)
181
+ EP_SANDBOX_SAMPLES = '{base_url}/samples'
182
+ EP_SANDBOX_SAMPLES_ID = '{base_url}/samples/{sample_id}'
183
+ EP_SANDBOX_SAMPLES_DOWNLOAD = '{base_url}/samples/{sample_id}/sample'
184
+ EP_SANDBOX_SAMPLES_SUMMARY = '{base_url}/samples/{sample_id}/summary'
185
+ EP_SANDBOX_SAMPLES_STATIC_REPORT = '{base_url}/samples/{sample_id}/reports/static'
186
+ EP_SANDBOX_SAMPLES_OVERVIEW = '{base_url}/samples/{sample_id}/overview.json'
187
+ EP_SANDBOX_SAMPLES_BEHAVIORAL = '{base_url}/samples/{sample_id}/{task_id}/report_triage.json'
188
+ EP_SANDBOX_SAMPLES_PROFILE = '{base_url}/samples/{sample_id}/profile'
189
+ EP_SANDBOX_SEARCH = '{base_url}/search'
190
+ EP_SANDBOX_PROFILES = '{base_url}/profiles'
191
+ EP_SANDBOX_PROFILES_ID = '{base_url}/profiles/{profile_id}'
192
+
161
193
  ################################################################################
162
194
  # Threat Map API Endpoints
163
195
  ################################################################################
@@ -38,7 +38,6 @@ class Evidence(RFBaseModel):
38
38
  timestamp: datetime
39
39
  description: str
40
40
  rule: str
41
- # TODO - temp fix until API team fixes/confirms behaviour of sightings
42
41
  sightings: int = 0
43
42
  mitigation: str
44
43
  level: int
@@ -11,6 +11,20 @@
11
11
  # accessed from any third party API. #
12
12
  ##############################################################################################
13
13
 
14
- from .entity_list import EntityList, ListEntity, ListInfoOut, ListStatusOut
14
+ from .constants import ListTagName
15
+ from .entity_list import (
16
+ EntityList,
17
+ ListEntity,
18
+ ListEntityWithTags,
19
+ ListInfoOut,
20
+ ListStatusOut,
21
+ )
15
22
  from .entity_list_mgr import EntityListMgr
16
23
  from .errors import ListApiError, ListResolutionError, ListStateError
24
+ from .models import (
25
+ EntityNotResolvedOperation,
26
+ ListEntityTag,
27
+ ReplaceEntityTagsOut,
28
+ TagsUnchangedOperation,
29
+ TagsUpdatedOperation,
30
+ )
@@ -0,0 +1,90 @@
1
+ ##################################### TERMS OF USE ###########################################
2
+ # The following code is provided for demonstration purpose only, and should not be used #
3
+ # without independent verification. Recorded Future makes no representations or warranties, #
4
+ # express, implied, statutory, or otherwise, regarding any aspect of this code or of the #
5
+ # information it may retrieve, and provides it both strictly “as-is” and without assuming #
6
+ # responsibility for any information it may retrieve. Recorded Future shall not be liable #
7
+ # for, and you assume all risk of using, the foregoing. By using this code, Customer #
8
+ # represents that it is solely responsible for having all necessary licenses, permissions, #
9
+ # rights, and/or consents to connect to third party APIs, and that it is solely responsible #
10
+ # for having all necessary licenses, permissions, rights, and/or consents to any data #
11
+ # accessed from any third party API. #
12
+ ##############################################################################################
13
+
14
+ from enum import Enum
15
+
16
+ IS_READY_INCREMENT = 5
17
+
18
+ ADD_OP = 'add'
19
+ REMOVE_OP = 'remove'
20
+ UNCHANGED_NAME = 'unchanged'
21
+ ERROR_NAME = 'error'
22
+
23
+
24
+ class ListTagName(str, Enum):
25
+ """The 57 predefined tag values accepted by `list/{id}/entity/tags`.
26
+
27
+ Provided for discoverability only - `update_entity_tags` accepts plain strings too,
28
+ and the API is the authority on which values are valid. `name` values here are the
29
+ API values (`tier1`), not display names (`Tier 1`).
30
+
31
+ See https://docs.recordedfuture.com/reference/lists-available-tags.
32
+ """
33
+
34
+ THIRD_PARTY = '3rd_party'
35
+ FOURTH_PARTY = '4th_party'
36
+ AVAILABILITY = 'availability'
37
+ BUSINESS_CONTINUITY = 'business_continuity'
38
+ C_SUITE = 'c_suite'
39
+ CEO = 'ceo'
40
+ CFO = 'cfo'
41
+ CLOUD = 'cloud'
42
+ CONFIDENTIAL_DATA = 'confidential_data'
43
+ CONFIDENTIALITY = 'confidentiality'
44
+ CONFIRMED = 'confirmed'
45
+ COO = 'coo'
46
+ CRITICAL = 'critical'
47
+ CRITICAL_INFRASTRUCTURE = 'critical_infrastructure'
48
+ CUSTOMER_DATA = 'customer_data'
49
+ CYBER_VENDOR = 'cyber_vendor'
50
+ DEVELOPMENT = 'development'
51
+ DMZ = 'dmz'
52
+ DORA = 'dora'
53
+ ECOMMERCE = 'ecommerce'
54
+ EOL = 'eol'
55
+ EOS = 'eos'
56
+ FALSE_NEGATIVE = 'false_negative'
57
+ FALSE_POSITIVE = 'false_positive'
58
+ FINANCIAL = 'financial'
59
+ FINISHED_GOODS = 'finished_goods'
60
+ GDPR = 'gdpr'
61
+ HIGH = 'high'
62
+ HIPAA = 'hipaa'
63
+ INFORMATION_AND_COMMUNICATION_TECHNOLOGY = 'information_and_communication_technology'
64
+ INTEGRITY = 'integrity'
65
+ INTERNAL = 'internal'
66
+ INTERNET_FACING = 'internet_facing'
67
+ ISO_27001 = 'iso_27001'
68
+ LOW = 'low'
69
+ M_AND_A = 'm_and_a'
70
+ MEDIUM = 'medium'
71
+ MONITORING = 'monitoring'
72
+ MOST_CRITICAL_SUPPLIER = 'most_critical_supplier'
73
+ NETWORK_CONNECTIVITY = 'network_connectivity'
74
+ NO_PATCH_AVAILABLE = 'no_patch_available'
75
+ PCI_DSS = 'pci_dss'
76
+ PII = 'pii'
77
+ POTENTIAL = 'potential'
78
+ PRODUCTION = 'production'
79
+ PROTECTED_HEALTH_INFORMATION = 'protected_health_information'
80
+ RAW_MATERIALS = 'raw_materials'
81
+ SOX = 'sox'
82
+ SUBSIDIARY = 'subsidiary'
83
+ TEMP_INCIDENT = 'temp_incident'
84
+ TIER0 = 'tier0'
85
+ TIER1 = 'tier1'
86
+ TIER2 = 'tier2'
87
+ TIER3 = 'tier3'
88
+ TRUE_NEGATIVE = 'true_negative'
89
+ TRUE_POSITIVE = 'true_positive'
90
+ UNPATCHED = 'unpatched'
@@ -22,7 +22,7 @@ from typing_extensions import Doc
22
22
 
23
23
  from ..common_models import IdNameType, RFBaseModel
24
24
  from ..constants import TIMESTAMP_STR
25
- from ..endpoints import EP_LIST
25
+ from ..endpoints import EP_LIST, EP_LIST_ENTITIES_WITH_TAGS, EP_LIST_ENTITY_TAGS
26
26
  from ..entity_match import EntityMatchMgr, MatchApiError
27
27
  from ..helpers import debug_call
28
28
  from ..helpers.helpers import connection_exceptions
@@ -31,9 +31,13 @@ from .constants import ADD_OP, ERROR_NAME, IS_READY_INCREMENT, REMOVE_OP, UNCHAN
31
31
  from .errors import ListApiError
32
32
  from .models import (
33
33
  AddEntityRequestModel,
34
+ EntityNotResolvedOperation,
34
35
  ListEntityOperationResponse,
36
+ ListEntityTag,
35
37
  OwnerOrganisationDetails,
36
38
  RemoveEntityRequestModel,
39
+ ReplaceEntityTagsIn,
40
+ ReplaceEntityTagsOut,
37
41
  )
38
42
 
39
43
 
@@ -85,6 +89,17 @@ class ListEntity(RFBaseModel):
85
89
  )
86
90
 
87
91
 
92
+ class ListEntityWithTags(ListEntity):
93
+ """Validate data received from `/{listId}/entitiesWithTags` endpoint.
94
+
95
+ Identical to `ListEntity`, with the addition of the list-specific `tags` assigned to the
96
+ entity. Tags are predefined and only populated for lists whose type has tagging enabled -
97
+ see `ListEntityTag`.
98
+ """
99
+
100
+ tags: list[ListEntityTag] = []
101
+
102
+
88
103
  class EntityList(ListInfoOut):
89
104
  """Validate data received from `/create` endpoint."""
90
105
 
@@ -273,6 +288,99 @@ class EntityList(ListInfoOut):
273
288
  response = self.rf_client.request('get', url)
274
289
  return [ListEntity.model_validate(entity) for entity in response.json()]
275
290
 
291
+ @debug_call
292
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
293
+ def entities_with_tags(
294
+ self,
295
+ ) -> Annotated[
296
+ list[ListEntityWithTags],
297
+ Doc('Response from the `list/{id}/entitiesWithTags` endpoint.'),
298
+ ]:
299
+ """Get entities for a list, together with their list-specific tags.
300
+
301
+ Tags are **not** free-text. They are a fixed set of 57 predefined values, so arbitrary
302
+ tag strings cannot be expected. See
303
+ https://docs.recordedfuture.com/reference/lists-available-tags for the full list of valid
304
+ tags, their display names, and their API values.
305
+
306
+ `tags` is only populated for lists whose type has tagging enabled server-side (currently
307
+ Third-Parties Watch Lists). A list of any other type returns its entities with an empty
308
+ `tags` list rather than an error, so an empty `tags` does not indicate a failure. A list
309
+ whose type supports tagging but which has not yet been migrated server-side responds 400
310
+ instead.
311
+
312
+ Endpoint:
313
+ `list/{id}/entitiesWithTags`
314
+
315
+ Raises:
316
+ ListApiError: If connection error occurs. The API responds 400 if the list's type
317
+ supports tagging but the list has not been migrated to support tags, and 404 if
318
+ the list does not exist.
319
+ """
320
+ url = EP_LIST_ENTITIES_WITH_TAGS.format(self.id_)
321
+ response = self.rf_client.request('get', url)
322
+ return [ListEntityWithTags.model_validate(entity) for entity in response.json()]
323
+
324
+ @debug_call
325
+ @validate_call
326
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
327
+ def update_entity_tags(
328
+ self,
329
+ entity: Annotated[
330
+ str | tuple[str, str], Doc('ID or (name, type) tuple of the entity to tag.')
331
+ ],
332
+ tags: Annotated[
333
+ list[str],
334
+ Doc('Complete set of tags to apply. Replaces every tag already on the entity.'),
335
+ ],
336
+ ) -> Annotated[
337
+ ReplaceEntityTagsOut, Doc('Response from the `list/{id}/entity/tags` endpoint.')
338
+ ]:
339
+ """Replace all tags on an entity in a list.
340
+
341
+ This **replaces** rather than appends: `tags` becomes the entity's complete tag set,
342
+ and passing an empty list clears every tag from it.
343
+
344
+ Tags are **not** free-text. They are a fixed set of 57 predefined values - pass them as
345
+ plain strings or as `ListTagName` members. Invalid names are rejected by the API with a
346
+ 400. See https://docs.recordedfuture.com/reference/lists-available-tags for the full
347
+ list.
348
+
349
+ Tagging is only enabled for lists whose type supports it (currently Third-Parties Watch
350
+ Lists); other list types respond 403.
351
+
352
+ If `entity` is a `(name, type)` tuple that cannot be resolved, the returned
353
+ `operation` is an `EntityNotResolvedOperation` rather than an exception, matching
354
+ `add` and `remove`.
355
+
356
+ Endpoint:
357
+ `list/{id}/entity/tags`
358
+
359
+ Raises:
360
+ ValidationError: if any supplied parameter is of incorrect type.
361
+ ListApiError: If connection error occurs. The API responds 400 for an invalid tag
362
+ name, entity, or list, 403 if tag updates are not enabled for the list type,
363
+ and 404 if the entity is not on the list.
364
+ """
365
+ if isinstance(entity, str):
366
+ resolved_entity_id = entity
367
+ else:
368
+ resolved_entity = self.match_mgr.resolve_entity_id(entity[0], entity_type=entity[1])
369
+ if not resolved_entity.is_found:
370
+ return ReplaceEntityTagsOut(
371
+ operation=EntityNotResolvedOperation(
372
+ status='entity_not_resolved', message=str(resolved_entity.content)
373
+ )
374
+ )
375
+ resolved_entity_id = resolved_entity.content.id_
376
+
377
+ request_body = {'entity': {'id': resolved_entity_id}, 'tags': tags}
378
+ ReplaceEntityTagsIn.model_validate(request_body)
379
+
380
+ url = EP_LIST_ENTITY_TAGS.format(self.id_)
381
+ response = self.rf_client.request('post', url, data=request_body)
382
+ return ReplaceEntityTagsOut.model_validate(response.json())
383
+
276
384
  @debug_call
277
385
  @connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
278
386
  def text_entries(
@@ -12,15 +12,33 @@
12
12
  ##############################################################################################
13
13
 
14
14
 
15
+ from datetime import datetime
16
+ from typing import Literal
17
+
15
18
  from pydantic import Field
16
19
 
17
- from ..common_models import RFBaseModel
20
+ from ..common_models import IdName, RFBaseModel
18
21
 
19
22
 
20
23
  class EntityID(RFBaseModel):
21
24
  id_: str = Field(alias='id')
22
25
 
23
26
 
27
+ class ListEntityTag(IdName):
28
+ """Validate a single tag received from `/{listId}/entitiesWithTags` endpoint.
29
+
30
+ Tags are **not** free-text. They are a fixed set of 57 predefined values, populated only for
31
+ lists whose type has tagging enabled (currently Third-Parties Watch Lists), so arbitrary tag
32
+ strings cannot be supplied or expected. `id` always has the form
33
+ `enum:EntityListTag:<name>`, for example `enum:EntityListTag:tier1`.
34
+
35
+ `name` is the tag's API value (`tier1`), not its display name (`Tier 1`).
36
+
37
+ See https://docs.recordedfuture.com/reference/lists-available-tags for the full list of
38
+ valid tags.
39
+ """
40
+
41
+
24
42
  class Organisation(RFBaseModel):
25
43
  organisation_id: str
26
44
  organisation_name: str
@@ -84,3 +102,67 @@ class ListEntityOperationResponse(RFBaseModel):
84
102
  """Validate data received from `/{listId}/entity/remove` endpoint."""
85
103
 
86
104
  result: str
105
+
106
+
107
+ class TagsUpdatedOperation(RFBaseModel):
108
+ """Tags on the entity were changed."""
109
+
110
+ status: Literal['tags_updated']
111
+ tags_before: list[str]
112
+ tags_after: list[str]
113
+ tags_added: list[str]
114
+ tags_removed: list[str]
115
+ updated: datetime
116
+
117
+
118
+ class TagsUnchangedOperation(RFBaseModel):
119
+ """Requested tags already matched the entity's tags, so nothing changed."""
120
+
121
+ status: Literal['tags_unchanged']
122
+ current_tags: list[str]
123
+ message: str
124
+
125
+
126
+ class EntityNotResolvedOperation(RFBaseModel):
127
+ """A `(name, type)` tuple could not be resolved to an entity ID.
128
+
129
+ Produced by psengine, never returned by the API. Mirrors how `add` and `remove`
130
+ surface a failed lookup in their result rather than raising.
131
+ """
132
+
133
+ status: Literal['entity_not_resolved']
134
+ message: str
135
+
136
+
137
+ class ReplaceEntityTagsIn(RFBaseModel):
138
+ """Validate data sent to `/{listId}/entity/tags` endpoint."""
139
+
140
+ entity: EntityID
141
+ tags: list[str]
142
+
143
+
144
+ class ReplaceEntityTagsOut(RFBaseModel):
145
+ """Validate data received from `/{listId}/entity/tags` endpoint.
146
+
147
+ `operation` is discriminated on `status`, so each variant only exposes the fields the
148
+ API actually returns for it.
149
+ """
150
+
151
+ entity_id: str | None = None
152
+ operation: TagsUpdatedOperation | TagsUnchangedOperation | EntityNotResolvedOperation = Field(
153
+ discriminator='status'
154
+ )
155
+
156
+ @property
157
+ def changed(self) -> bool:
158
+ """Whether the call actually changed the entity's tags."""
159
+ return self.operation.status == 'tags_updated'
160
+
161
+ @property
162
+ def current_tags(self) -> list[str] | None:
163
+ """The entity's tags after the call, or None if the entity was not resolved."""
164
+ if isinstance(self.operation, TagsUpdatedOperation):
165
+ return self.operation.tags_after
166
+ if isinstance(self.operation, TagsUnchangedOperation):
167
+ return self.operation.current_tags
168
+ return None