psengine 2.0.5__tar.gz → 2.0.6__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 (126) hide show
  1. {psengine-2.0.5 → psengine-2.0.6}/PKG-INFO +3 -3
  2. {psengine-2.0.5 → psengine-2.0.6}/psengine/_version.py +1 -1
  3. psengine-2.0.6/psengine/analyst_notes/markdown.py +216 -0
  4. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/note.py +32 -0
  5. {psengine-2.0.5 → psengine-2.0.6}/psengine/base_http_client.py +9 -2
  6. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/classic_alert_mgr.py +7 -10
  7. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/markdown/__init__.py +0 -1
  8. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/markdown/markdown.py +40 -34
  9. {psengine-2.0.5 → psengine-2.0.6}/psengine/constants.py +2 -2
  10. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/detection_mgr.py +2 -0
  11. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/helpers.py +2 -2
  12. {psengine-2.0.5 → psengine-2.0.6}/psengine/endpoints.py +5 -3
  13. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/lookup.py +5 -5
  14. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/lookup_mgr.py +2 -2
  15. {psengine-2.0.5/psengine/playbook_alerts/markdown → psengine-2.0.6/psengine/enrich/models}/__init__.py +0 -1
  16. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/entity_list.py +3 -6
  17. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/entity_list_mgr.py +1 -1
  18. {psengine-2.0.5 → psengine-2.0.6}/psengine/helpers/helpers.py +11 -12
  19. {psengine-2.0.5 → psengine-2.0.6}/psengine/markdown/markdown.py +19 -15
  20. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/constants.py +4 -0
  21. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/mappings.py +8 -0
  22. {psengine-2.0.5/psengine/enrich/models → psengine-2.0.6/psengine/playbook_alerts/markdown}/__init__.py +0 -1
  23. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/markdown/markdown.py +18 -11
  24. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/markdown/markdown_code_repo.py +24 -9
  25. psengine-2.0.6/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py +200 -0
  26. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/markdown/markdown_domain_abuse.py +26 -13
  27. psengine-2.0.6/psengine/playbook_alerts/markdown/markdown_geopolitics_facility.py +105 -0
  28. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/markdown/markdown_identity_exposure.py +27 -17
  29. psengine-2.0.6/psengine/playbook_alerts/markdown/markdown_third_party_risk.py +317 -0
  30. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/__init__.py +14 -7
  31. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/common_models.py +13 -0
  32. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/pba_code_repo_leak.py +1 -1
  33. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/pba_cyber_vulnerability.py +3 -12
  34. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/pba_domain_abuse.py +3 -3
  35. psengine-2.0.6/psengine/playbook_alerts/models/pba_geopolitics_facility.py +86 -0
  36. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/pba_identity_exposures.py +1 -1
  37. psengine-2.0.6/psengine/playbook_alerts/models/pba_malware_report.py +55 -0
  38. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/pba_third_party_risk.py +5 -13
  39. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/pa_category.py +2 -0
  40. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/playbook_alert_mgr.py +39 -20
  41. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/playbook_alerts.py +252 -46
  42. {psengine-2.0.5 → psengine-2.0.6}/psengine/rf_client.py +1 -1
  43. {psengine-2.0.5 → psengine-2.0.6}/psengine/risklists/models.py +1 -0
  44. {psengine-2.0.5 → psengine-2.0.6}/psengine/risklists/risklist_mgr.py +2 -2
  45. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/complex_entity.py +11 -12
  46. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/helpers.py +4 -5
  47. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/rf_bundle.py +5 -7
  48. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/util.py +1 -1
  49. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/PKG-INFO +3 -3
  50. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/SOURCES.txt +6 -0
  51. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/requires.txt +1 -1
  52. {psengine-2.0.5 → psengine-2.0.6}/pyproject.toml +3 -3
  53. {psengine-2.0.5 → psengine-2.0.6}/LICENSE +0 -0
  54. {psengine-2.0.5 → psengine-2.0.6}/README.rst +0 -0
  55. {psengine-2.0.5 → psengine-2.0.6}/psengine/__init__.py +0 -0
  56. {psengine-2.0.5 → psengine-2.0.6}/psengine/_sdk_id.py +0 -0
  57. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/__init__.py +0 -0
  58. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/constants.py +0 -0
  59. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/errors.py +0 -0
  60. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/helpers.py +0 -0
  61. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/models.py +0 -0
  62. {psengine-2.0.5 → psengine-2.0.6}/psengine/analyst_notes/note_mgr.py +0 -0
  63. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/__init__.py +0 -0
  64. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/classic_alert.py +0 -0
  65. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/constants.py +0 -0
  66. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/errors.py +0 -0
  67. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/helpers.py +0 -0
  68. {psengine-2.0.5 → psengine-2.0.6}/psengine/classic_alerts/models.py +0 -0
  69. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/__init__.py +0 -0
  70. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/collective_insights.py +0 -0
  71. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/constants.py +0 -0
  72. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/errors.py +0 -0
  73. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/insight.py +0 -0
  74. {psengine-2.0.5 → psengine-2.0.6}/psengine/collective_insights/models.py +0 -0
  75. {psengine-2.0.5 → psengine-2.0.6}/psengine/common_models.py +0 -0
  76. {psengine-2.0.5 → psengine-2.0.6}/psengine/config/__init__.py +0 -0
  77. {psengine-2.0.5 → psengine-2.0.6}/psengine/config/config.py +0 -0
  78. {psengine-2.0.5 → psengine-2.0.6}/psengine/config/errors.py +0 -0
  79. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/__init__.py +0 -0
  80. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/detection_rule.py +0 -0
  81. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/errors.py +0 -0
  82. {psengine-2.0.5 → psengine-2.0.6}/psengine/detection/models.py +0 -0
  83. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/__init__.py +0 -0
  84. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/constants.py +0 -0
  85. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/errors.py +0 -0
  86. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/models/base_enriched_entity.py +0 -0
  87. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/models/lookup.py +0 -0
  88. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/models/soar.py +0 -0
  89. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/soar.py +0 -0
  90. {psengine-2.0.5 → psengine-2.0.6}/psengine/enrich/soar_mgr.py +0 -0
  91. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/__init__.py +0 -0
  92. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/constants.py +0 -0
  93. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/errors.py +0 -0
  94. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_lists/models.py +0 -0
  95. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_match/__init__.py +0 -0
  96. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_match/entity_match.py +0 -0
  97. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_match/entity_match_mgr.py +0 -0
  98. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_match/errors.py +0 -0
  99. {psengine-2.0.5 → psengine-2.0.6}/psengine/entity_match/models.py +0 -0
  100. {psengine-2.0.5 → psengine-2.0.6}/psengine/errors.py +0 -0
  101. {psengine-2.0.5 → psengine-2.0.6}/psengine/helpers/__init__.py +0 -0
  102. {psengine-2.0.5 → psengine-2.0.6}/psengine/logger/__init__.py +0 -0
  103. {psengine-2.0.5 → psengine-2.0.6}/psengine/logger/constants.py +0 -0
  104. {psengine-2.0.5 → psengine-2.0.6}/psengine/logger/errors.py +0 -0
  105. {psengine-2.0.5 → psengine-2.0.6}/psengine/logger/rf_logger.py +0 -0
  106. {psengine-2.0.5 → psengine-2.0.6}/psengine/markdown/__init__.py +0 -0
  107. {psengine-2.0.5 → psengine-2.0.6}/psengine/markdown/models.py +0 -0
  108. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/__init__.py +0 -0
  109. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/errors.py +0 -0
  110. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/helpers.py +0 -0
  111. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/panel_log.py +0 -0
  112. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/panel_status.py +0 -0
  113. {psengine-2.0.5 → psengine-2.0.6}/psengine/playbook_alerts/models/search_endpoint.py +0 -0
  114. {psengine-2.0.5 → psengine-2.0.6}/psengine/risklists/__init__.py +0 -0
  115. {psengine-2.0.5 → psengine-2.0.6}/psengine/risklists/constants.py +0 -0
  116. {psengine-2.0.5 → psengine-2.0.6}/psengine/risklists/errors.py +0 -0
  117. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/__init__.py +0 -0
  118. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/base_stix_entity.py +0 -0
  119. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/constants.py +0 -0
  120. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/enriched_indicator.py +0 -0
  121. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/errors.py +0 -0
  122. {psengine-2.0.5 → psengine-2.0.6}/psengine/stix2/simple_entity.py +0 -0
  123. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/dependency_links.txt +0 -0
  124. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/entry_points.txt +0 -0
  125. {psengine-2.0.5 → psengine-2.0.6}/psengine.egg-info/top_level.txt +0 -0
  126. {psengine-2.0.5 → psengine-2.0.6}/setup.cfg +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: psengine
