psengine 2.8.4__tar.gz → 2.9.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 (176) hide show
  1. {psengine-2.8.4 → psengine-2.9.0}/PKG-INFO +3 -2
  2. {psengine-2.8.4 → psengine-2.9.0}/README.md +1 -0
  3. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/note_mgr.py +5 -0
  4. {psengine-2.8.4 → psengine-2.9.0}/psengine/config/config.py +33 -13
  5. {psengine-2.8.4 → psengine-2.9.0}/psengine/constants.py +2 -0
  6. {psengine-2.8.4 → psengine-2.9.0}/psengine/endpoints.py +27 -0
  7. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/models/soar.py +0 -1
  8. {psengine-2.8.4 → psengine-2.9.0}/psengine/helpers/helpers.py +38 -5
  9. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/malware_intel.py +1 -1
  10. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/models.py +2 -2
  11. psengine-2.9.0/psengine/sandbox/__init__.py +29 -0
  12. psengine-2.9.0/psengine/sandbox/client.py +225 -0
  13. psengine-2.9.0/psengine/sandbox/constants.py +25 -0
  14. psengine-2.9.0/psengine/sandbox/errors.py +103 -0
  15. psengine-2.9.0/psengine/sandbox/models/__init__.py +13 -0
  16. psengine-2.9.0/psengine/sandbox/models/analysis.py +70 -0
  17. psengine-2.9.0/psengine/sandbox/models/behavioral_report.py +212 -0
  18. psengine-2.9.0/psengine/sandbox/models/overview_report.py +185 -0
  19. psengine-2.9.0/psengine/sandbox/models/static_report.py +144 -0
  20. psengine-2.9.0/psengine/sandbox/sandbox.py +493 -0
  21. psengine-2.9.0/psengine/sandbox/sandbox_mgr.py +1462 -0
  22. psengine-2.9.0/pyproject.toml +121 -0
  23. psengine-2.8.4/pyproject.toml → psengine-2.9.0/pyproject.toml.orig +5 -5
  24. psengine-2.8.4/psengine/py.typed +0 -0
  25. {psengine-2.8.4 → psengine-2.9.0}/psengine/__init__.py +0 -0
  26. {psengine-2.8.4 → psengine-2.9.0}/psengine/_sdk_id.py +0 -0
  27. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/__init__.py +0 -0
  28. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/constants.py +0 -0
  29. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/errors.py +0 -0
  30. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/helpers.py +0 -0
  31. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/markdown.py +0 -0
  32. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/models.py +0 -0
  33. {psengine-2.8.4 → psengine-2.9.0}/psengine/analyst_notes/note.py +0 -0
  34. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/__init__.py +0 -0
  35. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/asi.py +0 -0
  36. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/asi_mgr.py +0 -0
  37. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/client.py +0 -0
  38. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/constants.py +0 -0
  39. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/errors.py +0 -0
  40. {psengine-2.8.4 → psengine-2.9.0}/psengine/asi/models.py +0 -0
  41. {psengine-2.8.4 → psengine-2.9.0}/psengine/base_http_client.py +0 -0
  42. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/__init__.py +0 -0
  43. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/classic_alert.py +0 -0
  44. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/classic_alert_mgr.py +0 -0
  45. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/constants.py +0 -0
  46. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/errors.py +0 -0
  47. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/helpers.py +0 -0
  48. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/markdown/__init__.py +0 -0
  49. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/markdown/markdown.py +0 -0
  50. {psengine-2.8.4 → psengine-2.9.0}/psengine/classic_alerts/models.py +0 -0
  51. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/__init__.py +0 -0
  52. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/collective_insights.py +0 -0
  53. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/constants.py +0 -0
  54. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/errors.py +0 -0
  55. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/insight.py +0 -0
  56. {psengine-2.8.4 → psengine-2.9.0}/psengine/collective_insights/models.py +0 -0
  57. {psengine-2.8.4 → psengine-2.9.0}/psengine/common_models.py +0 -0
  58. {psengine-2.8.4 → psengine-2.9.0}/psengine/config/__init__.py +0 -0
  59. {psengine-2.8.4 → psengine-2.9.0}/psengine/config/errors.py +0 -0
  60. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/__init__.py +0 -0
  61. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/detection_mgr.py +0 -0
  62. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/detection_rule.py +0 -0
  63. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/errors.py +0 -0
  64. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/helpers.py +0 -0
  65. {psengine-2.8.4 → psengine-2.9.0}/psengine/detection/models.py +0 -0
  66. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/__init__.py +0 -0
  67. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/constants.py +0 -0
  68. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/errors.py +0 -0
  69. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/lookup.py +0 -0
  70. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/lookup_mgr.py +0 -0
  71. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/models/__init__.py +0 -0
  72. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/models/base_enriched_entity.py +0 -0
  73. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/models/lookup.py +0 -0
  74. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/soar.py +0 -0
  75. {psengine-2.8.4 → psengine-2.9.0}/psengine/enrich/soar_mgr.py +0 -0
  76. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/__init__.py +0 -0
  77. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/constants.py +0 -0
  78. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/entity_list.py +0 -0
  79. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/entity_list_mgr.py +0 -0
  80. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/errors.py +0 -0
  81. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_lists/models.py +0 -0
  82. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_match/__init__.py +0 -0
  83. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_match/entity_match.py +0 -0
  84. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_match/entity_match_mgr.py +0 -0
  85. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_match/errors.py +0 -0
  86. {psengine-2.8.4 → psengine-2.9.0}/psengine/entity_match/models.py +0 -0
  87. {psengine-2.8.4 → psengine-2.9.0}/psengine/errors.py +0 -0
  88. {psengine-2.8.4 → psengine-2.9.0}/psengine/fusion/__init__.py +0 -0
  89. {psengine-2.8.4 → psengine-2.9.0}/psengine/fusion/errors.py +0 -0
  90. {psengine-2.8.4 → psengine-2.9.0}/psengine/fusion/fusion_mgr.py +0 -0
  91. {psengine-2.8.4 → psengine-2.9.0}/psengine/fusion/models.py +0 -0
  92. {psengine-2.8.4 → psengine-2.9.0}/psengine/helpers/__init__.py +0 -0
  93. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/__init__.py +0 -0
  94. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/constants.py +0 -0
  95. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/errors.py +0 -0
  96. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/identity.py +0 -0
  97. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/identity_mgr.py +0 -0
  98. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/models/__init__.py +0 -0
  99. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/models/common_models.py +0 -0
  100. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/models/detections.py +0 -0
  101. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/models/incident_report.py +0 -0
  102. {psengine-2.8.4 → psengine-2.9.0}/psengine/identity/models/lookup.py +0 -0
  103. {psengine-2.8.4 → psengine-2.9.0}/psengine/links/__init__.py +0 -0
  104. {psengine-2.8.4 → psengine-2.9.0}/psengine/links/errors.py +0 -0
  105. {psengine-2.8.4 → psengine-2.9.0}/psengine/links/links.py +0 -0
  106. {psengine-2.8.4 → psengine-2.9.0}/psengine/links/links_mgr.py +0 -0
  107. {psengine-2.8.4 → psengine-2.9.0}/psengine/links/models.py +0 -0
  108. {psengine-2.8.4 → psengine-2.9.0}/psengine/logger/__init__.py +0 -0
  109. {psengine-2.8.4 → psengine-2.9.0}/psengine/logger/constants.py +0 -0
  110. {psengine-2.8.4 → psengine-2.9.0}/psengine/logger/errors.py +0 -0
  111. {psengine-2.8.4 → psengine-2.9.0}/psengine/logger/rf_logger.py +0 -0
  112. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/__init__.py +0 -0
  113. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/auto_sigma_mgr.py +0 -0
  114. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/auto_yara_mgr.py +0 -0
  115. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/constants.py +0 -0
  116. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/errors.py +0 -0
  117. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/helpers.py +0 -0
  118. {psengine-2.8.4 → psengine-2.9.0}/psengine/malware_intel/malware_intel_mgr.py +0 -0
  119. {psengine-2.8.4 → psengine-2.9.0}/psengine/markdown/__init__.py +0 -0
  120. {psengine-2.8.4 → psengine-2.9.0}/psengine/markdown/markdown.py +0 -0
  121. {psengine-2.8.4 → psengine-2.9.0}/psengine/markdown/markdown_strings.py +0 -0
  122. {psengine-2.8.4 → psengine-2.9.0}/psengine/markdown/models.py +0 -0
  123. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/__init__.py +0 -0
  124. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/constants.py +0 -0
  125. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/errors.py +0 -0
  126. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/helpers.py +0 -0
  127. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/mappings.py +0 -0
  128. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/__init__.py +0 -0
  129. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown.py +0 -0
  130. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_code_repo.py +0 -0
  131. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py +0 -0
  132. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_domain_abuse.py +0 -0
  133. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_geopolitics_facility.py +0 -0
  134. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_identity_exposure.py +0 -0
  135. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_malware_report.py +0 -0
  136. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/markdown/markdown_third_party_risk.py +0 -0
  137. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/__init__.py +0 -0
  138. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/common_models.py +0 -0
  139. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/panel_log.py +0 -0
  140. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/panel_status.py +0 -0
  141. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_code_repo_leak.py +0 -0
  142. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_cyber_vulnerability.py +0 -0
  143. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_domain_abuse.py +0 -0
  144. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_geopolitics_facility.py +0 -0
  145. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_identity_exposures.py +0 -0
  146. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_malware_report.py +0 -0
  147. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/pba_third_party_risk.py +0 -0
  148. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/models/search_endpoint.py +0 -0
  149. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/pa_category.py +0 -0
  150. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/playbook_alert_mgr.py +0 -0
  151. {psengine-2.8.4 → psengine-2.9.0}/psengine/playbook_alerts/playbook_alerts.py +0 -0
  152. {psengine-2.8.4 → psengine-2.9.0}/psengine/rf_client.py +0 -0
  153. {psengine-2.8.4 → psengine-2.9.0}/psengine/risk_history/__init__.py +0 -0
  154. {psengine-2.8.4 → psengine-2.9.0}/psengine/risk_history/errors.py +0 -0
  155. {psengine-2.8.4 → psengine-2.9.0}/psengine/risk_history/models.py +0 -0
  156. {psengine-2.8.4 → psengine-2.9.0}/psengine/risk_history/risk_history_mgr.py +0 -0
  157. {psengine-2.8.4 → psengine-2.9.0}/psengine/risklists/__init__.py +0 -0
  158. {psengine-2.8.4 → psengine-2.9.0}/psengine/risklists/constants.py +0 -0
  159. {psengine-2.8.4 → psengine-2.9.0}/psengine/risklists/errors.py +0 -0
  160. {psengine-2.8.4 → psengine-2.9.0}/psengine/risklists/models.py +0 -0
  161. {psengine-2.8.4 → psengine-2.9.0}/psengine/risklists/risklist_mgr.py +0 -0
  162. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/__init__.py +0 -0
  163. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/base_stix_entity.py +0 -0
  164. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/complex_entity.py +0 -0
  165. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/constants.py +0 -0
  166. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/enriched_indicator.py +0 -0
  167. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/errors.py +0 -0
  168. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/helpers.py +0 -0
  169. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/rf_bundle.py +0 -0
  170. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/simple_entity.py +0 -0
  171. {psengine-2.8.4 → psengine-2.9.0}/psengine/stix2/util.py +0 -0
  172. {psengine-2.8.4 → psengine-2.9.0}/psengine/threat_maps/__init__.py +0 -0
  173. {psengine-2.8.4 → psengine-2.9.0}/psengine/threat_maps/errors.py +0 -0
  174. {psengine-2.8.4 → psengine-2.9.0}/psengine/threat_maps/models.py +0 -0
  175. {psengine-2.8.4 → psengine-2.9.0}/psengine/threat_maps/threat_map.py +0 -0
  176. {psengine-2.8.4 → psengine-2.9.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.4
3
+ Version: 2.9.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
@@ -23,7 +23,7 @@ Requires-Dist: idna>=3.15
23
23
  Requires-Dist: jsonpath-ng>=1.5.3,<=1.8.0
24
24
  Requires-Dist: stix2~=3.0.2
25
25
  Requires-Dist: python-dateutil>=2.7.0
26
- Requires-Dist: more-itertools>=9.0.0,<=11.0.2
26
+ Requires-Dist: more-itertools>=9.0.0,<=11.1.0
27
27
  Requires-Dist: pydantic>=2.7,<3.0.0
28
28
  Requires-Dist: pydantic-settings[toml]>=2.14.2,<2.15.0
29
29
  Requires-Python: >=3.10, <3.15
@@ -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
 
@@ -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
  #####################
@@ -158,6 +158,33 @@ EP_ASI_ASSETS_SEARCH = f'{EP_ASI_ASSETS}/_search'
158
158
  EP_ASI_EXPOSURES = f'{EP_ASI_PROJECTS}/{{}}/exposures'
159
159
  EP_ASI_EXPOSURES_BY_SIGNATURE = f'{EP_ASI_EXPOSURES}/{{}}'
160
160
 
161
+ ###############################################################################
162
+ # Recorded Future Sandbox API Base URLs and Endpoints
163
+ ###############################################################################
164
+ SANDBOX_API_VERSION = 'v0'
165
+
166
+ # Sandbox base URLs by region
167
+ SANDBOX_BASE_URLS = {
168
+ 'eu': 'https://sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
169
+ 'usa': 'https://us-sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
170
+ 'apj': 'https://apj-sandbox.recordedfuture.com/api/' + SANDBOX_API_VERSION,
171
+ 'public': 'https://tria.ge/api/' + SANDBOX_API_VERSION,
172
+ 'private': 'https://private.tria.ge/api/' + SANDBOX_API_VERSION,
173
+ }
174
+
175
+ # Sandbox Sample Endpoints (using {base_url} as placeholder)
176
+ EP_SANDBOX_SAMPLES = '{base_url}/samples'
177
+ EP_SANDBOX_SAMPLES_ID = '{base_url}/samples/{sample_id}'
178
+ EP_SANDBOX_SAMPLES_DOWNLOAD = '{base_url}/samples/{sample_id}/sample'
179
+ EP_SANDBOX_SAMPLES_SUMMARY = '{base_url}/samples/{sample_id}/summary'
180
+ EP_SANDBOX_SAMPLES_STATIC_REPORT = '{base_url}/samples/{sample_id}/reports/static'
181
+ EP_SANDBOX_SAMPLES_OVERVIEW = '{base_url}/samples/{sample_id}/overview.json'
182
+ EP_SANDBOX_SAMPLES_BEHAVIORAL = '{base_url}/samples/{sample_id}/{task_id}/report_triage.json'
183
+ EP_SANDBOX_SAMPLES_PROFILE = '{base_url}/samples/{sample_id}/profile'
184
+ EP_SANDBOX_SEARCH = '{base_url}/search'
185
+ EP_SANDBOX_PROFILES = '{base_url}/profiles'
186
+ EP_SANDBOX_PROFILES_ID = '{base_url}/profiles/{profile_id}'
187
+
161
188
  ################################################################################
162
189
  # Threat Map API Endpoints
163
190
  ################################################################################
@@ -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
@@ -486,13 +486,46 @@ class Validators:
486
486
  @staticmethod
487
487
  def convert_str_to_list(
488
488
  value: Annotated[str | list | None, Doc('String or list to convert.')],
489
- ) -> Annotated[list | None, Doc('Converted list with None values removed.')]:
490
- """Convert value from str to list and remove None values."""
491
- if value:
492
- value = value if isinstance(value, list) else [value]
493
- return [v for v in value if v is not None]
489
+ ) -> Annotated[list | None, Doc('Converted list, stripped and cleaned.')]:
490
+ """Convert value from str to list, strip strings, drop None/empty strings.
491
+
492
+ Non-string items in a list are passed through unchanged so enum-typed
493
+ fields (e.g. `list[DomainTypes]`) keep their values.
494
+ """
495
+ if value is None:
496
+ return None
497
+ if isinstance(value, str):
498
+ value = value.strip()
499
+ return [value] if value else []
500
+ if isinstance(value, list):
501
+ result = []
502
+ for v in value:
503
+ if v is None:
504
+ continue
505
+ if isinstance(v, str):
506
+ stripped = v.strip()
507
+ if not stripped:
508
+ continue
509
+ result.append(stripped)
510
+ else:
511
+ result.append(v)
512
+ return result
494
513
  return value
