psengine 2.0.5__tar.gz → 2.0.7__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 (127) hide show
  1. {psengine-2.0.5 → psengine-2.0.7}/PKG-INFO +3 -3
  2. {psengine-2.0.5 → psengine-2.0.7}/psengine/_version.py +1 -1
  3. psengine-2.0.7/psengine/analyst_notes/markdown.py +216 -0
  4. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/note.py +32 -0
  5. {psengine-2.0.5 → psengine-2.0.7}/psengine/base_http_client.py +9 -2
  6. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/classic_alert_mgr.py +7 -10
  7. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/markdown/__init__.py +0 -1
  8. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/markdown/markdown.py +41 -34
  9. {psengine-2.0.5 → psengine-2.0.7}/psengine/constants.py +2 -2
  10. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/detection_mgr.py +2 -0
  11. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/helpers.py +2 -2
  12. {psengine-2.0.5 → psengine-2.0.7}/psengine/endpoints.py +5 -3
  13. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/lookup.py +5 -5
  14. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/lookup_mgr.py +2 -2
  15. {psengine-2.0.5/psengine/playbook_alerts/markdown → psengine-2.0.7/psengine/enrich/models}/__init__.py +0 -1
  16. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/entity_list.py +5 -7
  17. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/entity_list_mgr.py +1 -1
  18. {psengine-2.0.5 → psengine-2.0.7}/psengine/helpers/helpers.py +11 -12
  19. {psengine-2.0.5 → psengine-2.0.7}/psengine/markdown/markdown.py +19 -15
  20. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/__init__.py +3 -0
  21. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/constants.py +32 -5
  22. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/errors.py +4 -0
  23. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/helpers.py +5 -3
  24. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/mappings.py +8 -0
  25. {psengine-2.0.5/psengine/enrich/models → psengine-2.0.7/psengine/playbook_alerts/markdown}/__init__.py +0 -1
  26. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/markdown/markdown.py +22 -33
  27. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/markdown/markdown_code_repo.py +24 -9
  28. psengine-2.0.7/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py +200 -0
  29. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/markdown/markdown_domain_abuse.py +26 -13
  30. psengine-2.0.7/psengine/playbook_alerts/markdown/markdown_geopolitics_facility.py +105 -0
  31. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/markdown/markdown_identity_exposure.py +27 -17
  32. psengine-2.0.7/psengine/playbook_alerts/markdown/markdown_malware_report.py +66 -0
  33. psengine-2.0.7/psengine/playbook_alerts/markdown/markdown_third_party_risk.py +317 -0
  34. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/__init__.py +14 -7
  35. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/common_models.py +13 -0
  36. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/pba_code_repo_leak.py +1 -1
  37. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/pba_cyber_vulnerability.py +3 -12
  38. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/pba_domain_abuse.py +3 -3
  39. psengine-2.0.7/psengine/playbook_alerts/models/pba_geopolitics_facility.py +86 -0
  40. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/pba_identity_exposures.py +1 -1
  41. psengine-2.0.7/psengine/playbook_alerts/models/pba_malware_report.py +55 -0
  42. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/pba_third_party_risk.py +5 -13
  43. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/pa_category.py +2 -1
  44. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/playbook_alert_mgr.py +210 -196
  45. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/playbook_alerts.py +262 -62
  46. {psengine-2.0.5 → psengine-2.0.7}/psengine/rf_client.py +38 -17
  47. {psengine-2.0.5 → psengine-2.0.7}/psengine/risklists/models.py +1 -0
  48. {psengine-2.0.5 → psengine-2.0.7}/psengine/risklists/risklist_mgr.py +15 -2
  49. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/complex_entity.py +11 -12
  50. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/helpers.py +4 -5
  51. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/rf_bundle.py +5 -7
  52. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/util.py +1 -1
  53. {psengine-2.0.5 → psengine-2.0.7}/psengine.egg-info/PKG-INFO +3 -3
  54. {psengine-2.0.5 → psengine-2.0.7}/psengine.egg-info/SOURCES.txt +7 -1
  55. {psengine-2.0.5 → psengine-2.0.7}/psengine.egg-info/requires.txt +1 -1
  56. {psengine-2.0.5 → psengine-2.0.7}/pyproject.toml +3 -7
  57. psengine-2.0.5/psengine.egg-info/entry_points.txt +0 -2
  58. {psengine-2.0.5 → psengine-2.0.7}/LICENSE +0 -0
  59. {psengine-2.0.5 → psengine-2.0.7}/README.rst +0 -0
  60. {psengine-2.0.5 → psengine-2.0.7}/psengine/__init__.py +0 -0
  61. {psengine-2.0.5 → psengine-2.0.7}/psengine/_sdk_id.py +0 -0
  62. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/__init__.py +0 -0
  63. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/constants.py +0 -0
  64. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/errors.py +0 -0
  65. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/helpers.py +0 -0
  66. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/models.py +0 -0
  67. {psengine-2.0.5 → psengine-2.0.7}/psengine/analyst_notes/note_mgr.py +0 -0
  68. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/__init__.py +0 -0
  69. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/classic_alert.py +0 -0
  70. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/constants.py +0 -0
  71. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/errors.py +0 -0
  72. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/helpers.py +0 -0
  73. {psengine-2.0.5 → psengine-2.0.7}/psengine/classic_alerts/models.py +0 -0
  74. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/__init__.py +0 -0
  75. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/collective_insights.py +0 -0
  76. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/constants.py +0 -0
  77. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/errors.py +0 -0
  78. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/insight.py +0 -0
  79. {psengine-2.0.5 → psengine-2.0.7}/psengine/collective_insights/models.py +0 -0
  80. {psengine-2.0.5 → psengine-2.0.7}/psengine/common_models.py +0 -0
  81. {psengine-2.0.5 → psengine-2.0.7}/psengine/config/__init__.py +0 -0
  82. {psengine-2.0.5 → psengine-2.0.7}/psengine/config/config.py +0 -0
  83. {psengine-2.0.5 → psengine-2.0.7}/psengine/config/errors.py +0 -0
  84. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/__init__.py +0 -0
  85. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/detection_rule.py +0 -0
  86. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/errors.py +0 -0
  87. {psengine-2.0.5 → psengine-2.0.7}/psengine/detection/models.py +0 -0
  88. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/__init__.py +0 -0
  89. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/constants.py +0 -0
  90. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/errors.py +0 -0
  91. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/models/base_enriched_entity.py +0 -0
  92. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/models/lookup.py +0 -0
  93. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/models/soar.py +0 -0
  94. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/soar.py +0 -0
  95. {psengine-2.0.5 → psengine-2.0.7}/psengine/enrich/soar_mgr.py +3 -3
  96. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/__init__.py +0 -0
  97. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/constants.py +0 -0
  98. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/errors.py +0 -0
  99. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_lists/models.py +0 -0
  100. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_match/__init__.py +0 -0
  101. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_match/entity_match.py +0 -0
  102. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_match/entity_match_mgr.py +0 -0
  103. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_match/errors.py +0 -0
  104. {psengine-2.0.5 → psengine-2.0.7}/psengine/entity_match/models.py +0 -0
  105. {psengine-2.0.5 → psengine-2.0.7}/psengine/errors.py +0 -0
  106. {psengine-2.0.5 → psengine-2.0.7}/psengine/helpers/__init__.py +0 -0
  107. {psengine-2.0.5 → psengine-2.0.7}/psengine/logger/__init__.py +0 -0
  108. {psengine-2.0.5 → psengine-2.0.7}/psengine/logger/constants.py +0 -0
  109. {psengine-2.0.5 → psengine-2.0.7}/psengine/logger/errors.py +0 -0
  110. {psengine-2.0.5 → psengine-2.0.7}/psengine/logger/rf_logger.py +0 -0
  111. {psengine-2.0.5 → psengine-2.0.7}/psengine/markdown/__init__.py +0 -0
  112. {psengine-2.0.5 → psengine-2.0.7}/psengine/markdown/models.py +0 -0
  113. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/panel_log.py +0 -0
  114. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/panel_status.py +0 -0
  115. {psengine-2.0.5 → psengine-2.0.7}/psengine/playbook_alerts/models/search_endpoint.py +0 -0
  116. {psengine-2.0.5 → psengine-2.0.7}/psengine/risklists/__init__.py +0 -0
  117. {psengine-2.0.5 → psengine-2.0.7}/psengine/risklists/constants.py +0 -0
  118. {psengine-2.0.5 → psengine-2.0.7}/psengine/risklists/errors.py +0 -0
  119. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/__init__.py +0 -0
  120. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/base_stix_entity.py +0 -0
  121. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/constants.py +0 -0
  122. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/enriched_indicator.py +0 -0
  123. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/errors.py +0 -0
  124. {psengine-2.0.5 → psengine-2.0.7}/psengine/stix2/simple_entity.py +0 -0
  125. {psengine-2.0.5 → psengine-2.0.7}/psengine.egg-info/dependency_links.txt +0 -0
  126. {psengine-2.0.5 → psengine-2.0.7}/psengine.egg-info/top_level.txt +0 -0
  127. {psengine-2.0.5 → psengine-2.0.7}/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.7
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.7'
@@ -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,8 @@ 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
+ '_Reference text is missing, check the Recorded Future '
98
+ f'{link("Portal", str(classic_alert.url.portal))} for more information._\n'
94
99
  )