3
- Version: 2.0.5
3
+ Version: 2.0.6
4
4
  Summary: psengine is a simple, yet elegant, library for rapid development of integrations with Recorded Future.
5
5
  Author-email: Moise Medici <moise.medici@recordedfuture.com>, Patrick Kinsella <patrick.kinsella@recordedfuture.com>, Ernest Bartosevic <ernest.bartosevic@recordedfuture.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/RecordedFuture-ProfessionalServices/psengine
8
- Project-URL: Changelog, https://github.com/RecordedFuture-ProfessionalServices/psengine/CHANGELOG.rst
8
+ Project-URL: Changelog, https://github.com/RecordedFuture-ProfessionalServices/psengine/blob/main/CHANGELOG.rst
9
9
  Keywords: API,Recorded Future,Cyber Security Engineering,Threat Intelligence
10
10
  Requires-Python: <3.14,>=3.9
11
11
  Description-Content-Type: text/x-rst
@@ -28,7 +28,7 @@ Requires-Dist: pytest-random-order==1.1.1; extra == "dev"
28
28
  Requires-Dist: pytest-vcr==1.0.2; extra == "dev"
29
29
  Requires-Dist: pytest-watch==4.2.0; extra == "dev"
30
30
  Requires-Dist: requests==2.29.0; extra == "dev"
