regscale-cli 6.23.0.0__py3-none-any.whl → 6.24.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of regscale-cli might be problematic. Click here for more details.

Files changed (44) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +2 -0
  3. regscale/integrations/commercial/__init__.py +1 -0
  4. regscale/integrations/commercial/sarif/sarif_converter.py +1 -1
  5. regscale/integrations/commercial/wizv2/click.py +109 -2
  6. regscale/integrations/commercial/wizv2/compliance_report.py +1485 -0
  7. regscale/integrations/commercial/wizv2/constants.py +72 -2
  8. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  9. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  10. regscale/integrations/commercial/wizv2/issue.py +775 -27
  11. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  12. regscale/integrations/commercial/wizv2/reports.py +243 -0
  13. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  14. regscale/integrations/compliance_integration.py +304 -51
  15. regscale/integrations/due_date_handler.py +210 -0
  16. regscale/integrations/public/cci_importer.py +444 -0
  17. regscale/integrations/scanner_integration.py +718 -153
  18. regscale/models/integration_models/CCI_List.xml +1 -0
  19. regscale/models/integration_models/cisa_kev_data.json +61 -3
  20. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  21. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +3 -3
  22. regscale/models/regscale_models/form_field_value.py +1 -1
  23. regscale/models/regscale_models/milestone.py +1 -0
  24. regscale/models/regscale_models/regscale_model.py +225 -60
  25. regscale/models/regscale_models/security_plan.py +3 -2
  26. regscale/regscale.py +7 -0
  27. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/METADATA +9 -9
  28. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/RECORD +44 -27
  29. tests/fixtures/test_fixture.py +13 -8
  30. tests/regscale/integrations/public/__init__.py +0 -0
  31. tests/regscale/integrations/public/test_alienvault.py +220 -0
  32. tests/regscale/integrations/public/test_cci.py +458 -0
  33. tests/regscale/integrations/public/test_cisa.py +1021 -0
  34. tests/regscale/integrations/public/test_emass.py +518 -0
  35. tests/regscale/integrations/public/test_fedramp.py +851 -0
  36. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  37. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  38. tests/regscale/integrations/public/test_oscal.py +453 -0
  39. tests/regscale/models/test_form_field_value_integration.py +304 -0
  40. tests/regscale/models/test_module_integration.py +582 -0
  41. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/LICENSE +0 -0
  42. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/WHEEL +0 -0
  43. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/entry_points.txt +0 -0
  44. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1021 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Test CISA Integrations"""
4
+
5
+ from datetime import datetime
6
+ from unittest import TestCase
7
+ from unittest.mock import MagicMock, patch
8
+ from urllib.error import URLError
9
+ import dateutil.parser as dparser
10
+ from requests.exceptions import RequestException
11
+
12
+ from bs4 import Tag, BeautifulSoup
13
+ import pytest
14
+ from regscale.integrations.public.cisa import (
15
+ build_threat,
16
+ convert_date_string,
17
+ filter_elements,
18
+ fuzzy_find_date,
19
+ gen_soup,
20
+ insert_or_upd_threat,
21
+ is_url,
22
+ merge_old,
23
+ parse_html,
24
+ process_element,
25
+ process_params,
26
+ process_threats,
27
+ unique,
28
+ update_regscale_links,
29
+ alerts,
30
+ parse_details,
31
+ pull_cisa_kev,
32
+ update_regscale,
33
+ update_regscale_threats,
34
+ )
35
+ from regscale.models import Threat, Link
36
+ from regscale.core.app.application import Application
37
+ from tests import CLITestFixture
38
+
39
+
40
+ class TestCisa(CLITestFixture, TestCase):
41
+ """Test CISA Integrations"""
42
+
43
+ @patch("regscale.integrations.public.cisa.Link.batch_update", return_value=None)
44
+ def test_update_regscale_links_no_threats(self, mock_batch_update):
45
+ """Test update_regscale_links function with no threats"""
46
+ threats = []
47
+ update_regscale_links(threats)
48
+ mock_batch_update.assert_called_once_with(threats)
49
+
50
+ @patch("regscale.integrations.public.cisa.Link.batch_update", return_value=None)
51
+ def test_update_regscale_links(self, mock_batch_update):
52
+ """Test update_regscale_links function"""
53
+ threats = [
54
+ Threat(
55
+ id=1,
56
+ threatType="Vulnerability",
57
+ status="Under Investigation",
58
+ source="Open Source",
59
+ title="Threat 1",
60
+ targetType="Target Type",
61
+ description="Description https://example.com",
62
+ )
63
+ ]
64
+ links = [Link(parentID=1, parentModule="threats", url="https://example.com", title="Threat 1")]
65
+
66
+ update_regscale_links(threats)
67
+ mock_batch_update.assert_called_once_with(links)
68
+
69
+ def test_process_threats_without_threats(self):
70
+ """Test process_threats with no threats"""
71
+ threats = []
72
+ unique_threats = set()
73
+ reg_threats = []
74
+ insert_threats, update_threats = process_threats(threats, unique_threats, reg_threats)
75
+ assert insert_threats == []
76
+ assert update_threats == []
77
+
78
+ def test_process_threats_insert(self):
79
+ """Test process_threats with insertions"""
80
+ threats = [
81
+ Threat(
82
+ id=1,
83
+ threatType="Vulnerability",
84
+ status="Under Investigation",
85
+ source="Open Source",
86
+ title="Threat 1",
87
+ )
88
+ ]
89
+ unique_threats = set()
90
+ reg_threats = []
91
+ insert_threats, update_threats = process_threats(threats, unique_threats, reg_threats)
92
+ assert insert_threats == [threats[0].dict()]
93
+ assert update_threats == []
94
+
95
+ def test_process_threats_update(self):
96
+ """Test process_threats with updates"""
97
+ threats = [
98
+ Threat(
99
+ id=1,
100
+ threatType="Vulnerability",
101
+ status="Closed",
102
+ source="Open Source",
103
+ title="Threat 1",
104
+ description="Description https://example.com",
105
+ )
106
+ ]
107
+ unique_threats = set(["Description https://example.com"])
108
+ reg_threats = [
109
+ Threat(
110
+ id=1,
111
+ threatType="Vulnerability",
112
+ status="Under Investigation",
113
+ source="Open Source",
114
+ title="Threat 1",
115
+ description="Description https://example.com",
116
+ )
117
+ ]
118
+ insert_threats, update_threats = process_threats(threats, unique_threats, reg_threats)
119
+ assert insert_threats == []
120
+ assert update_threats == [reg_threats[0].dict()]
121
+
122
+ @patch("regscale.integrations.public.cisa.parse_details")
123
+ def test_build_empty_threat(self, mock_parse_details):
124
+ """Test build_threat function with empty threat"""
125
+ mock_parse_details.return_value = None
126
+ app = MagicMock()
127
+ threat = build_threat(app, "https://example.com", "Description https://example.com", "Threat 1")
128
+ assert threat is None
129
+
130
+ @patch("regscale.integrations.public.cisa.parse_details")
131
+ @patch("regscale.integrations.public.cisa.Threat")
132
+ def test_build_threat(self, mock_threat_class, mock_parse_details):
133
+ """Test build_threat function"""
134
+ app = MagicMock()
135
+ app.config = {"userId": "1"}
136
+
137
+ mock_threat_instance = MagicMock()
138
+ mock_threat_class.return_value = mock_threat_instance
139
+ mock_threat_class.xstr.return_value = ""
140
+
141
+ dat = (
142
+ "2025-05-26",
143
+ ["Vulnerability 1"],
144
+ ["Mitigation 1"],
145
+ ["Note 1"],
146
+ )
147
+ mock_parse_details.return_value = dat
148
+
149
+ threat = build_threat(app, "https://example.com", "Description https://example.com", "Threat 1")
150
+
151
+ mock_threat_class.assert_called_once_with(
152
+ uuid=Threat.xstr(None),
153
+ title="Threat 1",
154
+ threatType="Specific",
155
+ threatOwnerId="1",
156
+ dateIdentified="2025-05-26",
157
+ targetType="Other",
158
+ source="Open Source",
159
+ description="Description https://example.com",
160
+ vulnerabilityAnalysis="Vulnerability 1",
161
+ mitigations="Mitigation 1",
162
+ notes="Note 1",
163
+ dateCreated="2025-05-26",
164
+ status="Initial Report/Notification",
165
+ )
166
+
167
+ assert threat is not None
168
+ assert threat == mock_threat_instance
169
+
170
+ def test_filter_elements_filter_list(self):
171
+ """Test filter_elements function blocked by filter list"""
172
+ filter_list = [
173
+ "c-figure__media",
174
+ "c-product-survey__text-area",
175
+ "l-full__footer",
176
+ "usa-navbar",
177
+ ]
178
+ for cls in filter_list:
179
+ element = Tag(name="p", attrs={"class": [cls]})
180
+ assert filter_elements(element) is None
181
+
182
+ def test_filter_elements_tags(self):
183
+ """Test filter_elements function"""
184
+ tags = ["p", "li", "div", "table"]
185
+ for tag in tags:
186
+ element = Tag(name=tag)
187
+ assert filter_elements(element) == element
188
+
189
+ def test_filter_elements_bad_tag(self):
190
+ """Test filter_elements function with bad tag"""
191
+ element = Tag(name="h1") # tag not in list
192
+ assert filter_elements(element) is None
193
+
194
+ def test_process_params_filtered_element(self):
195
+ """Test process_params function with filtered element"""
196
+ element = Tag(name="p", attrs={"class": ["c-figure__media"]})
197
+ vulnerability, mitigation, notes = process_params(element, "", [], [], [])
198
+ assert vulnerability == []
199
+ assert mitigation == []
200
+ assert notes == []
201
+
202
+ def test_process_params_summary(self):
203
+ """Test process_params function with summary"""
204
+ element = Tag(name="p")
205
+ element.string = "Lorem ipsum or something"
206
+
207
+ # check summary is appended to notes
208
+ vulnerability, mitigation, notes = process_params(element, "summary", [], [], [])
209
+ assert vulnerability == []
210
+ assert mitigation == []
211
+ assert notes == ["<p>Lorem ipsum or something</p>"]
212
+
213
+ # check summary is not appended to notes if already in notes
214
+ vulnerability, mitigation, notes = process_params(element, "summary", [], [], notes)
215
+ assert vulnerability == []
216
+ assert mitigation == []
217
+ assert notes == ["<p>Lorem ipsum or something</p>"]
218
+
219
+ def test_process_params_vulnerability(self):
220
+ """Test process_params function with vulnerability"""
221
+ element = Tag(name="p")
222
+ element.string = "Lorem ipsum or something"
223
+
224
+ # check vulnerability is appended to vulnerability
225
+ vulnerability, mitigation, notes = process_params(element, "technical details", [], [], [])
226
+ assert vulnerability == ["<p>Lorem ipsum or something</p>"]
227
+ assert mitigation == []
228
+ assert notes == []
229
+
230
+ # check vulnerability is not appended to vulnerability if already in vulnerability
231
+ vulnerability, mitigation, notes = process_params(element, "technical details", vulnerability, [], [])
232
+ assert vulnerability == ["<p>Lorem ipsum or something</p>"]
233
+ assert mitigation == []
234
+ assert notes == []
235
+
236
+ def test_process_params_mitigation(self):
237
+ """Test process_params function with mitigation"""
238
+ element = Tag(name="p")
239
+ element.string = "Lorem ipsum or something"
240
+
241
+ # check mitigation is appended to mitigation
242
+ vulnerability, mitigation, notes = process_params(element, "mitigations", [], [], [])
243
+ assert vulnerability == []
244
+ assert mitigation == ["<p>Lorem ipsum or something</p>"]
245
+ assert notes == []
246
+
247
+ # check mitigation is not appended to mitigation if already in mitigation
248
+ vulnerability, mitigation, notes = process_params(element, "mitigations", [], mitigation, [])
249
+ assert vulnerability == []
250
+ assert mitigation == ["<p>Lorem ipsum or something</p>"]
251
+ assert notes == []
252
+
253
+ def test_process_element_basic_non_header(self):
254
+ """Test process_element with a basic non-header element"""
255
+ dat = Tag(name="p")
256
+ dat.string = "Some content"
257
+ last_header = {"type": "h2", "title": "Previous Header"}
258
+ last_h3 = "Previous H3"
259
+ nav_string = "technical details"
260
+ div_list = ["technical details", "mitigations", "summary"]
261
+ args = (
262
+ dat,
263
+ last_header,
264
+ last_h3,
265
+ nav_string,
266
+ div_list,
267
+ [], # vulnerability
268
+ [], # mitigation
269
+ [], # notes
270
+ )
271
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
272
+ assert new_last_header == last_header
273
+ assert new_last_h3 == last_h3
274
+ assert new_nav_string == nav_string
275
+
276
+ def test_process_element_header_update(self):
277
+ """Test process_element updates headers for h1-h6 elements"""
278
+ for header_type in ["h1", "h2", "h3", "h4", "h5", "h6"]:
279
+ dat = Tag(name=header_type)
280
+ dat.string = f"New {header_type} Header"
281
+ last_header = {"type": "h2", "title": "Previous Header"}
282
+ last_h3 = "Previous H3"
283
+ args = (
284
+ dat,
285
+ last_header,
286
+ last_h3,
287
+ "", # nav_string
288
+ [], # div_list
289
+ [], # vulnerability
290
+ [], # mitigation
291
+ [], # notes
292
+ )
293
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
294
+ assert new_last_header == {"type": header_type, "title": f"New {header_type} Header"}
295
+ # Only h3 should update last_h3
296
+ assert new_last_h3 == (f"New {header_type} Header" if header_type == "h3" else last_h3)
297
+ assert new_nav_string == ""
298
+
299
+ def test_process_element_nav_string_update(self):
300
+ """Test process_element updates nav_string when text matches div_list"""
301
+ div_list = ["technical details", "mitigations", "summary"]
302
+ for nav_text in div_list:
303
+ dat = Tag(name="p")
304
+ dat.string = nav_text
305
+ last_header = {"type": "h2", "title": "Some Header"}
306
+ last_h3 = "Some H3"
307
+ args = (
308
+ dat,
309
+ last_header,
310
+ last_h3,
311
+ "", # nav_string
312
+ div_list,
313
+ [], # vulnerability
314
+ [], # mitigation
315
+ [], # notes
316
+ )
317
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
318
+ assert new_last_header == last_header
319
+ assert new_last_h3 == last_h3
320
+ assert new_nav_string == nav_text
321
+
322
+ def test_process_element_process_params_conditions(self):
323
+ """Test process_element calls process_params when all conditions are met"""
324
+ dat = Tag(name="p")
325
+ dat.string = "Content to process"
326
+ last_header = {"type": "h2", "title": "Some Header"}
327
+ last_h3 = "technical details" # matches div_list
328
+ nav_string = "technical details" # matches div_list
329
+ div_list = ["technical details", "mitigations", "summary"]
330
+ vulnerability = []
331
+ mitigation = []
332
+ notes = []
333
+ args = (
334
+ dat,
335
+ last_header,
336
+ last_h3,
337
+ nav_string,
338
+ div_list,
339
+ vulnerability,
340
+ mitigation,
341
+ notes,
342
+ )
343
+ with patch("regscale.integrations.public.cisa.process_params") as mock_process_params:
344
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
345
+ mock_process_params.assert_called_once_with(dat, nav_string, vulnerability, mitigation, notes)
346
+ assert new_last_header == last_header
347
+ assert new_last_h3 == last_h3
348
+ assert new_nav_string == nav_string
349
+
350
+ def test_process_element_no_process_params_missing_last_h3(self):
351
+ """Test process_element doesn't call process_params when last_h3 is missing"""
352
+ dat = Tag(name="p")
353
+ dat.string = "Content to process"
354
+ last_header = {"type": "h2", "title": "Some Header"}
355
+ last_h3 = None # Missing last_h3
356
+ nav_string = "technical details"
357
+ div_list = ["technical details", "mitigations", "summary"]
358
+ args = (
359
+ dat,
360
+ last_header,
361
+ last_h3,
362
+ nav_string,
363
+ div_list,
364
+ [], # vulnerability
365
+ [], # mitigation
366
+ [], # notes
367
+ )
368
+ with patch("regscale.integrations.public.cisa.process_params") as mock_process_params:
369
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
370
+ mock_process_params.assert_not_called()
371
+ assert new_last_header == last_header
372
+ assert new_last_h3 == last_h3
373
+ assert new_nav_string == nav_string
374
+
375
+ def test_process_element_no_process_params_missing_nav_string(self):
376
+ """Test process_element doesn't call process_params when nav_string is missing"""
377
+ dat = Tag(name="p")
378
+ dat.string = "Content to process"
379
+ last_header = {"type": "h2", "title": "Some Header"}
380
+ last_h3 = "technical details"
381
+ nav_string = "" # Missing nav_string
382
+ div_list = ["technical details", "mitigations", "summary"]
383
+ args = (
384
+ dat,
385
+ last_header,
386
+ last_h3,
387
+ nav_string,
388
+ div_list,
389
+ [], # vulnerability
390
+ [], # mitigation
391
+ [], # notes
392
+ )
393
+ with patch("regscale.integrations.public.cisa.process_params") as mock_process_params:
394
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
395
+ mock_process_params.assert_not_called()
396
+ assert new_last_header == last_header
397
+ assert new_last_h3 == last_h3
398
+ assert new_nav_string == nav_string
399
+
400
+ def test_process_element_no_process_params_h3_not_in_div_list(self):
401
+ """Test process_element doesn't call process_params when last_h3 doesn't match div_list"""
402
+ dat = Tag(name="p")
403
+ dat.string = "Content to process"
404
+ last_header = {"type": "h2", "title": "Some Header"}
405
+ last_h3 = "unmatched h3" # Doesn't match div_list
406
+ nav_string = "technical details"
407
+ div_list = ["technical details", "mitigations", "summary"]
408
+ args = (
409
+ dat,
410
+ last_header,
411
+ last_h3,
412
+ nav_string,
413
+ div_list,
414
+ [], # vulnerability
415
+ [], # mitigation
416
+ [], # notes
417
+ )
418
+ with patch("regscale.integrations.public.cisa.process_params") as mock_process_params:
419
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
420
+ mock_process_params.assert_not_called()
421
+ assert new_last_header == last_header
422
+ assert new_last_h3 == last_h3
423
+ assert new_nav_string == nav_string
424
+
425
+ def test_process_element_no_process_params_content_in_div_list(self):
426
+ """Test process_element doesn't call process_params when content matches div_list"""
427
+ dat = Tag(name="p")
428
+ dat.string = "technical details" # Matches div_list
429
+ last_header = {"type": "h2", "title": "Some Header"}
430
+ last_h3 = "technical details"
431
+ nav_string = "technical details"
432
+ div_list = ["technical details", "mitigations", "summary"]
433
+ args = (
434
+ dat,
435
+ last_header,
436
+ last_h3,
437
+ nav_string,
438
+ div_list,
439
+ [], # vulnerability
440
+ [], # mitigation
441
+ [], # notes
442
+ )
443
+ with patch("regscale.integrations.public.cisa.process_params") as mock_process_params:
444
+ new_last_header, new_last_h3, new_nav_string = process_element(args)
445
+ mock_process_params.assert_not_called()
446
+ assert new_last_header == last_header
447
+ assert new_last_h3 == last_h3
448
+ assert new_nav_string == nav_string
449
+
450
+ @patch("regscale.integrations.public.cisa.fuzzy_find_date", return_value="2023-01-01T00:00:00")
451
+ @patch("regscale.integrations.public.cisa.gen_soup")
452
+ def test_parse_details_with_content(self, mock_gen_soup, mock_find_date):
453
+ """Test parse_details function with actual content processing"""
454
+ html = """
455
+ <div class="l-full__main">
456
+ <h2>Technical Details</h2>
457
+ <h3>technical details</h3>
458
+ <p>Vulnerability content</p>
459
+ <h2>Mitigations</h2>
460
+ <p>Mitigation content</p>
461
+ <h2>Summary</h2>
462
+ <p>Summary content</p>
463
+ </div>
464
+ """
465
+ mock_gen_soup.return_value = BeautifulSoup(html, "html.parser")
466
+
467
+ result = parse_details("https://example.com")
468
+
469
+ assert result is not None
470
+ assert result[0] == "2023-01-01T00:00:00"
471
+ assert result[1] == ["<p>Vulnerability content</p>"]
472
+ assert result[2] == ["<p>Mitigation content</p>"]
473
+ assert result[3] == ["<p>Summary content</p>"]
474
+
475
+ @patch("regscale.integrations.public.cisa.fuzzy_find_date", return_value="2023-01-01T00:00:00")
476
+ @patch("regscale.integrations.public.cisa.gen_soup")
477
+ def test_parse_details_empty_content(self, mock_gen_soup, mock_find_date):
478
+ """Test parse_details function with no content to process"""
479
+ html = """
480
+ <div class="l-full__main">
481
+ <h2>Some Header</h2>
482
+ <p>Some content that doesn't match any sections</p>
483
+ </div>
484
+ """
485
+ mock_gen_soup.return_value = BeautifulSoup(html, "html.parser")
486
+
487
+ result = parse_details("https://example.com")
488
+
489
+ assert result is not None
490
+ assert result[0] == "2023-01-01T00:00:00"
491
+ assert result[1] == ["See Link for details."]
492
+ assert result[2] == ["See Link for details."]
493
+ assert result[3] == ["See Link for details."]
494
+
495
+ @patch("regscale.integrations.public.cisa.fuzzy_find_date", return_value=None)
496
+ @patch("regscale.integrations.public.cisa.gen_soup", return_value=MagicMock())
497
+ def test_parse_details_no_date(self, mock_gen_soup, mock_find_date):
498
+ """Test parse_details function with no date"""
499
+ result = parse_details("https://example.com")
500
+ assert result is None
501
+
502
+ def test_fuzzy_find_date_first_regex(self):
503
+ """Test fuzzy_find_date function with first call match"""
504
+ html1 = """<div class="c-field__content">Last Revised: January 15, 2024</div>"""
505
+ html2 = """<div class="c-field__content">Release Date: January 15, 2024</div>"""
506
+ soup1 = BeautifulSoup(html1, "html.parser")
507
+ soup2 = BeautifulSoup(html2, "html.parser")
508
+ assert fuzzy_find_date(soup1) == "2024-01-15T00:00:00"
509
+ assert fuzzy_find_date(soup2) == "2024-01-15T00:00:00"
510
+
511
+ def test_fuzzy_find_date(self):
512
+ """Test fuzzy_find_date's recursive functionality"""
513
+ html1 = """
514
+ <div class="c-field__content">Some other content</div>
515
+ <div class="c-field__content">More content</div>
516
+ <div class="c-field__content">January 15, 2024</div>
517
+ """
518
+
519
+ for i in range(0, 5):
520
+ with patch(
521
+ "regscale.integrations.public.cisa.fuzzy_find_date", wraps=fuzzy_find_date
522
+ ) as mock_fuzzy_find_date:
523
+ html1 = '<div class="c-field__content">Content</div>' + html1 if i > 0 else html1
524
+ soup1 = BeautifulSoup(html1, "html.parser")
525
+ assert fuzzy_find_date(soup1) == "2024-01-15T00:00:00"
526
+ assert mock_fuzzy_find_date.call_count == i
527
+
528
+ def test_fuzzy_find_date_not_found(self):
529
+ """Test fuzzy_find_date function when we run out of attempts"""
530
+ html1 = """<div class="c-field__content">Invalid Date</div>"""
531
+ soup1 = BeautifulSoup(html1, "html.parser")
532
+ with patch("regscale.integrations.public.cisa.fuzzy_find_date", wraps=fuzzy_find_date) as mock_fuzzy_find_date:
533
+ # throw parser error to skip date parsing and force next attempt
534
+ with patch("regscale.integrations.public.cisa.BeautifulSoup.find_all", side_effect=dparser.ParserError):
535
+ assert fuzzy_find_date(soup1) is None
536
+ assert mock_fuzzy_find_date.call_count == 5 # Should try all 5 attempts
537
+
538
+ def test_gen_soup(self):
539
+ """Test gen_soup function"""
540
+ html = "<html><body>Test</body></html>"
541
+ mock_response = MagicMock()
542
+ mock_response.content = html
543
+ mock_response.raise_for_status = MagicMock()
544
+
545
+ with patch("regscale.integrations.public.cisa.Api.get", return_value=mock_response) as mock_api_get:
546
+ soup = gen_soup("https://example.com")
547
+ assert soup is not None
548
+ mock_api_get.assert_called_once_with("https://example.com")
549
+ mock_response.raise_for_status.assert_called_once()
550
+
551
+ def test_gen_soup_tuple(self):
552
+ """Test gen_soup function with tuple"""
553
+ html = "<html><body>Test</body></html>"
554
+ mock_response = MagicMock()
555
+ mock_response.content = html
556
+ mock_response.raise_for_status = MagicMock()
557
+ with patch("regscale.integrations.public.cisa.Api.get", return_value=mock_response) as mock_api_get:
558
+ soup = gen_soup(("https://example.com", "https://example2.com"))
559
+ assert soup is not None
560
+ mock_api_get.assert_called_once_with("https://example.com")
561
+ mock_response.raise_for_status.assert_called_once()
562
+
563
+ def test_gen_soup_invalid_url(self):
564
+ """Test gen_soup function with invalid url"""
565
+ # Test with a string that's not a URL
566
+ with pytest.raises(URLError) as context:
567
+ gen_soup("not_a_url")
568
+ assert context.type == URLError
569
+ assert context.value.reason == "URL is invalid, exiting..."
570
+
571
+ def test_pull_cisa_kev_integration(self):
572
+ """Test pull_cisa_kev with actual call"""
573
+ # Clear any cached data
574
+ if hasattr(pull_cisa_kev, "_cached_data"):
575
+ delattr(pull_cisa_kev, "_cached_data")
576
+
577
+ data = pull_cisa_kev()
578
+ assert "title" in data.keys()
579
+ assert data["title"] == "CISA Catalog of Known Exploited Vulnerabilities"
580
+
581
+ @patch("regscale.integrations.public.cisa.Api.get")
582
+ def test_pull_cisa_kev_success(self, mock_get):
583
+ """Test pull_cisa_kev successful API call"""
584
+ # Clear any cached data
585
+ if hasattr(pull_cisa_kev, "_cached_data"):
586
+ delattr(pull_cisa_kev, "_cached_data")
587
+
588
+ mock_response = MagicMock()
589
+ mock_response.json.return_value = {"title": "Test Data"}
590
+ mock_get.return_value = mock_response
591
+
592
+ # First call should hit the API
593
+ data = pull_cisa_kev()
594
+ assert data == {"title": "Test Data"}
595
+ mock_get.assert_called_once()
596
+
597
+ # Second call should use cached data
598
+ mock_get.reset_mock()
599
+ data = pull_cisa_kev()
600
+ assert data == {"title": "Test Data"}
601
+ mock_get.assert_not_called()
602
+
603
+ @patch("regscale.integrations.public.cisa.Api.get")
604
+ def test_pull_cisa_kev_fallback(self, mock_get):
605
+ """Test pull_cisa_kev falls back to package data on error"""
606
+ # Clear any cached data
607
+ if hasattr(pull_cisa_kev, "_cached_data"):
608
+ delattr(pull_cisa_kev, "_cached_data")
609
+
610
+ mock_get.side_effect = RequestException("API Error")
611
+
612
+ data = pull_cisa_kev()
613
+ assert "title" in data
614
+ assert data["title"] == "CISA Catalog of Known Exploited Vulnerabilities"
615
+
616
+ @patch("regscale.integrations.public.cisa.Api.get")
617
+ def test_pull_cisa_kev_config_url(self, mock_get):
618
+ """Test pull_cisa_kev with custom URL from config"""
619
+ # Clear any cached data
620
+ if hasattr(pull_cisa_kev, "_cached_data"):
621
+ delattr(pull_cisa_kev, "_cached_data")
622
+
623
+ mock_response = MagicMock()
624
+ mock_response.json.return_value = {"title": "Custom Data"}
625
+ mock_get.return_value = mock_response
626
+
627
+ # Set up custom URL in config
628
+ app = Application()
629
+ kev_url = app.config["cisaKev"] or None
630
+ app.config["cisaKev"] = "https://custom.url"
631
+ app.save_config(app.config)
632
+ app.logger.info(f"cisaKev: {app.config['cisaKev']}")
633
+
634
+ data = pull_cisa_kev()
635
+ assert data == {"title": "Custom Data"}
636
+ mock_get.assert_called_once_with(url="https://custom.url", headers={}, retry_login=False)
637
+
638
+ # cleanup
639
+ if kev_url:
640
+ app.config["cisaKev"] = kev_url
641
+ else:
642
+ app.config.pop("cisaKev")
643
+ app.save_config(app.config)
644
+
645
+ @patch("regscale.integrations.public.cisa.Api.get")
646
+ def test_pull_cisa_kev_var_url(self, mock_get):
647
+ """Test pull_cisa_kev with default cisa url from integration"""
648
+ # Clear any cached data
649
+ if hasattr(pull_cisa_kev, "_cached_data"):
650
+ delattr(pull_cisa_kev, "_cached_data")
651
+
652
+ mock_response = MagicMock()
653
+ mock_response.json.return_value = {"title": "Custom Data"}
654
+ mock_get.return_value = mock_response
655
+
656
+ # Remove url from config
657
+ app = Application()
658
+ kev_url = app.config["cisaKev"] or None
659
+ app.config.pop("cisaKev")
660
+ app.save_config(app.config)
661
+
662
+ data = pull_cisa_kev()
663
+ assert data == {"title": "Custom Data"}
664
+ mock_get.assert_called_once()
665
+
666
+ # cleanup
667
+ if kev_url:
668
+ app.config["cisaKev"] = kev_url
669
+ app.save_config(app.config)
670
+
671
+ def test_merge_old(self):
672
+ """Test merge_old function"""
673
+ update_vuln = {"id": 2, "name": "Test Threat", "title": "New Title", "description": "New Description"}
674
+ old_vuln = {
675
+ "id": 1,
676
+ "uuid": "123",
677
+ "status": "Active",
678
+ "source": "CISA",
679
+ "threatType": "Specific",
680
+ "threatOwnerId": 456,
681
+ "notes": "Old Notes",
682
+ "targetType": "Other",
683
+ "dateCreated": "2024-01-01",
684
+ "isPublic": True,
685
+ "investigated": False,
686
+ "investigationResults": "Closed",
687
+ }
688
+ expected = {
689
+ "id": 1,
690
+ "name": "Test Threat",
691
+ "title": "New Title",
692
+ "description": "New Description",
693
+ "uuid": "123",
694
+ "status": "Active",
695
+ "source": "CISA",
696
+ "threatType": "Specific",
697
+ "threatOwnerId": 456,
698
+ "notes": "Old Notes",
699
+ "targetType": "Other",
700
+ "dateCreated": "2024-01-01",
701
+ "isPublic": True,
702
+ "investigated": False,
703
+ "investigationResults": "Closed",
704
+ }
705
+ assert merge_old(update_vuln, old_vuln) == expected
706
+ assert merge_old(update_vuln, {}) == update_vuln
707
+ assert merge_old({}, old_vuln) == {
708
+ "id": 1,
709
+ "uuid": "123",
710
+ "status": "Active",
711
+ "source": "CISA",
712
+ "threatType": "Specific",
713
+ "threatOwnerId": 456,
714
+ "notes": "Old Notes",
715
+ "targetType": "Other",
716
+ "dateCreated": "2024-01-01",
717
+ "isPublic": True,
718
+ "investigated": False,
719
+ "investigationResults": "Closed",
720
+ }
721
+
722
+ @patch("regscale.integrations.public.cisa.Api.post", return_value=None)
723
+ def test_insert_or_upd_threat_insert(self, mock_post):
724
+ """Test insert_or_upd_threat function"""
725
+ threat = {}
726
+ app = MagicMock()
727
+ insert_or_upd_threat(threat, app)
728
+ mock_post.assert_called_once()
729
+
730
+ @patch("regscale.integrations.public.cisa.Api.put", return_value=None)
731
+ def test_insert_or_upd_threat_update(self, mock_put):
732
+ """Test insert_or_upd_threat function"""
733
+ threat = {}
734
+ app = MagicMock()
735
+ insert_or_upd_threat(threat, app, 1)
736
+ mock_put.assert_called_once()
737
+
738
+ @patch("regscale.integrations.public.cisa.Threat.bulk_update", return_value=None)
739
+ def test_update_regscale_threats(self, mock_bulk_update):
740
+ """Test update_regscale_threats function"""
741
+ threats = [Threat()]
742
+ update_regscale_threats(threats)
743
+ mock_bulk_update.assert_called_once_with(None, threats)
744
+
745
+ @patch("regscale.integrations.public.cisa.Threat.bulk_update", return_value=None)
746
+ def test_update_regscale_threats_no_threats(self, mock_bulk_update):
747
+ """Test update_regscale_threats function with no threats"""
748
+ update_regscale_threats()
749
+ update_regscale_threats([])
750
+ mock_bulk_update.assert_not_called()
751
+
752
+ def test_convert_date_string(self):
753
+ """Test convert_date_string function"""
754
+ date_str = "2022-11-03"
755
+ assert convert_date_string(date_str) == "2022-11-03T00:00:00.000Z"
756
+
757
+ def test_unique(self):
758
+ """Test unique function"""
759
+ test_list = ["a", "b", "c", "a", "b", "c"]
760
+ assert unique(test_list) == ["a", "b", "c"]
761
+
762
+ def test_is_url(self):
763
+ """Test is_url function"""
764
+ assert is_url("https://example.com")
765
+ assert not is_url("not_a_url")
766
+ assert not is_url("")
767
+
768
+ def test_is_url_value_error(self):
769
+ """Test is_url function raising a ValueError"""
770
+ with patch("regscale.integrations.public.cisa.urlparse", side_effect=ValueError):
771
+ assert not is_url("not_a_url")
772
+
773
+ @patch("regscale.integrations.public.cisa.update_regscale_threats")
774
+ @patch("regscale.integrations.public.cisa.Threat.bulk_insert")
775
+ @patch("regscale.integrations.public.cisa.Threat.fetch_all_threats")
776
+ @patch("regscale.integrations.public.cisa.Application")
777
+ def test_update_regscale(self, mock_app, mock_fetch_threats, mock_bulk_insert, mock_update_threats):
778
+ """Test update_regscale function with both insert and update"""
779
+ # Setup mocks
780
+ mock_app_instance = MagicMock()
781
+ mock_app_instance.config = {"userId": "123"}
782
+ mock_app.return_value = mock_app_instance
783
+
784
+ # Mock existing threats
785
+ existing_threat = Threat(
786
+ id=1,
787
+ description="Qualcomm Multiple Chipsets Incorrect Authorization Vulnerability",
788
+ investigationResults="Some results",
789
+ threatType="Specific",
790
+ title="CVE-2025-21479",
791
+ threatOwnerId="123",
792
+ targetType="Other",
793
+ source="Open Source",
794
+ dateCreated=datetime.now().isoformat(),
795
+ status="Initial Report/Notification",
796
+ )
797
+ mock_fetch_threats.return_value = [existing_threat]
798
+
799
+ data = {
800
+ "title": "CISA Catalog of Known Exploited Vulnerabilities",
801
+ "catalogVersion": "2025.06.03",
802
+ "dateReleased": "2025-06-03T16:48:39.9414Z",
803
+ "count": 3,
804
+ "vulnerabilities": [
805
+ {
806
+ "cveID": "CVE-2025-21479",
807
+ "vendorProject": "Qualcomm",
808
+ "product": "Multiple Chipsets",
809
+ "vulnerabilityName": "Qualcomm Multiple Chipsets Incorrect Authorization Vulnerability",
810
+ "dateAdded": "2025-06-03",
811
+ "shortDescription": "Multiple Qualcomm chipsets contain an incorrect authorization vulnerability.",
812
+ "requiredAction": "Apply mitigations per vendor instructions",
813
+ "notes": "Please check with specific vendors",
814
+ "cwes": ["CWE-863"],
815
+ "dueDate": "2025-06-24",
816
+ },
817
+ {
818
+ "cveID": "CVE-2025-27038",
819
+ "vendorProject": "Qualcomm",
820
+ "product": "Multiple Chipsets",
821
+ "vulnerabilityName": "Qualcomm Multiple Chipsets Use-After-Free Vulnerability",
822
+ "dateAdded": "2025-06-03",
823
+ "shortDescription": "Multiple Qualcomm chipsets contain a use-after-free vulnerability.",
824
+ "requiredAction": "Apply mitigations per vendor instructions",
825
+ "notes": "Please check with specific vendors",
826
+ "cwes": ["CWE-416"],
827
+ "dueDate": "2025-06-24",
828
+ },
829
+ ],
830
+ }
831
+
832
+ try:
833
+ update_regscale(data)
834
+ except Exception as e:
835
+ assert False, "update_regscale raised exception: {}".format(e)
836
+
837
+ # Verify new threats were inserted
838
+ mock_bulk_insert.assert_called_once()
839
+ inserted_threats = mock_bulk_insert.call_args[0][1]
840
+ assert len(inserted_threats) == 1 # should only insert second vuln
841
+ assert inserted_threats[0]["title"] == "CVE-2025-27038"
842
+
843
+ # Verify existing threats were updated
844
+ mock_update_threats.assert_called_once()
845
+ updated_threats = mock_update_threats.call_args[1]["json_list"]
846
+ assert len(updated_threats) == 1 # only update first vuln
847
+ assert updated_threats[0]["title"] == "CVE-2025-21479"
848
+ assert "investigationResults" in updated_threats[0] # preserve investigation results
849
+
850
+ def test_parsing(self):
851
+ """Test link parse method"""
852
+ links = [
853
+ "https://www.cisa.gov/news-events/cybersecurity-advisories/aa23-039a",
854
+ "https://www.cisa.gov/news-events/cybersecurity-advisories/aa22-249a",
855
+ "https://www.cisa.gov/news-events/cybersecurity-advisories/aa22-131a",
856
+ ]
857
+ for link in links:
858
+ dat = parse_details(link)
859
+ assert dat is not None
860
+
861
+ @patch("regscale.core.app.api.Api.get")
862
+ def test_load_from_package(self, mock_pull_cisa_kev):
863
+ mock_pull_cisa_kev.return_value = None
864
+ data = pull_cisa_kev()
865
+ assert data is not None
866
+
867
+ @patch("regscale.integrations.public.cisa.is_valid", return_value=False)
868
+ def test_alerts_bad_app(self, mock_is_valid):
869
+ """Test alerts function with bad app"""
870
+ with pytest.raises(SystemExit) as e:
871
+ alerts(1)
872
+ mock_is_valid.assert_called_once()
873
+ assert e.type == SystemExit
874
+ assert e.value.code == 1
875
+
876
+ @patch("regscale.integrations.public.cisa.parse_html")
877
+ @patch("regscale.integrations.public.cisa.update_regscale_links")
878
+ @patch("regscale.integrations.public.cisa.update_regscale_threats")
879
+ @patch("regscale.integrations.public.cisa.Threat.fetch_all_threats")
880
+ @patch("regscale.integrations.public.cisa.process_threats")
881
+ def test_alerts_no_threats(
882
+ self,
883
+ mock_process_threats,
884
+ mock_fetch_threats,
885
+ mock_update_regscale_links,
886
+ mock_update_regscale_threats,
887
+ mock_parse_html,
888
+ ):
889
+ """Test alerts function with no threats from CISA"""
890
+ mock_fetch_threats.return_value = []
891
+ mock_parse_html.return_value = []
892
+ alerts(1)
893
+ mock_parse_html.assert_called_once()
894
+ mock_process_threats.assert_not_called()
895
+ mock_update_regscale_links.assert_not_called()
896
+ mock_update_regscale_threats.assert_not_called()
897
+
898
+ @patch("regscale.integrations.public.cisa.parse_html")
899
+ @patch("regscale.integrations.public.cisa.update_regscale_links")
900
+ @patch("regscale.integrations.public.cisa.update_regscale_threats")
901
+ @patch("regscale.integrations.public.cisa.Threat.fetch_all_threats")
902
+ @patch("regscale.integrations.public.cisa.process_threats")
903
+ @patch("regscale.integrations.public.cisa.Threat.bulk_insert")
904
+ def test_alerts_insert(
905
+ self,
906
+ mock_bulk_insert,
907
+ mock_process_threats,
908
+ mock_fetch_threats,
909
+ mock_update_regscale_threats,
910
+ mock_update_regscale_links,
911
+ mock_parse_html,
912
+ ):
913
+ """Test alerts function with inserts"""
914
+ insert_threats = [
915
+ {
916
+ "title": "CVE-2025-21479",
917
+ "description": "Qualcomm Multiple Chipsets Incorrect Authorization Vulnerability",
918
+ "threatType": "Specific",
919
+ "targetType": "Other",
920
+ "source": "Open Source",
921
+ "status": "Initial Report/Notification",
922
+ }
923
+ ]
924
+ mock_parse_html.return_value = ["Threat"]
925
+ mock_process_threats.return_value = (insert_threats, [])
926
+ alerts(1)
927
+ mock_process_threats.assert_called_once()
928
+ mock_bulk_insert.assert_called_once()
929
+ mock_update_regscale_links.assert_called_once()
930
+ mock_update_regscale_threats.assert_not_called()
931
+
932
+ @patch("regscale.integrations.public.cisa.parse_html")
933
+ @patch("regscale.integrations.public.cisa.update_regscale_links")
934
+ @patch("regscale.integrations.public.cisa.update_regscale_threats")
935
+ @patch("regscale.integrations.public.cisa.Threat.fetch_all_threats")
936
+ @patch("regscale.integrations.public.cisa.process_threats")
937
+ @patch("regscale.integrations.public.cisa.Threat.bulk_insert")
938
+ def test_alerts_update(
939
+ self,
940
+ mock_bulk_insert,
941
+ mock_process_threats,
942
+ mock_fetch_threats,
943
+ mock_update_regscale_threats,
944
+ mock_update_regscale_links,
945
+ mock_parse_html,
946
+ ):
947
+ """Test alerts function with updates"""
948
+ update_threats = [
949
+ {
950
+ "title": "CVE-2025-21479",
951
+ "description": "Qualcomm Multiple Chipsets Incorrect Authorization Vulnerability",
952
+ "threatType": "Specific",
953
+ "targetType": "Other",
954
+ "source": "Open Source",
955
+ "status": "Initial Report/Notification",
956
+ }
957
+ ]
958
+ mock_parse_html.return_value = ["Threat"]
959
+ mock_process_threats.return_value = ([], update_threats)
960
+ alerts(1)
961
+ mock_process_threats.assert_called_once()
962
+ mock_update_regscale_threats.assert_called_once()
963
+ mock_bulk_insert.assert_not_called()
964
+ mock_update_regscale_links.assert_not_called()
965
+
966
+ @pytest.mark.skip("Skipping alerts integration test due to forbidden url error")
967
+ @patch("regscale.integrations.public.cisa.Threat.fetch_all_threats", wraps=Threat.fetch_all_threats)
968
+ @patch("regscale.integrations.public.cisa.parse_html", wraps=parse_html)
969
+ def test_alerts(self, mock_parse_html, mock_fetch_threats):
970
+ """Integration test for alerts function"""
971
+ alerts(2021)
972
+
973
+ # Verify core operations were called
974
+ mock_fetch_threats.assert_called_once()
975
+ mock_parse_html.assert_called_once()
976
+
977
+ @pytest.mark.skip("Skipping kev integration test to due api error")
978
+ def test_cisa_integration(self):
979
+ """Full integration test of CISA KEV ingestion"""
980
+ data = pull_cisa_kev()
981
+ assert data is not None
982
+ assert "title" in data.keys()
983
+ assert data["title"] == "CISA Catalog of Known Exploited Vulnerabilities"
984
+ assert "vulnerabilities" in data
985
+
986
+ update_regscale(data)
987
+
988
+ reg_threats = Threat.fetch_all_threats()
989
+ assert len(reg_threats) > 0
990
+
991
+ @patch("regscale.integrations.public.cisa.build_threat")
992
+ @patch("regscale.integrations.public.cisa.gen_soup")
993
+ def test_parse_html(self, mock_gen_soup, mock_build_threat):
994
+ """Test parse_html function's core parsing logic"""
995
+ mock_soup = MagicMock()
996
+ mock_article = MagicMock()
997
+ mock_article.text = "Some Title | CISA Alert"
998
+ mock_link = MagicMock()
999
+ mock_link.__getitem__.return_value = "/some/path" # This handles the href access
1000
+ mock_article.find_all.return_value = [mock_link]
1001
+
1002
+ # First call returns one article, second call returns empty list
1003
+ mock_soup.find_all.side_effect = [[mock_article], []]
1004
+ mock_gen_soup.return_value = mock_soup
1005
+
1006
+ mock_threat = Threat(
1007
+ title="CISA Alert",
1008
+ description="Test Description",
1009
+ threatType="Specific",
1010
+ targetType="Other",
1011
+ source="Open Source",
1012
+ status="Initial Report/Notification",
1013
+ )
1014
+ mock_build_threat.return_value = mock_threat
1015
+
1016
+ app = Application()
1017
+ result = parse_html("https://example.com", app)
1018
+
1019
+ assert mock_gen_soup.call_count == 2
1020
+ assert mock_build_threat.call_count == 2
1021
+ assert result == [mock_threat]