95
100
 
96
101
  if include_triggered_by:
@@ -101,7 +106,7 @@ def _process_hit_fragment(
101
106
  triggered_by = TRIGGERED_BY_HTML.format(triggered_by)
102
107
  content.append(triggered_by)
103
108
  else:
104
- content.append(f"{bold('Triggered By:')}\n+ {triggered_by}\n")
109
+ content.append(f'{bold("Triggered By:")}\n+ {triggered_by}\n')
105
110
 
106
111
  return title_line, content
107
112
 
@@ -125,7 +130,7 @@ def _process_entities(entities, hit) -> list[list[str]]:
125
130
 
126
131
 
127
132
  def _hits_markdown(
128
- classic_alert,
133
+ classic_alert: 'ClassicAlert',
129
134
  hits,
130
135
  include_fragment_entities: bool = True,
131
136
  include_triggered_by: bool = True,
@@ -167,7 +172,7 @@ def _hits_markdown(
167
172
  return sections
168
173
 
169
174
 
170
- def _enriched_entities_markdown(classic_alert) -> list:
175
+ def _enriched_entities_markdown(classic_alert: 'ClassicAlert') -> list:
171
176
  results = []
172
177
  for entity in classic_alert.enriched_entities:
173
178
  if not entity.evidence:
@@ -175,10 +180,10 @@ def _enriched_entities_markdown(classic_alert) -> list:
175
180
 
176
181
  criticality = entity.criticality
177
182
  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",
183
+ f'{bold("Risk Score:")} {criticality.score}',
184
+ f'{bold("Criticality:")} {criticality.name}',
185
+ f'{bold("Triggered:")} {criticality.triggered.strftime(TIMESTAMP_STR)}',
186
+ f'{bold("Last Triggered:")} {criticality.last_triggered.strftime(TIMESTAMP_STR)} \n\n',
182
187
  ]
183
188
 
184
189
  evidences = []
@@ -197,7 +202,7 @@ def _enriched_entities_markdown(classic_alert) -> list:
197
202
  return results
198
203
 
199
204
 
200
- def _target_entities_markdown(classic_alert, html_tags: bool = False) -> list:
205
+ def _target_entities_markdown(classic_alert: 'ClassicAlert', html_tags: bool = False) -> list:
201
206
  results = []
202
207
  for entity in classic_alert.enriched_entities:
203
208
  result = {'title': f'Target {entity.entity.name}'}
@@ -213,17 +218,16 @@ def _target_entities_markdown(classic_alert, html_tags: bool = False) -> list:
213
218
  return results
214
219
 
215
220
 
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))}",
221
+ def _create_summary_section(ca: 'ClassicAlert') -> None:
222
+ return [
223
+ f'{bold("ID:")} {ca.id_} ',
224
+ f'{bold("Triggered:")} {ca.log.triggered.strftime(TIMESTAMP_STR)} ',
225
+ f'{bold("Alerting Rule:")} {ca.rule.name} ',
226
+ f'{link("API", str(ca.url.api))} | {link("Portal", str(ca.url.portal))}',
222
227
  ]