31
- Requires-Dist: ruff~=0.7.0; extra == "dev"
31
+ Requires-Dist: ruff~=0.11.0; extra == "dev"
32
32
  Requires-Dist: wheel==0.37.1; extra == "dev"
33
33
  Requires-Dist: setuptools==61.0.0; extra == "dev"
34
34
  Requires-Dist: Sphinx==7.1.2; extra == "dev"
@@ -11,4 +11,4 @@
11
11
  # accessed from any third party API. #
12
12
  ##############################################################################################
13
13
 
14
- __version__ = '2.0.5'
14
+ __version__ = '2.0.6'
@@ -0,0 +1,216 @@
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 collections import defaultdict
16
+ from itertools import chain
17
+ from typing import TYPE_CHECKING
18
+
19
+ from markdown_strings import bold, unordered_list
20
+
21
+ from ..constants import TIMESTAMP_STR
22
+ from ..endpoints import EP_ANALYST_NOTE_LOOKUP
23
+ from ..markdown.markdown import MarkdownMaker, divider, html_collapsible
24
+
25
+ if TYPE_CHECKING:
26
+ from ..analyst_notes import AnalystNote
27
+
28
+ EXTRACTED_KEYS = {
29
+ 'AttackVector': 'Attack Vector',
30
+ 'Malware': 'Malware',
31
+ 'MalwareCategory': 'Malware Category',
32
+ 'WinRegKey': 'Windows Registry Key',
33
+ 'Hash': 'Hash',
34
+ }
35
+
36
+
37
+ def _cleanup_insikt_note_text(note_text: str) -> str:
38
+ """Clean up insikt note text to avoid markdown rendering issues."""
39
+ translation = {
40
+ r'\•': '+ ',
41
+ r'--+': '',
42
+ r'>>+': '',
43
+ r'<<+': '',
44
+ r'\*\*': '••',
45
+ r'#': '\\#',
46
+ r'_': '\\_',
47
+ }
48
+ for k, v in translation.items():
49
+ note_text = re.sub(k, v, note_text)
50
+
51
+ return note_text
52
+
53
+
54
+ def _entity_by_type_list(entities: list, entity_type: str) -> list:
55
+ """Extract all the related entities by type."""
56
+ return sorted({entity.name for entity in entities if entity.type_ == entity_type})
57
+
58
+
59
+ def _vuln_list(entities: list) -> list:
60
+ """Extract all the vulnerabilities and add the description if present."""
61
+ vulns = [entity for entity in entities if entity.type_ == 'CyberVulnerability']
62
+
63
+ texts = []
64
+ for vuln in sorted(vulns, key=lambda x: x.name):
65
+ text = bold(vuln.name)
66
+ if vuln.description:
67
+ descr = vuln.description.replace('\n', ' ')
68
+ text = f'{text}: {descr}'
69
+ texts.append(text)
70
+
71
+ return texts
72
+
73
+
74
+ def _add_extra_entities(note: 'AnalystNote', html_tags: bool, md_maker: MarkdownMaker):
75
+ """Add the Entities Extracted block for EXTRACTED_KEYS types."""
76
+ data = defaultdict(list)
77
+ entities = list(chain(note.attributes.note_entities, note.attributes.context_entities))
78
+
79
+ for entity_type, clean_entity_type in EXTRACTED_KEYS.items():
80
+ if extracted := _entity_by_type_list(entities, entity_type):
81
+ data[clean_entity_type].extend(extracted)
82
+
83
+ if vulns := _vuln_list(entities):
84
+ data['Vulnerability'].extend(vulns)
85
+
86
+ data = dict(sorted(data.items()))
87
+ if data:
88
+ if html_tags:
89
+ md_maker.add_section(
90
+ 'Entities',
91
+ [
92
+ html_collapsible(f'<b>{k}</b>', '\n\n' + unordered_list(v, esc=False)) + '\n'
93
+ if len(v) > 5
94
+ else f'{bold(k)}\n{unordered_list(v, esc=False)}\n\n'
95
+ for k, v in data.items()
96
+ ],
97
+ )
98
+ else:
99
+ md_maker.add_section(
100
+ 'Entities',
101
+ [
102
+ '\n'.join([bold(f'{k}:'), unordered_list(v, esc=False) + '\n'])
103
+ for k, v in data.items()
104
+ ],
105
+ )
106
+
107
+
108
+ def _add_diamond_model(
109
+ note: 'AnalystNote',
110
+ html_tags: bool,
111
+ defang_malicious_infrastructure: bool,
112
+ md_maker: MarkdownMaker,
113
+ ):
114
+ """Add all the Diamond Models if present.
115
+
116
+ Since a note can have more than one Diamond model for different targets/methods all will be
117
+ displayed.
118
+ The collapsible is only applied to the title (ie. Diamond Model 1) and each section that has
119
+ more than 5 entries.
120
+
121
+ """
122
+ html_title = '<h4>Cyber Attack'
123
+ diamond_models, data = [], []
124
+ for diamond_model in note.attributes.diamond_model:
125
+ model = {
126
+ 'Malicious Infrastructure': [e.name for e in diamond_model.malicious_infrastructure],
127
+ 'Capabilities': [e.name for e in diamond_model.capabilities],
128
+ 'Adversary': [e.name for e in diamond_model.adversary],
129
+ 'Target': [f'{e.name} ({e.type_})' for e in diamond_model.target],
130
+ }
131
+ if defang_malicious_infrastructure:
132
+ iocs = [ioc.replace('.', '[.]') for ioc in model['Malicious Infrastructure']]
133
+ model['Malicious Infrastructure'] = iocs
134
+
135
+ diamond_models.append({k: v for k, v in model.items() if v})
136
+
137
+ if html_tags:
138
+ data = [
139
+ html_collapsible(
140
+ f'{html_title} {i}{": " + model["Adversary"][0] if model.get("Adversary") else ""}',
141
+ '\n\n'
142
+ + ''.join(
143
+ html_collapsible(f'<b>{section}</b>', f'\n\n{unordered_list(sorted(entities))}')
144
+ + '\n'
145
+ if len(entities) > 5
146
+ else f'\n{bold(section)}\n\n{unordered_list(sorted(entities))}\n\n'
147
+ for section, entities in model.items()
148
+ )
149
+ + f'\n{divider()}',
150
+ )
151
+ + '\n'
152
+ for i, model in enumerate(diamond_models, 1)
153
+ ]
154
+
155
+ else:
156
+ data = [
157
+ '\n'
158
+ + bold(
159
+ f'Cyber Attack {i}{": " + model["Adversary"][0] if model.get("Adversary") else ""}'
160
+ )
161
+ + '\n'
162
+ + '\n\n'.join(
163
+ [
164
+ '\n' + section + ':\n' + unordered_list(sorted(entities))
165
+ for section, entities in model.items()
166
+ ]
167
+ )
168
+ + f'\n{divider()}\n'
169
+ for i, model in enumerate(diamond_models, 1)
170
+ ]
171
+
172
+ if data:
173
+ md_maker.add_section('Diamond Models', data)
174
+
175
+
176
+ def _markdown(
177
+ note: 'AnalystNote',
178
+ extract_entities: bool,
179
+ diamond_model: bool,
180
+ html_tags: bool,
181
+ defang_malicious_infrastructure: bool,
182
+ character_limit: int,
183
+ ) -> str:
184
+ """Main markdown function."""
185
+ md_maker = MarkdownMaker()
186
+ topic = (
187
+ note.attributes.topic
188
+ if isinstance(note.attributes.topic, list)
189
+ else [note.attributes.topic]
190
+ )
191
+ topic_str = f'{bold("Topic:")} {{}}' if len(topic) == 1 else f'{bold("Topics:")} {{}}'
192
+ intro = [
193
+ f'{bold("ID:")} {note.id_} ',
194
+ f'{bold("Published:")} {note.attributes.published.strftime(TIMESTAMP_STR)} ',
195
+ f'{bold("Source:")} {note.source.name} ',
196
+ topic_str.format(', '.join(t.name for t in topic) + ' '),
197
+ ]
198
+ if validation_urls := '\n- '.join(url.name for url in note.attributes.validation_urls):
199
+ validation_urls = f'\n- {validation_urls}\n'
200
+ intro.append(f'{bold("Validation URLs:")} {validation_urls}')
201
+
202
+ intro.append(f'[API]({EP_ANALYST_NOTE_LOOKUP.format(note.id_)}) | [Portal]({note.portal_url})')
203
+
204
+ md_maker.add_title(note.attributes.title)
205
+ md_maker.add_section('Summary', intro)
206
+
207
+ if extract_entities:
208
+ _add_extra_entities(note, html_tags, md_maker)
209
+
210
+ if diamond_model:
211
+ _add_diamond_model(note, html_tags, defang_malicious_infrastructure, md_maker)
212
+
213
+ md_maker.add_section('Note', _cleanup_insikt_note_text(note.attributes.text))
214
+
215
+ output = md_maker.format_output()
216
+ return output if not character_limit else output[:character_limit]
@@ -19,6 +19,7 @@ from pydantic import Field
19
19
  from ..common_models import IdNameTypeDescription, RFBaseModel