495
514
 
515
+ @staticmethod
516
+ def none_to_empty_list(
517
+ value: Annotated[list | None, Doc('A list or `null`/empty value from the API.')],
518
+ ) -> Annotated[list, Doc('The value, or [] when it is None/empty.')]:
519
+ """Coerce a `null`/empty API value to a list so the field is always iterable."""
520
+ return value or []
521
+
522
+ @staticmethod
523
+ def empty_str_to_none(
524
+ value: Annotated[str | None, Doc('A value that may be an empty string.')],
525
+ ) -> Annotated[str | None, Doc('None if the value is an empty string, else the value.')]:
526
+ """Normalise an empty string (`''`) to None; pass everything else through."""
527
+ return None if value == '' else value
528
+
496
529
  @staticmethod
497
530
  def convert_relative_time(
498
531
  input_time: Annotated[str, Doc("Relative time string, e.g., '7d', '3h'.")],
@@ -138,7 +138,7 @@ class AutoSigmaJobOut(RFBaseModel):
138
138
  end_date: str | None = None
139
139
  n_matched_hashes: int | None = None
140
140
  family_counts: dict[str, int] = {}
141
- patterns: list[SigmaPatternItem] = Field(default_factory=list)
141
+ patterns: list[SigmaPatternItem] = []
142
142
 
143
143
  def __str__(self):
144
144
  created = self.created.strftime(TIMESTAMP_STR) if self.created else 'N/A'
@@ -319,13 +319,13 @@ class YaraJob(RFBaseModel):
319
319
  class SigmaPatternStats(RFBaseModel):
320
320
  n_hashes: int | float | None = None
321
321
  overlap: int | float | None = None
322
- family_counts: dict[str, int | float] = Field(default_factory=dict)
322
+ family_counts: dict[str, int | float] = {}
323
323
 
324
324
 
325
325
  class SigmaPatternItem(RFBaseModel):
326
326
  image: str | None = None
327
327
  cmd_pattern: str | None = None
328
- matched_cmds: list[str] = Field(default_factory=list)
328
+ matched_cmds: list[str] = []
329
329
  stats: SigmaPatternStats | None = None
330
330
 
331
331
 
@@ -0,0 +1,29 @@
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 .sandbox import (
15
+ BehavioralReport,
16
+ BehavioralReportFailure,
17
+ BehavioralReportsResult,
18
+ OverviewReport,
19
+ Profile,
20
+ ProfileDeleteOut,
21
+ ProfileOptions,
22
+ ProfileUpdateOut,
23
+ Sample,
24
+ SampleDeleteOut,
25
+ SampleProfileOut,
26
+ SampleTasks,
27
+ StaticAnalysisReport,
28
+ )
29
+ from .sandbox_mgr import SandboxChoice, SandboxMgr, SandboxSubset
@@ -0,0 +1,225 @@
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
+ import re
15
+ from typing import Annotated
16
+
17
+ from pydantic import Field, validate_call
18
+ from requests.exceptions import JSONDecodeError
19
+ from requests.models import Response
20
+ from typing_extensions import Doc
21
+
22
+ from ..base_http_client import BaseHTTPClient
23
+ from ..constants import SANDBOX_TOKEN_VALIDATION_REGEX
24
+ from ..helpers import debug_call
25
+ from .constants import DEFAULT_PAGE_LIMIT, MAXIMUM_SAMPLES, SAMPLES_PER_PAGE
26
+
27
+
28
+ @validate_call
29
+ def is_api_token_format_valid(
30
+ token: Annotated[str, Doc('A Recorded Future API token.')],
31
+ ) -> Annotated[bool, Doc('True if the token format is valid, False otherwise.')]:
32
+ """Check if the token format is valid.
33
+
34
+ The function performs a simple regex check but does not validate the token against the API.
35
+ """
36
+ return re.fullmatch(SANDBOX_TOKEN_VALIDATION_REGEX, token) is not None
37
+
38
+
39
+ class SandboxClient(BaseHTTPClient):
40
+ """Recorded Future Sandbox API client."""
41
+
42
+ def __init__(
43
+ self,
44
+ api_token: Annotated[
45
+ str | None,
46
+ Doc('The Sandbox API token. Defaults to RF_SANDBOX_TOKEN environment variable.'),
47
+ ] = None,
48
+ http_proxy: Annotated[str, Doc('An HTTP proxy URL.')] = None,
49
+ https_proxy: Annotated[str, Doc('An HTTPS proxy URL.')] = None,
50
+ verify: Annotated[
51
+ str | bool,
52
+ Doc('An SSL verification flag or path to CA bundle.'),
53
+ ] = None,
54
+ auth: Annotated[tuple[str, str], Doc('Basic Auth credentials.')] = None,
55
+ cert: Annotated[str | tuple[str, str] | None, Doc('Client certificates.')] = None,
56
+ timeout: Annotated[int, Doc('A request timeout. Defaults to 120.')] = None,
57
+ retries: Annotated[int, Doc('A number of retries. Defaults to 5.')] = None,
58
+ backoff_factor: Annotated[int, Doc('A backoff factor. Defaults to 1.')] = None,
59
+ status_forcelist: Annotated[
60
+ list, Doc('A list of status codes to force a retry. Defaults to [502, 503, 504].')
61
+ ] = None,
62
+ pool_max_size: Annotated[
63
+ int, Doc('The maximum number of connections in the pool. Defaults to 120.')
64
+ ] = None,
65
+ ):
66
+ """Recorded Future HTTP API client."""
67
+ super().__init__(
68
+ http_proxy=http_proxy,
69
+ https_proxy=https_proxy,
70
+ verify=verify,
71
+ auth=auth,
72
+ cert=cert,
73
+ timeout=timeout,
74
+ retries=retries,
75
+ backoff_factor=backoff_factor,
76
+ status_forcelist=status_forcelist,
77
+ pool_max_size=pool_max_size,
78
+ )
79
+
80
+ self._api_token = api_token or self.config.sandbox_token.get_secret_value()
81
+ if not self._api_token:
82
+ raise ValueError('Missing Recorded Future Sandbox API token.')
83
+ if not is_api_token_format_valid(self._api_token):
84
+ raise ValueError(
85
+ f'Invalid Sandbox API token: must match regex {SANDBOX_TOKEN_VALIDATION_REGEX}'
86
+ )
87
+
88
+ @debug_call
89
+ @validate_call
90
+ def request(
91
+ self,
92
+ method: Annotated[
93
+ str, Doc('An HTTP method, one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.')
94
+ ],
95
+ url: Annotated[str, Doc('A URL to make the request to.')],
96
+ data: Annotated[dict | list[dict] | bytes | None, Doc('A request body.')] = None,
97
+ *,
98
+ params: Annotated[dict | None, Doc('HTTP query parameters.')] = None,
99
+ headers: Annotated[
100
+ dict | None,
101
+ Doc('If specified, it overrides default headers and does not set the token.'),
102
+ ] = None,
103
+ content_type_header: Annotated[
104
+ str | None, Doc('Content-Type header value.')
105
+ ] = 'application/json',
106
+ **kwargs,
107
+ ) -> Annotated[Response, Doc('A requests.Response object.')]:
108
+ """Perform an HTTP request.
109
+
110
+ Raises:
111
+ ValidationError: If method is not one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.
112
+ """
113
+ headers = headers or self._prepare_headers(content_type_header)
114
+
115
+ return self.call(
116
+ method=method,
117
+ url=url,
118
+ headers=headers,
119
+ data=data,
120
+ params=params,
121
+ **kwargs,
122
+ )
123
+
124
+ @debug_call
125
+ @validate_call
126
+ def request_paged(
127
+ self,
128
+ method: Annotated[
129
+ str, Doc('An HTTP method, one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.')
130
+ ],
131
+ url: Annotated[str, Doc('A URL to make the request to.')],
132
+ data: Annotated[dict | list[dict] | bytes | None, Doc('A request body.')] = None,
133
+ *,
134
+ params: Annotated[dict | None, Doc('HTTP query parameters.')] = None,
135
+ headers: Annotated[
136
+ dict | None,
137
+ Doc('If specified, it overrides default headers and does not set the token.'),
138
+ ] = None,
139
+ content_type_header: Annotated[
140
+ str | None, Doc('Content-Type header value.')
141
+ ] = 'application/json',
142
+ max_results: int = DEFAULT_PAGE_LIMIT,
143
+ results_per_page: Annotated[
144
+ int | None, Doc('The number of samples per page for pagination.')
145
+ ] = Field(ge=1, le=MAXIMUM_SAMPLES, default=SAMPLES_PER_PAGE),
146
+ **kwargs,
147
+ ) -> Annotated[list, Doc('Result rows accumulated across all fetched pages.')]:
148
+ """Perform a paged HTTP request, following `next` offsets until `max_results` is reached.
149
+
150
+ Raises:
151
+ ValidationError: If method is not one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.
152
+ """
153
+ headers = headers or self._prepare_headers(content_type_header)
154
+ # Copy so our `limit`/`offset` bookkeeping never mutates the caller's dict.
155
+ params = dict(params or {})
156
+ all_results: list = []
157
+
158
+ # Trim `limit` to remaining headroom so the last page doesn't overfetch.
159
+ params['limit'] = min(results_per_page, max_results - len(all_results))
160
+ response = self.request(
161
+ method=method,
162
+ url=url,
163
+ headers=headers,
164
+ data=data,
165
+ params=params,
166
+ **kwargs,
167
+ )
168
+
169
+ try:
170
+ json_response = response.json()
171
+ except JSONDecodeError:
172
+ self.log.debug(f'Paged request does not contain valid JSON:\n{response.text}')
173
+ raise
174
+
175
+ try:
176
+ page = json_response['data'] or []
177
+ except KeyError:
178
+ self.log.debug(f'Paged request does not contain `data` JSON key:\n{response.text}')
179
+ raise
180
+
181
+ all_results.extend(page)
182
+ offset = json_response.get('next')
183
+ prev_offset = None
184
+
185
+ while offset and offset != prev_offset and len(all_results) < max_results:
186
+ params['offset'] = offset
187
+ params['limit'] = min(results_per_page, max_results - len(all_results))
188
+ response = self.request(
189
+ method=method,
190
+ url=url,
191
+ headers=headers,
192
+ data=data,
193
+ params=params,
194
+ **kwargs,
195
+ )
196
+ json_response = response.json()
197
+ page = json_response.get('data', [])
198
+
199
+ if not page:
200
+ # The last page is always empty
201
+ self.log.debug('Paged request returned an empty `data` page; stopping.')
202
+ break
203
+
204
+ all_results.extend(page)
205
+ prev_offset = offset
206
+ offset = json_response.get('next')
207
+
208
+ return all_results[:max_results]
209
+
210
+ def _prepare_headers(self, content_type_header: str | None = 'application/json'):
211
+ user_agent = self._get_user_agent_header()
212
+ headers = {
213
+ 'User-Agent': user_agent,
214
+ 'accept': 'application/json',
215
+ }
216
+ # `content_type_header=None` omits Content-Type so `requests` can set the
217
+ # multipart/form-data boundary itself (used by multipart file uploads).
218
+ if content_type_header is not None:
219
+ headers['Content-Type'] = content_type_header
220
+ if self._api_token:
221
+ headers['Authorization'] = f'Bearer {self._api_token}'
222
+ else:
223
+ # In theory should never happen, but just in case
224
+ self.log.warning('Request being made with no Sandbox API key set')
225
+ return headers
@@ -0,0 +1,25 @@
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
+ DEFAULT_PAGE_LIMIT = 10
15
+ SAMPLES_PER_PAGE = 50
16
+ MAXIMUM_SAMPLES = 200
17
+
18
+ STATIC_REPORT_WAIT_INTERVAL_SECONDS = 20
19
+ STATIC_REPORT_WAIT_DEFAULT_TIMEOUT_SECONDS = 600
20
+
21
+ BEHAVIORAL_REPORT_WAIT_INTERVAL_SECONDS = 20
22
+ BEHAVIORAL_REPORT_WAIT_DEFAULT_TIMEOUT_SECONDS = 1800
23
+
24
+ OVERVIEW_REPORT_WAIT_INTERVAL_SECONDS = 20
25
+ OVERVIEW_REPORT_WAIT_DEFAULT_TIMEOUT_SECONDS = 1800