223
- return summary_content
224
228
 
225
229
 
226
- def _get_entities_to_defang(classic_alert) -> set:
230
+ def _get_entities_to_defang(classic_alert: 'ClassicAlert') -> set:
227
231
  """Return a set of IOC entities to defang from the classic_alert hits."""
228
232
  if not classic_alert.hits:
229
233
  return set()
@@ -233,35 +237,38 @@ def _get_entities_to_defang(classic_alert) -> set:
233
237
  for entity in chain.from_iterable(h.entities for h in classic_alert.hits)
234
238
  if entity.type_ in MARKDOWN_ENTITY_TYPES_TO_DEFANG
235
239
  }
236
- defanged_entities = raw_entities.union({esc_format(ent, esc=True) for ent in raw_entities})
237
- return defanged_entities
240
+ return raw_entities.union({esc_format(ent, esc=True) for ent in raw_entities})
238
241
 
239
242
 
240
- def _add_summary_section(md_maker: MarkdownMaker, classic_alert) -> None:
243
+ def _add_summary_section(md_maker: MarkdownMaker, classic_alert: 'ClassicAlert') -> None:
241
244
  """Adds the 'Summary' section to the markdown builder."""
242
245
  md_maker.add_title(_clean_title(classic_alert.title))
243
246
  md_maker.add_section('Summary', _create_summary_section(classic_alert))