20
20
  from ..constants import TIMESTAMP_STR
21
21
  from .constants import NOTES_PER_PAGE, URL_TO_PORTAL
22
+ from .markdown import _markdown
22
23
  from .models import Attributes, PreviewAttributesIn, PreviewAttributesOut, RequestAttachment
23
24
 
24
25
 
@@ -98,6 +99,37 @@ class AnalystNote(RFBaseModel):
98
99
  return URL_TO_PORTAL.format(self.id_)
99
100
  return URL_TO_PORTAL.format(f'doc:{self.id_}')
100
101
 
102
+ def markdown(
103
+ self,
104
+ extract_entities: bool = True,
105
+ diamond_model: bool = True,
106
+ html_tags: bool = False,
107
+ defang_malicious_infrastructure: bool = False,
108
+ character_limit: int = None,
109
+ ) -> str:
110
+ """Return the markdown representation of the Note.
111
+
112
+ Args:
113
+ extract_entities (bool): Extract and include entities in the markdown. Defaults to True.
114
+ diamond_model (bool): Include a diamond model visualization. Defaults to True.
115
+ html_tags (bool): Include HTML tags in the output. Defaults to False.
116
+ defang_malicious_infrastructure (bool): Defang URLs or other malicious indicators.
117
+ Defaults to False.
118
+ character_limit (int, optional): Limit the output to a specified number of characters.
119
+ Defaults to None.
120
+
121
+ Returns:
122
+ str: The generated markdown string.
123
+ """
124
+ return _markdown(
125
+ self,
126
+ extract_entities=extract_entities,
127
+ diamond_model=diamond_model,
128
+ html_tags=html_tags,
129
+ defang_malicious_infrastructure=defang_malicious_infrastructure,
130
+ character_limit=character_limit,
131
+ )
132
+
101
133
 
102
134
  class AnalystNotePreviewIn(RFBaseModel):
103
135
  """Validate data sent to ``/preview`` endpoint."""
@@ -8,7 +8,7 @@
8
8
  # represents that it is solely responsible for having all necessary licenses, permissions, #
9
9
  # rights, and/or consents to connect to third party APIs, and that it is solely responsible #
10
10
  # for having all necessary licenses, permissions, rights, and/or consents to any data #
11
- # accessed from any third party API. #.
11
+ # accessed from any third party API. #
12
12
  ##############################################################################################
13
13
 
14
14
  import json
@@ -16,7 +16,14 @@ import logging
16
16
  from typing import Union
17
17
 
18
18
  from pydantic import validate_call
19
- from requests import ConnectionError, ConnectTimeout, HTTPError, ReadTimeout, Session, adapters
19
+ from requests import (
20
+ ConnectionError, # noqa: A004
21
+ ConnectTimeout,
22
+ HTTPError,
23
+ ReadTimeout,
24
+ Session,
25
+ adapters,
26
+ )
20
27
  from requests.adapters import HTTPAdapter, Retry
21
28
  from requests.exceptions import JSONDecodeError
22
29
  from requests.models import Response
@@ -13,7 +13,7 @@
13
13
 
14
14
  import logging
15
15
  from itertools import chain
16
- from typing import Optional, Union
16
+ from typing import Annotated, Optional, Union
17
17
 
18
18
  from pydantic import Field, validate_call
19
19
 
@@ -26,8 +26,7 @@ from ..endpoints import (
26
26
  EP_CLASSIC_ALERTS_SEARCH,
27
27
  EP_CLASSIC_ALERTS_UPDATE,
28
28
  )
29
- from ..helpers import MultiThreadingHelper, debug_call
30
- from ..helpers.helpers import connection_exceptions
29
+ from ..helpers import MultiThreadingHelper, connection_exceptions, debug_call
31
30
  from ..rf_client import RFClient
32
31
  from .classic_alert import AlertRuleOut, ClassicAlert, ClassicAlertHit
33
32
  from .constants import ALERTS_PER_PAGE, ALL_CA_FIELDS, REQUIRED_CA_FIELDS
@@ -126,20 +125,19 @@ class ClassicAlertMgr:
126
125
  )
127
126
  )
128
127
 
129
- elif isinstance(rule_id, list):
128
+ if isinstance(rule_id, list):
130
129
  return list(chain.from_iterable(self._search(rule, **params) for rule in rule_id))
131
130
 
132
- elif isinstance(rule_id, str):
131
+ if isinstance(rule_id, str):
133
132
  return self._search(rule_id, **params)
134
- else:
135
- return self._search(**params)
133
+ return self._search(**params)
136
134
 
137
135
  @debug_call
138
136
  @validate_call
139
137
  @connection_exceptions(ignore_status_code=[], exception_to_raise=AlertFetchError)
140
138
  def fetch(
141
139
  self,
142
- id_: str,
140
+ id_: Annotated[str, Field(min_length=4)],
143
141
  fields: Optional[list[str]] = ALL_CA_FIELDS,
144
142
  tagged_text: Optional[bool] = None,
145
143
  ) -> ClassicAlert:
@@ -418,8 +416,7 @@ class ClassicAlertMgr:
418
416
  JSON response