244
247
 
245
248
 
246
- def _add_owner_org_section(md_maker: MarkdownMaker, classic_alert, owner_org: bool) -> None:
249
+ def _add_owner_org_section(
250
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', owner_org: bool
251
+ ) -> None:
247
252
  """Adds 'Owner Organisation Details' section if owner_org is True and details are present."""
248
253
  if owner_org and classic_alert.owner_organisation_details:
249
254
  md_maker.add_section('Owner Organisation Details', _owner_org_markdown(classic_alert))
250
255
 
251
256
 
252
- def _add_ai_insights_section(md_maker: MarkdownMaker, classic_alert, ai_insights: bool) -> None:
257
+ def _add_ai_insights_section(
258
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', ai_insights: bool
259
+ ) -> None:
253
260
  """Adds the 'AI Insights' sections if ai_insights is True and data is present."""
254
261
  if ai_insights and classic_alert.ai_insights:
255
262
  if classic_alert.ai_insights.text:
256
263
  md_maker.add_section('AI Insights', [classic_alert.ai_insights.text])
257
264
  if classic_alert.ai_insights.comment:
258
265
  md_maker.add_section(
259
- 'AI Insights', [f"{bold('Comment:')} {classic_alert.ai_insights.comment}"]
266
+ 'AI Insights', [f'{bold("Comment:")} {classic_alert.ai_insights.comment}']
260
267
  )
261
268
 
262
269
 
263
270
  def _add_enriched_entities_sections(
264
- md_maker: MarkdownMaker, classic_alert, html_tags: bool
271
+ md_maker: MarkdownMaker, classic_alert: 'ClassicAlert', html_tags: bool
265
272
  ) -> None:
266
273
  """Adds sections related to enriched entities (evidence and references)."""
267
274
  if any(x.evidence for x in classic_alert.enriched_entities):
@@ -277,7 +284,7 @@ def _add_enriched_entities_sections(
277
284
 
278
285
  def _add_hits_section_if_no_enriched_entities(
279
286
  md_maker: MarkdownMaker,
280
- classic_alert,
287
+ classic_alert: 'ClassicAlert',
281
288
  fragment_entities: bool,
282
289
  triggered_by: bool,
283
290
  html_tags: bool,
@@ -297,7 +304,7 @@ def _add_hits_section_if_no_enriched_entities(
297
304
 
298
305
 
299
306
  def _markdown_alert(
300
- classic_alert,
307
+ classic_alert: 'ClassicAlert',
301
308
  owner_org: bool = False,
302
309
  ai_insights: bool = True,
303
310
  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