419
417
  """
420
418
  self.log.info(f'Updating alerts: {updates}')
421
- response = self.rf_client.request('post', url=EP_CLASSIC_ALERTS_UPDATE, data=updates).json()
422
- return response
419
+ return self.rf_client.request('post', url=EP_CLASSIC_ALERTS_UPDATE, data=updates).json()
423
420
 
424
421
  @debug_call
425
422
  @validate_call
@@ -10,4 +10,3 @@
10
10
  # for having all necessary licenses, permissions, rights, and/or consents to any data #
11
11
  # accessed from any third party API. #
12
12
  ##############################################################################################
13
-
@@ -13,9 +13,13 @@
13
13
 
14
14
  import re
15
15
  from itertools import chain
16
+ from typing import TYPE_CHECKING
16
17
 
17
18
  from markdown_strings import blockquote, bold, esc_format, link
18
19
 
20
+ if TYPE_CHECKING:
21
+ from ..classic_alerts.classic_alert import ClassicAlert
22
+
19
23
  from ...constants import TIMESTAMP_STR, TRUNCATE_COMMENT
20
24
  from ...markdown import (
21
25
  MarkdownMaker,
@@ -44,7 +48,7 @@ def _clean_title(alert_title: str) -> str:
44
48
  return re.sub(expression, '', alert_title).strip()
45
49
 
46
50
 
47
- def _owner_org_markdown(classic_alert) -> list[str]:
51
+ def _owner_org_markdown(classic_alert: 'ClassicAlert') -> list[str]:
48
52
  results = []
49
53
  details = classic_alert.owner_organisation_details
50
54
 
@@ -52,9 +56,9 @@ def _owner_org_markdown(classic_alert) -> list[str]:
52
56
  return []
53
57
 
54
58
  if details.enterprise_name:
55
- results.append(f"{bold('Enterprise:')} {details.enterprise_name} ")
59
+ results.append(f'{bold("Enterprise:")} {details.enterprise_name} ')
56
60
  if details.owner_name:
57
- results.append(f"{bold('Owner:')} {details.owner_name} ")
61
+ results.append(f'{bold("Owner:")} {details.owner_name} ')
58
62
 
59
63
  orgs = [[org.organisation_id, org.organisation_name] for org in details.organisations]
60
64
  orgs.insert(0, ['Organisation ID', 'Organisation Name'])
@@ -64,24 +68,24 @@ def _owner_org_markdown(classic_alert) -> list[str]:
64
68
 
65
69
 
66
70
  def _process_hit_fragment(
67
- hit, include_triggered_by: bool, html_tags: bool, classic_alert
71
+ hit, include_triggered_by: bool, html_tags: bool, classic_alert: 'ClassicAlert'
68
72
  ) -> tuple[str, str]:
69
73
  content = []
70
74
  authors = ', '.join(author.name for author in hit.document.authors)
71
75
 
72
76
  title_line = f' From {hit.document.source.name}'
73
77
  if authors:
74
- content.append(f"{bold('Author(s):')} {authors}\n")
78
+ content.append(f'{bold("Author(s):")} {authors}\n')
75
79
 
76
80
  if hit.document.title and hit.fragment:
77
81
  first_half_title = hit.document.title[: (len(hit.document.title) // 2)]
78
82
  if not hit.fragment.lower().startswith(first_half_title.lower()):
79
- content.append(f"{bold('Title:')} {clean_text(hit.document.title)}\n")
83
+ content.append(f'{bold("Title:")} {clean_text(hit.document.title)}\n')
80
84
  elif hit.document.title and not hit.fragment:
81
- content.append(f"{bold('Title:')} {clean_text(hit.document.title)}\n")
85
+ content.append(f'{bold("Title:")} {clean_text(hit.document.title)}\n')
82
86
 
83
87
  if hit.document.url:
84
- content.append(f"{bold('URL:')} {hit.document.url}\n")
88
+ content.append(f'{bold("URL:")} {hit.document.url}\n')
85
89
 
86
90
  if hit.fragment:
87
91
  fragment = (
@@ -90,7 +94,7 @@ def _process_hit_fragment(
90
94
  content.append(f'{blockquote(fragment)}\n')
91
95
  else:
92
96
  content.append(
93
- f"_Reference text is missing, check the Recorded Future {link('Portal', str(classic_alert.url.portal))} for more information._\n" # noqa: E501
97
+ f'_Reference text is missing, check the Recorded Future {link("Portal", str(classic_alert.url.portal))} for more information._\n' # noqa: E501
94
98
  )
95
99
 
96
100
  if include_triggered_by:
@@ -101,7 +105,7 @@ def _process_hit_fragment(
101
105
  triggered_by = TRIGGERED_BY_HTML.format(triggered_by)
102
106
  content.append(triggered_by)
103
107
  else:
104
- content.append(f"{bold('Triggered By:')}\n+ {triggered_by}\n")
108
+ content.append(f'{bold("Triggered By:")}\n+ {triggered_by}\n')
105
109
 
106
110
  return title_line, content
107
111
 
@@ -125,7 +129,7 @@ def _process_entities(entities, hit) -> list[list[str]]:
125
129
 
126
130
 
127
131
  def _hits_markdown(
128
- classic_alert,
132
+ classic_alert: 'ClassicAlert',
129
133
  hits,
130
134
  include_fragment_entities: bool = True,
131
135
  include_triggered_by: bool = True,
@@ -167,7 +171,7 @@ def _hits_markdown(
167
171
  return sections
168
172
 
169
173
 
170
- def _enriched_entities_markdown(classic_alert) -> list:
174
+ def _enriched_entities_markdown(classic_alert: 'ClassicAlert') -> list:
171
175
  results = []
172
176
  for entity in classic_alert.enriched_entities:
173
177
  if not entity.evidence:
@@ -175,10 +179,10 @@ def _enriched_entities_markdown(classic_alert) -> list:
175
179
 
176
180
  criticality = entity.criticality
177
181
  contents = [
178
- f"{bold('Risk Score:')} {criticality.score}",
179
- f"{bold('Criticality:')} {criticality.name}",
180
- f"{bold('Triggered:')} {criticality.triggered.strftime(TIMESTAMP_STR)}",
181
- f"{bold('Last Triggered:')} {criticality.last_triggered.strftime(TIMESTAMP_STR)} \n\n",
182
+ f'{bold("Risk Score:")} {criticality.score}',
183
+ f'{bold("Criticality:")} {criticality.name}',
184
+ f'{bold("Triggered:")} {criticality.triggered.strftime(TIMESTAMP_STR)}',
185
+ f'{bold("Last Triggered:")} {criticality.last_triggered.strftime(TIMESTAMP_STR)} \n\n',
182
186
  ]
183
187
 
184
188
  evidences = []
@@ -197,7 +201,7 @@ def _enriched_entities_markdown(classic_alert) -> list:
197
201
  return results
198
202
 
199
203
 
200
- def _target_entities_markdown(classic_alert, html_tags: bool = False) -> list:
204
+ def _target_entities_markdown(classic_alert: 'ClassicAlert', html_tags: bool = False) -> list:
201
205
  results = []
202
206
  for entity in classic_alert.enriched_entities:
203
207
  result = {'title': f'Target {entity.entity.name}'}
@@ -213,17 +217,16 @@ def _target_entities_markdown(classic_alert, html_tags: bool = False) -> list:
213
217
  return results
214
218
 
215
219
 
216
- def _create_summary_section(ca) -> None:
217
- summary_content = [
218
- f"{bold('ID:')} {ca.id_} ",
219
- f"{bold('Triggered:')} {ca.log.triggered.strftime(TIMESTAMP_STR)} ",
220
- f"{bold('Alerting Rule:')} {ca.rule.name} ",
221
- f"{link('API', str(ca.url.api))} | {link('Portal', str(ca.url.portal))}",
220
+ def _create_summary_section(ca: 'ClassicAlert') -> None:
221
+ return [
222
+ f'{bold("ID:")} {ca.id_} ',
223
+ f'{bold("Triggered:")} {ca.log.triggered.strftime(TIMESTAMP_STR)} ',
224
+ f'{bold("Alerting Rule:")} {ca.rule.name} ',
225
+ f'{link("API", str(ca.url.api))} | {link("Portal", str(ca.url.portal))}',
222
226
  ]
223
- return summary_content
224
227
 
225
228
 
226
- def _get_entities_to_defang(classic_alert) -> set:
229
+ def _get_entities_to_defang(classic_alert: 'ClassicAlert') -> set:
227
230
  """Return a set of IOC entities to defang from the classic_alert hits."""
228
231
  if not classic_alert.hits:
229
232
  return set()
@@ -233,35 +236,38 @@ def _get_entities_to_defang(classic_alert) -> set:
233
236
  for entity in chain.from_iterable(h.entities for h in classic_alert.hits)
234
237
  if entity.type_ in MARKDOWN_ENTITY_TYPES_TO_DEFANG
235
238
  }
236
- defanged_entities = raw_entities.union({esc_format(ent, esc=True) for ent in raw_entities})
237
- return defanged_entities
239
+ return raw_entities.union({esc_format(ent, esc=True) for ent in raw_entities})
238
240
 
239
241
 
240
- def _add_summary_section(md_maker: MarkdownMaker, classic_alert) -> None:
242
+ def _add_summary_section(md_maker: MarkdownMaker, classic_alert: 'ClassicAlert') -> None:
241
243
  """Adds the 'Summary' section to the markdown builder."""
242
244
  md_maker.add_title(_clean_title(classic_alert.title))
243
245
  md_maker.add_section('Summary', _create_summary_section(classic_alert))
244
246
 
245
247
 
246
- def _add_owner_org_section(md_maker: MarkdownMaker, classic_alert, owner_org: bool) -> None:
248
+ def _add_owner_org_section(
249
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', owner_org: bool
250
+ ) -> None:
247
251
  """Adds 'Owner Organisation Details' section if owner_org is True and details are present."""
248
252
  if owner_org and classic_alert.owner_organisation_details:
249
253
  md_maker.add_section('Owner Organisation Details', _owner_org_markdown(classic_alert))
250
254
 
251
255
 
252
- def _add_ai_insights_section(md_maker: MarkdownMaker, classic_alert, ai_insights: bool) -> None:
256
+ def _add_ai_insights_section(
257
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', ai_insights: bool
258
+ ) -> None:
253
259
  """Adds the 'AI Insights' sections if ai_insights is True and data is present."""
254
260
  if ai_insights and classic_alert.ai_insights:
255
261
  if classic_alert.ai_insights.text:
256
262
  md_maker.add_section('AI Insights', [classic_alert.ai_insights.text])
257
263
  if classic_alert.ai_insights.comment:
258
264
  md_maker.add_section(
259
- 'AI Insights', [f"{bold('Comment:')} {classic_alert.ai_insights.comment}"]
265
+ 'AI Insights', [f'{bold("Comment:")} {classic_alert.ai_insights.comment}']
260
266
  )
261
267
 
262
268
 
263
269
  def _add_enriched_entities_sections(
264
- md_maker: MarkdownMaker, classic_alert, html_tags: bool
270
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', html_tags: bool
265
271
  ) -> None:
266
272
  """Adds sections related to enriched entities (evidence and references)."""
267
273
  if any(x.evidence for x in classic_alert.enriched_entities):
@@ -277,7 +283,7 @@ def _add_enriched_entities_sections(
277
283
 
278
284
  def _add_hits_section_if_no_enriched_entities(
279
285
  md_maker: MarkdownMaker,
280
- classic_alert,
286
+ classic_alert: 'ClassicAlert',
281
287
  fragment_entities: bool,
282
288
  triggered_by: bool,
283
289
  html_tags: bool,
@@ -297,7 +303,7 @@ def _add_hits_section_if_no_enriched_entities(
297
303
 
298
304
 
299
305
  def _markdown_alert(
300
- classic_alert,
306
+ classic_alert: 'ClassicAlert',
301
307
  owner_org: bool = False,
302
308
  ai_insights: bool = True,
303
309
  fragment_entities: bool = True,
@@ -22,8 +22,8 @@ DEFAULT_MAX_WORKERS = 10
22
22
  #####################
23
23
  # Recorded Future API
24
24
  #####################
25
- RF_TOKEN_ENV_VAR = 'RF_TOKEN'
26
- RF_TOKEN_VALIDATION_REGEX = r'^[a-f0-9]{32}$'
25
+ RF_TOKEN_ENV_VAR = 'RF_TOKEN' # noqa: S105
26
+ RF_TOKEN_VALIDATION_REGEX = r'^[a-f0-9]{32}$' # noqa: S105
27
27
 
28
28
  #####################
29
29
  # Recorded Future Portal
@@ -132,4 +132,6 @@ class DetectionMgr:
132
132
 
133
133
  if result:
134
134
  return result[0]
135
+
135
136
  self.log.info(f'No rule found for id {doc_id}')
137
+ return None
@@ -47,9 +47,9 @@ def save_rule(rule: DetectionRule, output_directory: Union[str, Path] = None):
47
47
  output_directory = Path(output_directory).absolute() if output_directory else Path().cwd()
48
48
  OSHelpers.mkdir(output_directory)
49
49
 
50
- for data in rule.rules:
50
+ for i, data in enumerate(rule.rules):
51
51
  try:
52
- full_path = output_directory / data.file_name
52
+ full_path = output_directory / (data.file_name or f'{rule.id_.replace(":", "_")}_{i}')
53
53
  full_path.write_text(data.content)
54
54
  LOG.info(f'Wrote: {full_path}')
55
55
  except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as err: # noqa: PERF203
@@ -51,11 +51,13 @@ EP_FUSION_FILES = CONNECT_API_BASE_URL + '/fusion/files'
51
51
  EP_PLAYBOOK_ALERT = BASE_URL + '/playbook-alert'
52
52
  EP_PLAYBOOK_ALERT_SEARCH = EP_PLAYBOOK_ALERT + '/search'
53
53
  EP_PLAYBOOK_ALERT_COMMON = EP_PLAYBOOK_ALERT + '/common'
54
- EP_PLAYBOOK_ALERT_DOMAIN_ABUSE = EP_PLAYBOOK_ALERT + '/domain_abuse'
55
- EP_PLAYBOOK_ALERT_CYBER_VULNERABILITY = EP_PLAYBOOK_ALERT + '/vulnerability'
56
54
  EP_PLAYBOOK_ALERT_CODE_REPO_LEAKAGE = EP_PLAYBOOK_ALERT + '/code_repo_leakage'
57
- EP_PLAYBOOK_ALERT_THIRD_PARTY_RISK = EP_PLAYBOOK_ALERT + '/third_party_risk'
55
+ EP_PLAYBOOK_ALERT_CYBER_VULNERABILITY = EP_PLAYBOOK_ALERT + '/vulnerability'
56
+ EP_PLAYBOOK_ALERT_DOMAIN_ABUSE = EP_PLAYBOOK_ALERT + '/domain_abuse'
57
+ EP_PLAYBOOK_ALERT_GEOPOLITICS_FACILITY = EP_PLAYBOOK_ALERT + '/geopolitics_facility'
58
58
  EP_PLAYBOOK_ALERT_IDENTITY_NOVEL_EXPOSURES = EP_PLAYBOOK_ALERT + '/identity_novel_exposures'
59
+ EP_PLAYBOOK_ALERT_THIRD_PARTY_RISK = EP_PLAYBOOK_ALERT + '/third_party_risk'
60
+ EP_PLAYBOOK_ALERT_MALWARE_REPORT = EP_PLAYBOOK_ALERT + '/malware_report'
59
61
 
60
62
  ###############################################################################
61
63
  # Entity Match Endpoint