regscale-cli 6.18.0.0__py3-none-any.whl → 6.19.0.1__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 (47) hide show
  1. regscale/__init__.py +1 -1
  2. regscale/integrations/api_paginator.py +932 -0
  3. regscale/integrations/api_paginator_example.py +348 -0
  4. regscale/integrations/commercial/__init__.py +11 -10
  5. regscale/integrations/commercial/{qualys.py → qualys/__init__.py} +756 -105
  6. regscale/integrations/commercial/qualys/scanner.py +1051 -0
  7. regscale/integrations/commercial/qualys/variables.py +21 -0
  8. regscale/integrations/commercial/sicura/api.py +1 -0
  9. regscale/integrations/commercial/stigv2/click_commands.py +36 -8
  10. regscale/integrations/commercial/stigv2/stig_integration.py +63 -9
  11. regscale/integrations/commercial/tenablev2/__init__.py +9 -0
  12. regscale/integrations/commercial/tenablev2/authenticate.py +23 -2
  13. regscale/integrations/commercial/tenablev2/commands.py +779 -0
  14. regscale/integrations/commercial/tenablev2/jsonl_scanner.py +1999 -0
  15. regscale/integrations/commercial/tenablev2/sc_scanner.py +600 -0
  16. regscale/integrations/commercial/tenablev2/scanner.py +7 -5
  17. regscale/integrations/commercial/tenablev2/utils.py +21 -4
  18. regscale/integrations/commercial/tenablev2/variables.py +4 -0
  19. regscale/integrations/jsonl_scanner_integration.py +523 -142
  20. regscale/integrations/scanner_integration.py +102 -26
  21. regscale/integrations/transformer/__init__.py +17 -0
  22. regscale/integrations/transformer/data_transformer.py +445 -0
  23. regscale/integrations/transformer/mappings/__init__.py +8 -0
  24. regscale/integrations/variables.py +2 -0
  25. regscale/models/__init__.py +5 -2
  26. regscale/models/integration_models/cisa_kev_data.json +6 -6
  27. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  28. regscale/models/regscale_models/asset.py +5 -2
  29. regscale/models/regscale_models/file.py +5 -2
  30. regscale/models/regscale_models/group.py +2 -1
  31. regscale/models/regscale_models/user_group.py +1 -1
  32. regscale/regscale.py +3 -1
  33. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/METADATA +1 -1
  34. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/RECORD +46 -30
  35. tests/regscale/core/test_version.py +22 -0
  36. tests/regscale/integrations/__init__.py +0 -0
  37. tests/regscale/integrations/test_api_paginator.py +597 -0
  38. tests/regscale/integrations/test_integration_mapping.py +60 -0
  39. tests/regscale/integrations/test_issue_creation.py +317 -0
  40. tests/regscale/integrations/test_issue_due_date.py +46 -0
  41. tests/regscale/integrations/transformer/__init__.py +0 -0
  42. tests/regscale/integrations/transformer/test_data_transformer.py +850 -0
  43. regscale/integrations/commercial/tenablev2/click.py +0 -1641
  44. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/LICENSE +0 -0
  45. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/WHEEL +0 -0
  46. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/entry_points.txt +0 -0
  47. {regscale_cli-6.18.0.0.dist-info → regscale_cli-6.19.0.1.dist-info}/top_level.txt +0 -0
@@ -1,1641 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """Tenable integration for RegScale CLI"""
4
-
5
- import queue
6
- from concurrent.futures import wait
7
- from typing import TYPE_CHECKING
8
-
9
- from regscale.integrations.integration_override import IntegrationOverride
10
- from regscale.validation.record import validate_regscale_object
11
-
12
- # Delay import of Tenable libraries
13
- if TYPE_CHECKING:
14
- from tenable.io import TenableIO # type: ignore
15
- from tenable.sc import TenableSC # type: ignore
16
- import pandas as pd # Type Checking
17
-
18
- import collections
19
- import json
20
- import os
21
- import re
22
- import tempfile
23
- import time
24
- import uuid
25
- from concurrent.futures import ThreadPoolExecutor
26
- from datetime import datetime, timedelta
27
- from itertools import groupby
28
- from pathlib import Path
29
- from threading import current_thread, get_ident, get_native_id
30
- from typing import Dict, List, Optional, Set, Tuple, Union
31
- from urllib.parse import urljoin
32
-
33
- import click
34
- import requests
35
- from requests.exceptions import RequestException
36
- from rich.console import Console
37
- from rich.pretty import pprint
38
- from rich.progress import track
39
- from tenable.sc.analysis import AnalysisResultsIterator
40
-
41
- from regscale import __version__
42
- from regscale.core.app.api import Api
43
- from regscale.core.app.application import Application
44
- from regscale.core.app.logz import create_logger
45
- from regscale.core.app.utils.app_utils import (
46
- check_file_path,
47
- check_license,
48
- create_progress_object,
49
- epoch_to_datetime,
50
- error_and_exit,
51
- format_dict_to_html,
52
- get_current_datetime,
53
- regscale_string_to_epoch,
54
- save_data_to,
55
- )
56
- from regscale.core.app.utils.pickle_file_handler import PickleFileHandler
57
- from regscale.integrations.commercial.nessus.nessus_utils import get_cpe_file
58
- from regscale.models.app_models.click import file_types, hidden_file_path, regscale_ssp_id, save_output_to
59
- from regscale.models.integration_models.tenable_models.integration import SCIntegration
60
- from regscale.models.integration_models.tenable_models.models import AssetCheck, TenableAsset, TenableIOAsset
61
- from regscale.models.regscale_models import ControlImplementation
62
- from regscale.models.regscale_models.asset import Asset
63
- from regscale.models.regscale_models.issue import Issue
64
- from regscale.models.regscale_models.scan_history import ScanHistory
65
- from regscale.utils.threading import ThreadSafeCounter
66
- from regscale.validation.address import validate_mac_address
67
-
68
- console = Console()
69
-
70
- logger = create_logger("rich")
71
- REGSCALE_INC = "RegScale, Inc."
72
- REGSCALE_CLI = "RegScale CLI"
73
-
74
- FULLY_IMPLEMENTED = "Fully Implemented"
75
- NOT_IMPLEMENTED = "Not Implemented"
76
- IN_REMEDIATION = "In Remediation"
77
-
78
- DONE_MSG = "Done!"
79
-
80
-
81
- #####################################################################################################
82
- #
83
- # Tenable.sc Documentation: https://docs.tenable.com/tenablesc/api/index.htm
84
- # pyTenable GitHub repo: https://github.com/tenable/pyTenable
85
- # Python tenable.sc documentation: https://pytenable.readthedocs.io/en/stable/api/sc/index.html
86
- #
87
- #####################################################################################################
88
-
89
-
90
- # Create group to handle OSCAL processing
91
- @click.group()
92
- def tenable():
93
- """Performs actions on the Tenable APIs."""
94
-
95
-
96
- @tenable.group(help="[BETA] Performs actions on the Tenable.io API.")
97
- def io():
98
- """Performs actions on the Tenable.io API."""
99
-
100
-
101
- @tenable.group(help="[BETA] Performs actions on the Tenable.sc API.")
102
- def sc():
103
- """Performs actions on the Tenable.sc API."""
104
-
105
-
106
- @tenable.group(help="[BETA] Import Nessus scans and assets to RegScale.")
107
- def nessus():
108
- """Performs actions on the Tenable.sc API."""
109
-
110
-
111
- @nessus.command(name="import_nessus")
112
- @click.option(
113
- "--folder_path",
114
- prompt="Enter the folder path of the Nessus files to process",
115
- help="RegScale will load the Nessus Scans",
116
- type=click.Path(exists=True),
117
- )
118
- @click.option(
119
- "--scan_date",
120
- type=click.DateTime(formats=["%Y-%m-%d"]),
121
- help="The the scan date of the file.",
122
- required=False,
123
- )
124
- @regscale_ssp_id()
125
- def import_nessus(folder_path: click.Path, regscale_ssp_id: click.INT, scan_date: click.DateTime):
126
- """Import Nessus scans, vulnerabilities and assets to RegScale."""
127
- from regscale.integrations.commercial.nessus.scanner import NessusIntegration
128
-
129
- if not validate_regscale_object(regscale_ssp_id, "securityplans"):
130
- logger.warning("SSP #%i is not a valid RegScale Security Plan.", regscale_ssp_id)
131
- return
132
- NessusIntegration.sync_assets(plan_id=regscale_ssp_id, path=folder_path)
133
- NessusIntegration.sync_findings(
134
- plan_id=regscale_ssp_id, path=folder_path, enable_finding_date_update=True, scan_date=scan_date
135
- )
136
-
137
-
138
- @nessus.command(name="update_cpe_dictionary")
139
- def update_cpe_dictionary():
140
- """
141
- Manually update the CPE 2.2 dictionary from NIST.
142
- """
143
- get_cpe_file(download=True)
144
-
145
-
146
- @sc.command(name="export_scans")
147
- @save_output_to()
148
- @file_types([".json", ".csv", ".xlsx"])
149
- def export_scans(save_output_to: Path, file_type: str):
150
- """Export scans from Tenable Host to a .json, .csv or .xlsx file."""
151
- # get the scan results
152
- results = get_usable_scan_list()
153
-
154
- # check if file path exists
155
- check_file_path(save_output_to)
156
-
157
- # set the file name
158
- file_name = f"tenable_scans_{get_current_datetime('%m%d%Y')}"
159
-
160
- # save the data as the selected file by the user
161
- save_data_to(
162
- file=Path(f"{save_output_to}/{file_name}{file_type}"),
163
- data=results,
164
- )
165
-
166
-
167
- def validate_tags(ctx: click.Context, param: click.Option, value: str) -> List[Tuple[str, str]]:
168
- """
169
- Validate the tuple elements.
170
-
171
- :param click.Context ctx: Click context
172
- :param click.Option param: Click option
173
- :param str value: A string value to parse and validate
174
- :return: Tuple of validated values
175
- :rtype: List[Tuple[str,str]]
176
- :raise ValueError: If the value is not in the correct format
177
- """
178
- if not value:
179
- return []
180
-
181
- tuple_list = []
182
- for item in value.split(","):
183
- parts = [part for part in item.strip().split(":") if part]
184
- if len(parts) != 2:
185
- raise ValueError(f"""Invalid format: "{item}". Expected 'key:value'""")
186
- tuple_list.append((parts[0], parts[1]))
187
-
188
- return tuple_list
189
-
190
-
191
- def get_usable_scan_list() -> list:
192
- """
193
- Usable Scans from Tenable Host
194
-
195
- :return: List of scans from Tenable
196
- :rtype: list
197
- """
198
- results = []
199
- try:
200
- client = gen_client()
201
- results = client.scans.list()["usable"]
202
- except Exception as ex:
203
- logger.error(ex)
204
- return results
205
-
206
-
207
- def get_detailed_scans(scan_list: list = None) -> list:
208
- """
209
- Generate list of detailed scans (Warning: this action could take 20 minutes or more to complete)
210
-
211
- :param list scan_list: List of scans from Tenable, defaults to None
212
- :raise SystemExit: If there is an error with the request
213
- :return: Detailed list of Tenable scans
214
- :rtype: list
215
- """
216
- client = gen_client()
217
- detailed_scans = []
218
- for scan in track(scan_list, description="Fetching detailed scans..."):
219
- try:
220
- det = client.scans.details(id=scan["id"])
221
- detailed_scans.append(det)
222
- except RequestException as ex: # This is the correct syntax
223
- raise SystemExit(ex) from ex
224
-
225
- return detailed_scans
226
-
227
-
228
- @sc.command(name="save_queries")
229
- @save_output_to()
230
- @file_types([".json", ".csv", ".xlsx"])
231
- def save_queries(save_output_to: Path, file_type: str):
232
- """Get a list of query definitions and save them as a .json, .csv or .xlsx file."""
233
- # get the queries from Tenable
234
- query_list = get_queries()
235
-
236
- # check if file path exists
237
- check_file_path(save_output_to)
238
-
239
- # set the file name
240
- file_name = f"tenable_queries_{get_current_datetime('%m%d%Y')}"
241
-
242
- # save the data as a .json file
243
- save_data_to(
244
- file=Path(f"{save_output_to}{os.sep}{file_name}{file_type}"),
245
- data=query_list,
246
- )
247
-
248
-
249
- def get_queries() -> list:
250
- """
251
- List of query definitions
252
-
253
- :return: List of queries from Tenable
254
- :rtype: list
255
- """
256
- app = Application()
257
- tsc = gen_tsc(app.config)
258
- return tsc.queries.list()
259
-
260
-
261
- @sc.command(name="query_vuln")
262
- @click.option(
263
- "--query_id",
264
- type=click.INT,
265
- help="Tenable query ID to retrieve via API",
266
- prompt="Enter Tenable query ID",
267
- required=True,
268
- )
269
- @regscale_ssp_id()
270
- @click.option(
271
- "--scan_date",
272
- "-sd",
273
- type=click.DateTime(formats=["%Y-%m-%d"]),
274
- help="The scan date of the file.",
275
- required=False,
276
- )
277
- # Add Prompt for RegScale SSP name
278
- def query_vuln(query_id: int, regscale_ssp_id: int, scan_date: datetime = None):
279
- """Query Tenable vulnerabilities and sync assets to RegScale."""
280
- q_vuln(query_id=query_id, ssp_id=regscale_ssp_id, scan_date=scan_date)
281
-
282
-
283
- @io.command(name="sync_assets")
284
- @regscale_ssp_id()
285
- @click.option(
286
- "--tags",
287
- type=click.STRING,
288
- help='Optional tags to filter assets, wrap in double quotes, e.g. --tags "Tag1:tag1a,Tag2:tag2b"',
289
- default=None,
290
- required=False,
291
- callback=validate_tags,
292
- )
293
- # Add Prompt for RegScale SSP name
294
- def query_assets(regscale_ssp_id: int, tags: Optional[List[Tuple[str, str]]] = None):
295
- """Query Tenable Assets and sync to RegScale."""
296
- # Validate ssp
297
- from regscale.integrations.commercial.tenablev2.scanner import TenableIntegration
298
-
299
- TenableIntegration.sync_assets(plan_id=regscale_ssp_id, tags=tags)
300
-
301
-
302
- @io.command(name="sync_vulns")
303
- @regscale_ssp_id()
304
- @click.option(
305
- "--tags",
306
- type=click.STRING,
307
- help='Optional tags to filter vulns, wrap in double quotes, e.g. --tags "Tag1:tag1a,Tag2:tag2b"',
308
- default=None,
309
- required=False,
310
- callback=validate_tags,
311
- )
312
- @click.option(
313
- "--scan_date",
314
- "-sd",
315
- type=click.DateTime(formats=["%Y-%m-%d"]),
316
- help="The scan date of the file.",
317
- required=False,
318
- )
319
- def query_vulns(regscale_ssp_id: int, tags: Optional[List[Tuple[str, str]]] = None, scan_date: datetime = None):
320
- """
321
- Query Tenable vulnerabilities and sync assets, vulnerabilities and issues to RegScale.
322
- """
323
- from regscale.integrations.commercial.tenablev2.scanner import TenableIntegration
324
-
325
- TenableIntegration.sync_findings(plan_id=regscale_ssp_id, tags=tags, scan_date=scan_date)
326
-
327
-
328
- def validate_regscale_security_plan(parent_id: int) -> bool:
329
- """
330
- Validate RegScale Security Plan exists
331
-
332
- :param int parent_id: The ID number from RegScale of the System Security Plan
333
- :return: If API call was successful
334
- :rtype: bool
335
- """
336
- app = check_license()
337
- config = app.config
338
- headers = {
339
- "Authorization": config["token"],
340
- }
341
- url = urljoin(config["domain"], f"/api/securityplans/{parent_id}")
342
- response = requests.get(url, headers=headers)
343
- return response.ok
344
-
345
-
346
- @io.command(name="list_jobs")
347
- @click.option(
348
- "--job_type",
349
- default="vulns",
350
- type=click.Choice(["vulns", "assets"]),
351
- show_default=True,
352
- help="Tenable job type.",
353
- required=False,
354
- )
355
- @click.option(
356
- "--last",
357
- type=click.INT,
358
- default=100,
359
- show_default=True,
360
- help="Filter the last n jobs.",
361
- required=False,
362
- )
363
- @click.option(
364
- "--job_status",
365
- type=click.Choice(["processing", "finished", "cancelled"]),
366
- help="Filter by status.",
367
- required=False,
368
- )
369
- def list_jobs(job_type: str, last: int, job_status: str):
370
- """Retrieve a list of jobs from Tenable.io."""
371
- app = Application()
372
- config = app.config
373
- client = gen_tio(config)
374
- if job_status:
375
- jobs = [job for job in client.exports.jobs(job_type) if job["status"] == str(job_status).upper()]
376
- else:
377
- jobs = client.exports.jobs(job_type)
378
- jobs = sorted(jobs, key=lambda k: (k["created"]), reverse=False)
379
- # filter the last N jobs
380
- for job in jobs[len(jobs) - last :]:
381
- console.print(
382
- f"UUID: {job['uuid']}, STATUS: {job['status']}, CREATED: {epoch_to_datetime(job['created'], epoch_type='milliseconds')}"
383
- )
384
-
385
-
386
- @io.command(name="cancel_job")
387
- @click.option(
388
- "--uuid",
389
- type=click.STRING,
390
- help="Tenable job UUID.",
391
- prompt="Enter the UUID of the job to cancel.",
392
- required=True,
393
- )
394
- @click.option(
395
- "--job_type",
396
- default="vulns",
397
- type=click.Choice(["vulns", "assets"]),
398
- show_default=True,
399
- help="Tenable job type.",
400
- required=False,
401
- )
402
- def cancel_job(uuid: str, job_type: str):
403
- """Cancel a Tenable IO job."""
404
- app = Application()
405
- config = app.config
406
- client = gen_tio(config)
407
- client.exports.cancel(job_type, export_uuid=uuid)
408
-
409
-
410
- def process_vulnerabilities(counts: collections.Counter, reg_assets: list, ssp_id: int, tenable_vulns: list) -> list:
411
- """
412
- Process Tenable vulnerabilities
413
-
414
- :param collections.Counter counts: Dictionary of counts of each vulnerability
415
- :param list reg_assets: List of RegScale assets
416
- :param int ssp_id: RegScale System Security Plan ID
417
- :param list tenable_vulns: List of Tenable vulnerabilities
418
- :return: List of assets to update
419
- :rtype: list
420
- """
421
- update_assets = []
422
- for vuln in set(tenable_vulns):
423
- update_assets = process_vuln(counts, reg_assets, ssp_id, vuln)
424
- return update_assets
425
-
426
-
427
- def q_vuln(query_id: int, ssp_id: int, scan_date: datetime = None) -> list:
428
- """
429
- Query Tenable vulnerabilities
430
-
431
- :param int query_id: Tenable query ID
432
- :param int ssp_id: RegScale System Security Plan ID
433
- :param datetime scan_date: Scan date, defaults to None
434
- :return: List of queries from Tenable
435
- :rtype: list
436
- """
437
- check_license()
438
- # At SSP level, provide a list of vulnerabilities and the counts of each
439
- fetch_vulns(query_id=query_id, regscale_ssp_id=ssp_id, scan_date=scan_date)
440
-
441
-
442
- def process_vuln(counts: collections.Counter, reg_assets: list, ssp_id: int, vuln: TenableAsset) -> list:
443
- """
444
- Process Tenable vulnerability data
445
-
446
- :param collections.Counter counts: Dictionary of counts of each vulnerability
447
- :param list reg_assets: List of RegScale assets
448
- :param int ssp_id: RegScale System Security Plan ID
449
- :param TenableAsset vuln: Tenable vulnerability object
450
- :return: List of assets to update
451
- :rtype: list
452
- """
453
- update_assets = []
454
- vuln.count = dict(counts)[vuln.pluginName]
455
- lookup_assets = lookup_asset(reg_assets, vuln.macAddress, vuln.dnsName)
456
- # Update parent id to SSP on insert
457
- if len(lookup_assets) > 0:
458
- for asset in set(lookup_assets):
459
- # Do update
460
- # asset = reg_asset[0]
461
- asset.parentId = ssp_id
462
- asset.parentModule = "securityplans"
463
- asset.macAddress = vuln.macAddress.upper()
464
- asset.osVersion = vuln.operatingSystem
465
- asset.purchaseDate = "01-01-1970"
466
- asset.endOfLifeDate = "01-01-1970"
467
- if asset.ipAddress is None:
468
- asset.ipAddress = vuln.ip
469
- asset.operatingSystem = determine_os(asset.operatingSystem)
470
- try:
471
- assert asset.id
472
- # avoid duplication
473
- if asset not in update_assets:
474
- update_assets.append(asset)
475
- except AssertionError as aex:
476
- logger.error("Asset does not have an id, unable to update!\n%s", aex)
477
- return update_assets
478
-
479
-
480
- def determine_os(os_string: str) -> str:
481
- """
482
- Determine RegScale friendly OS name
483
-
484
- :param str os_string: String of the asset's OS
485
- :return: RegScale acceptable OS
486
- :rtype: str
487
- """
488
- linux_words = ["linux", "ubuntu", "hat", "centos", "rocky", "alma", "alpine"]
489
- if re.compile("|".join(linux_words), re.IGNORECASE).search(os_string):
490
- return "Linux"
491
- elif (os_string.lower()).startswith("windows"):
492
- return "Windows Server" if "server" in os_string else "Windows Desktop"
493
- else:
494
- return "Other"
495
-
496
-
497
- def lookup_asset(asset_list: list, mac_address: str, dns_name: str = None) -> list:
498
- """
499
- Lookup asset in Tenable and return the data from Tenable
500
-
501
- :param list asset_list: List of assets to lookup in Tenable
502
- :param str mac_address: Mac address of asset
503
- :param str dns_name: DNS Name of the asset, defaults to None
504
- :return: List of assets that fit the provided filters
505
- :rtype: list
506
- """
507
- results = []
508
- if validate_mac_address(mac_address):
509
- if dns_name:
510
- results = [
511
- Asset(**asset)
512
- for asset in asset_list
513
- if "macAddress" in asset
514
- and asset["macAddress"] == mac_address
515
- and asset["name"] == dns_name
516
- and "macAddress" in asset
517
- and "name" in asset
518
- ]
519
- else:
520
- results = [asset for asset in asset_list if asset["macAddress"] == mac_address]
521
- # Return unique list
522
- return list(set(results))
523
-
524
-
525
- def create_issue_from_vuln(app: Application, row: "pd.Series", default_due_delta: int) -> "Issue":
526
- """
527
- Creates an Issue object from a Tenable vulnerability
528
-
529
- :param Application app: Application object
530
- :param pd.Series row: Row of data from Tenable
531
- :param int default_due_delta: Default due delta
532
- :return: Issue object
533
- :rtype: Issue
534
- """
535
-
536
- default_status = app.config["issues"]["tenable"]["status"]
537
- fmt = "%Y-%m-%d %H:%M:%S"
538
- plugin_id = row["pluginID"]
539
- port = row["port"]
540
- protocol = row["protocol"]
541
- due_date = datetime.strptime(row["last_scan"], fmt) + timedelta(days=default_due_delta)
542
- if due_date < datetime.now():
543
- due_date = datetime.now() + timedelta(days=default_due_delta)
544
- if "synopsis" in row:
545
- title = row["synopsis"]
546
- return Issue(
547
- title=title or row["pluginName"],
548
- description=row["description"] or row["pluginName"] + f"<br>Port: {port}<br>Protocol: {protocol}",
549
- issueOwnerId=app.config["userId"],
550
- status=default_status,
551
- severityLevel=Issue.assign_severity(row["severity"]),
552
- dueDate=due_date.strftime(fmt),
553
- identification="Vulnerability Assessment",
554
- parentId=row["regscale_ssp_id"],
555
- parentModule="securityplans",
556
- pluginId=plugin_id,
557
- vendorActions=row["solution"],
558
- assetIdentifier=f'DNS: {row["dnsName"]} - IP: {row["ip"]}',
559
- )
560
-
561
-
562
- def create_issue_from_row(app: Application, row: "pd.Series", default_due_delta: int) -> "Issue":
563
- """
564
- Creates an Issue object from a Tenable vulnerability
565
-
566
- :param Application app: Application object
567
- :param pd.Series row: Row of data from Tenable
568
- :param int default_due_delta: Default due delta
569
- :return: Issue object
570
- :rtype: Issue
571
- """
572
- if row["severity"] != "Info":
573
- issue = create_issue_from_vuln(app, row, default_due_delta)
574
- if isinstance(issue, Issue):
575
- return issue
576
- return None
577
-
578
-
579
- def prepare_issues_for_sync(
580
- app: Application, df: "pd.DataFrame", regscale_ssp_id: int
581
- ) -> Tuple[List["Issue"], List["Issue"]]:
582
- """
583
- Prepares Tenable vulnerabilities for synchronization as RegScale issues
584
-
585
- :param Application app: Application object
586
- :param pd.DataFrame df: Dataframe of Tenable data
587
- :param int regscale_ssp_id: RegScale System Security Plan ID
588
- :return: List of issues to insert, list of issues to update
589
- :rtype: Tuple[List[Issue], List[Issue]]
590
- """
591
-
592
- default_due_delta = app.config["issues"]["tenable"]["moderate"]
593
- existing_issues = Issue.get_all_by_parent(parent_id=regscale_ssp_id, parent_module="securityplans")
594
- sc_issues = []
595
- new_issues = set()
596
- update_issues = set()
597
- for index, row in df.iterrows():
598
- issue = create_issue_from_row(app, row, default_due_delta)
599
- if isinstance(issue, Issue):
600
- sc_issues.append(issue)
601
- # Generate list of completely new issues, and merge with existing issues if they have the same title
602
- # group issues by title
603
- grouped_issues = {k: list(g) for k, g in groupby(sc_issues, key=lambda x: x.title)}
604
- for title in grouped_issues:
605
- reg_key = 0
606
- regs = [iss for iss in existing_issues if iss.title == title and iss.id]
607
- if regs:
608
- reg_key = regs[0].id
609
- issues = set(grouped_issues[title])
610
- for issue in issues:
611
- asset_ident = combine_strings({iss.assetIdentifier for iss in issues})
612
- if reg_key and issue.title not in {iss.title for iss in update_issues}:
613
- issue.id = reg_key
614
- issue.assetIdentifier = asset_ident
615
- update_issues.add(issue)
616
- elif not reg_key:
617
- issue.assetIdentifier = asset_ident
618
- new_issues.add(issue)
619
-
620
- return list(new_issues), list(update_issues)
621
-
622
-
623
- def combine_strings(set_of_strings: Set[str]) -> str:
624
- """
625
- Combines a set of strings into a single string
626
-
627
- :param Set[str] set_of_strings: Set of strings
628
- :rtype: str
629
- :return: Combined string
630
- """
631
- return "<br>".join(set_of_strings)
632
-
633
-
634
- def sync_issues_to_regscale(new_issues: List["Issue"], update_issues: List["Issue"]) -> None:
635
- """
636
- Synchronizes issues to RegScale
637
-
638
- :param List[Issue] new_issues: New issues
639
- :param List[Issue] update_issues: Updated issues
640
- :rtype: None
641
- """
642
- logger = create_logger()
643
-
644
- if new_issues:
645
- logger.info(f"Creating {len(new_issues)} new issue(s) in RegScale...")
646
- Issue.batch_create(new_issues)
647
- logger.info("Finished creating issue(s) in RegScale.")
648
- else:
649
- logger.info("No new issues to create.")
650
-
651
- if update_issues:
652
- logger.info(f"Updating {len(update_issues)} existing issue(s) in RegScale...")
653
- Issue.batch_update(update_issues)
654
- logger.info("Finished updating issue(s) in RegScale.")
655
- else:
656
- logger.info("No issues to update.")
657
-
658
-
659
- def create_regscale_issue_from_vuln(regscale_ssp_id: int, df: Optional["pd.DataFrame"] = None) -> None:
660
- """
661
- Sync Tenable Vulnerabilities to RegScale issues
662
-
663
- :param int regscale_ssp_id: RegScale System Security Plan ID
664
- :param Optional["pd.DataFrame"] df: Pandas dataframe of Tenable data
665
- :rtype: None
666
- """
667
- import pandas as pd # Optimize import performance
668
-
669
- if df is None:
670
- df = pd.DataFrame()
671
- app = Application()
672
- new_issues, update_issues = prepare_issues_for_sync(app, df, regscale_ssp_id)
673
- sync_issues_to_regscale(new_issues, update_issues)
674
-
675
-
676
- def fetch_assets(ssp_id: int) -> list[TenableIOAsset]:
677
- """
678
- Fetch assets from Tenable IO and sync to RegScale
679
-
680
- :param int ssp_id: RegScale System Security Plan ID
681
- :return: List of Tenable assets
682
- :rtype: list[TenableIOAsset]
683
- """
684
- tenable_last_updated: int = 0
685
- app = Application()
686
- config = app.config
687
- client = gen_tio(config=config)
688
- assets: List[TenableIOAsset] = []
689
- logger.info("Fetching existing assets from RegScale...")
690
-
691
- existing_assets: List[Asset] = Asset.get_all_by_parent(parent_id=ssp_id, parent_module="securityplans")
692
-
693
- logger.info("Found %i existing asset(s) in RegScale.", len(existing_assets))
694
-
695
- filtered_assets = [asset for asset in existing_assets if asset.tenableId and asset.dateLastUpdated]
696
- # Get last epoch updated from RegScale, limit to Tenable assets
697
- if filtered_assets:
698
- tenable_last_updated = max([regscale_string_to_epoch(asset.dateLastUpdated) for asset in filtered_assets])
699
- export = client.exports.assets(updated_at=tenable_last_updated)
700
- logger.info("Saving chunked asset files from Tenable IO for processing...")
701
- temp_loc = Path(tempfile.gettempdir()) / "tenable_io" / str(uuid.uuid4()) # random folder name
702
- # show process status
703
- box_len = 0
704
- status = client.exports.status(export_type=export.type, export_uuid=export.uuid)
705
- with create_progress_object(indeterminate=True) as job_progress:
706
- job_progress.add_task("Fetching Chunked Tenable IO data...", start=False, total=None)
707
- while status["status"] == "PROCESSING":
708
- box_len = len(status["chunks_available"])
709
- time.sleep(0.5)
710
- status = client.exports.status(export_type=export.type, export_uuid=export.uuid)
711
- # Process chunks of data
712
- with create_progress_object(indeterminate=True) as saving_progress:
713
- saving_task = saving_progress.add_task(
714
- "Saving Tenable IO data to disk...",
715
- total=box_len,
716
- )
717
- export.run_threaded(
718
- func=write_io_chunk,
719
- kwargs={"data_dir": temp_loc},
720
- num_threads=3,
721
- )
722
- saving_progress.update(saving_task, advance=1)
723
- process_to_regscale(data_dir=temp_loc, ssp_id=ssp_id, existing_assets=existing_assets)
724
- return assets
725
-
726
-
727
- def fetch_vulns(query_id: int = 0, regscale_ssp_id: int = 0, scan_date: datetime = None):
728
- """
729
- Fetch vulnerabilities from Tenable by query ID
730
-
731
- :param int query_id: Tenable query ID, defaults to 0
732
- :param int regscale_ssp_id: RegScale System Security Plan ID, defaults to 0
733
- :param datetime scan_date: Scan date, defaults to None
734
- """
735
-
736
- client = gen_client()
737
- if query_id and client._env_base == "TSC":
738
- vulns = client.analysis.vulns(query_id=query_id)
739
- sc = SCIntegration(plan_id=regscale_ssp_id, scan_date=scan_date)
740
- # Create pickle file to cache data
741
- # make sure folder exists
742
- with tempfile.TemporaryDirectory() as temp_dir:
743
- logger.info("Saving Tenable SC data to disk...%s", temp_dir)
744
- num_assets_processed, num_findings_to_process = consume_iterator_to_file(
745
- iterator=vulns, dir_path=Path(temp_dir), scanner=sc
746
- )
747
- iterables = tenable_dir_to_tuple_generator(Path(temp_dir))
748
- try:
749
- sc.sync_assets(
750
- plan_id=regscale_ssp_id,
751
- integration_assets=(asset for sublist in iterables[0] for asset in sublist),
752
- asset_count=num_assets_processed,
753
- )
754
- sc.sync_findings(
755
- plan_id=regscale_ssp_id,
756
- integration_findings=(finding for sublist in iterables[1] for finding in sublist),
757
- finding_count=num_findings_to_process,
758
- )
759
- except IndexError as ex:
760
- logger.error("Error processing Tenable SC data: %s", ex)
761
-
762
-
763
- def tenable_dir_to_tuple_generator(dir_path: Path):
764
- """
765
- Generate a tuple of chained generators for Tenable directories.
766
- """
767
- from itertools import chain
768
-
769
- assets_gen = chain.from_iterable(
770
- (dat["assets"] for dat in PickleFileHandler(file).read()) for file in dir_path.iterdir()
771
- )
772
- findings_gen = chain.from_iterable(
773
- (dat["findings"] for dat in PickleFileHandler(file).read()) for file in dir_path.iterdir()
774
- )
775
-
776
- return assets_gen, findings_gen
777
-
778
-
779
- def consume_iterator_to_file(iterator: AnalysisResultsIterator, dir_path: Path, scanner: SCIntegration) -> tuple:
780
- """
781
- Consume an iterator and write the results to a file
782
-
783
- :param AnalysisResultsIterator iterator: Tenable SC iterator
784
- :param Path dir_path: The directory to save the pickled files
785
- :param SCIntegration scanner: Tenable SC Integration object
786
- :rtype: tuple
787
- :return: The total count of assets and findings processed
788
- """
789
- app = Application()
790
- logger.info("Consuming Tenable SC iterator...")
791
- override = IntegrationOverride(app)
792
- asset_count = 0
793
- findings_count = 0
794
- total_count = ThreadSafeCounter()
795
- page_number = ThreadSafeCounter()
796
- rec_count = ThreadSafeCounter()
797
- process_list = queue.Queue()
798
- futures_lst = []
799
- with ThreadPoolExecutor(max_workers=5) as executor:
800
- for dat in iterator:
801
- total_count.increment()
802
- process_list.put(dat)
803
- rec_count.increment()
804
- if rec_count.value == len(iterator.page):
805
- page_number.increment()
806
- futures_lst.append(
807
- executor.submit(
808
- process_sc_chunk,
809
- app=app,
810
- vulns=pop_queue(queue=process_list, queue_len=len(iterator.page)),
811
- page=page_number.value,
812
- dir_path=dir_path,
813
- sc=scanner,
814
- override=override,
815
- )
816
- )
817
- rec_count.set(0)
818
- # Collect results from all threads
819
- asset_count = 0
820
- findings_count = 0
821
- # Wait for completion
822
- wait(futures_lst)
823
-
824
- for future in futures_lst:
825
- findings, assets = future.result()
826
- asset_count += assets
827
- findings_count += findings
828
-
829
- if total_count.value == 0:
830
- logger.warning("No Tenable SC data found.")
831
- return asset_count, findings_count
832
-
833
-
834
- def pop_queue(queue: queue.Queue, queue_len: int) -> list:
835
- """
836
- Pop items from a queue
837
-
838
- :param queue.Queue queue: Queue object
839
- :param int queue_len: Length of the queue
840
- :return: List of items from the queue
841
- :rtype: list
842
- """
843
- retrieved_items = []
844
-
845
- # Use a for loop to get 1000 items
846
- for _ in range(queue_len):
847
- # Check if the queue is not empty
848
- if not queue.empty():
849
- # Get an item from the queue and append it to the list
850
- retrieved_items.append(queue.get())
851
- else:
852
- # Break the loop if the queue is empty
853
- break
854
- return retrieved_items
855
-
856
-
857
- def process_sc_chunk(**kwargs) -> tuple:
858
- """
859
- Process Tenable SC chunk
860
-
861
- :param kwargs: Keyword arguments
862
- :rtype: tuple
863
- :return: Tuple of findings and assets
864
- """
865
- # iterator.page, iterator.page_count, file_path, query_id, ssp_id
866
- integration_mapping = kwargs.get("override")
867
-
868
- vulns = kwargs.get("vulns")
869
- dir_path = kwargs.get("dir_path")
870
- generated_file_name = f"tenable_scan_page_{kwargs.get('page')}.pkl"
871
- pickled_file_handler = PickleFileHandler(str(dir_path / generated_file_name))
872
- tenable_sc: SCIntegration = kwargs.get("sc")
873
- thread = current_thread()
874
- if not len(vulns):
875
- return (0, 0)
876
- # I can't add a to-do thanks to sonarlint, but we need to add CVE lookup from plugin id
877
- # append file to path
878
- # Process to RegScale
879
- tenable_vulns = [TenableAsset(**vuln) for vuln in vulns]
880
- # Empty "DNS" should just be IP
881
- for vuln in tenable_vulns:
882
- if not vuln.dnsName:
883
- vuln.dnsName = vuln.ip
884
- findings = []
885
- assets = []
886
- for vuln in tenable_vulns:
887
- findings += tenable_sc.parse_findings(vuln=vuln, integration_mapping=integration_mapping)
888
- if vuln.dnsName not in {asset.name for asset in assets}: # avoid duplicates
889
- assets.append(tenable_sc.to_integration_asset(vuln, **kwargs))
890
- pickled_file_handler.write({"assets": assets, "findings": findings})
891
-
892
- logger.info(
893
- "Submitting %i findings and %i assets to the CLI Job Queue from Tenable SC Page %i...",
894
- len(findings),
895
- len(assets),
896
- kwargs.get("page"),
897
- )
898
- logger.debug(f"Completed thread: name={thread.name}, idnet={get_ident()}, id={get_native_id()}")
899
- return (len(findings), len(assets))
900
-
901
-
902
- def get_last_pull_epoch(regscale_ssp_id: int) -> int:
903
- """
904
- Gather last pull epoch from RegScale Security Plan
905
-
906
- :param int regscale_ssp_id: RegScale System Security Plan ID
907
- :return: Last pull epoch
908
- :rtype: int
909
-
910
- """
911
- fmt: str = "%Y-%m-%d"
912
- two_months_ago: datetime = datetime.now() - timedelta(weeks=8)
913
- two_weeks_ago: datetime = datetime.now() - timedelta(weeks=2)
914
- last_pull: int = round(two_weeks_ago.timestamp()) # default the last pull date to two weeks
915
- # Limit the query with a filter_date to avoid taxing the database in the case of a large number of scans
916
- if res := ScanHistory.get_by_parent_recursive(
917
- parent_id=regscale_ssp_id, parent_module="securityplans", filter_date=two_months_ago.strftime(fmt)
918
- ):
919
- # order by ScanDate desc
920
- fmt = "%Y-%m-%dT%H:%M:%S"
921
- res = sorted(res, key=lambda x: datetime.strptime(x.scanDate, fmt), reverse=True)
922
- # Convert to timestampe
923
- last_pull = round((datetime.strptime(res[0].scanDate, fmt)).timestamp())
924
- return last_pull
925
-
926
-
927
- @sc.command(name="list_tags")
928
- def sc_tags():
929
- """List tags from Tenable"""
930
- list_tags()
931
-
932
-
933
- def list_tags() -> None:
934
- """
935
- Query a list of tags on the server and print to console
936
-
937
- :rtype: None
938
- """
939
- tag_list = get_tags()
940
- pprint(tag_list)
941
-
942
-
943
- def get_tags() -> list:
944
- """
945
- List of Tenable query definitions
946
-
947
- :return: List of unique tags for Tenable queries
948
- :rtype: list
949
- """
950
- client = gen_client()
951
- logger.debug(client._env_base)
952
- if client._env_base == "TSC":
953
- return client.queries.tags()
954
- return list(client.tags.list())
955
-
956
-
957
- def gen_client() -> Union["TenableIO", "TenableSC"]:
958
- """
959
- Return the appropriate Tenable client based on the URL
960
-
961
- :return: Client type
962
- :rtype: Union["TenableIO", "TenableSC"]
963
- """
964
- app = Application()
965
- config = app.config
966
- if "cloud.tenable.com" in config["tenableUrl"]:
967
- return gen_tio(config)
968
- return gen_tsc(config)
969
-
970
-
971
- def gen_tsc(config: dict) -> "TenableSC":
972
- """
973
- Generate Tenable Object
974
-
975
- :param dict config: Configuration dictionary
976
- :return: Tenable client
977
- :rtype: "TenableSC"
978
- """
979
- from restfly.errors import APIError
980
- from tenable.sc import TenableSC
981
-
982
- if not config:
983
- app = Application()
984
- config = app.config
985
- res = TenableSC(
986
- url=config["tenableUrl"],
987
- access_key=config["tenableAccessKey"],
988
- secret_key=config["tenableSecretKey"],
989
- vendor=REGSCALE_INC,
990
- product=REGSCALE_CLI,
991
- build=__version__,
992
- )
993
- try:
994
- res.status.status()
995
- except APIError:
996
- error_and_exit("Unable to authenticate with Tenable SC. Please check your credentials.", False)
997
- return res
998
-
999
-
1000
- def gen_tio(config: dict) -> "TenableIO":
1001
- """
1002
- Generate Tenable Object
1003
-
1004
- :param dict config: Configuration dictionary
1005
- :return: Tenable client
1006
- :rtype: "TenableIO"
1007
- """
1008
-
1009
- from restfly.errors import UnauthorizedError
1010
- from tenable.io import TenableIO
1011
-
1012
- res = TenableIO(
1013
- url=config["tenableUrl"],
1014
- access_key=config["tenableAccessKey"],
1015
- secret_key=config["tenableSecretKey"],
1016
- vendor=REGSCALE_INC,
1017
- product=REGSCALE_CLI,
1018
- build=__version__,
1019
- )
1020
-
1021
- try:
1022
- # Check a quick API to make sure we have access
1023
- res.scans.list(last_modified=datetime.now())
1024
- except UnauthorizedError:
1025
- error_and_exit(
1026
- "Unable to authenticate with Tenable Vulnerability Management (IO). Please check your credentials.", False
1027
- )
1028
-
1029
- return res
1030
-
1031
-
1032
- def get_controls(catalog_id: int) -> List[Dict]:
1033
- """
1034
- Gets all the controls
1035
-
1036
- :param int catalog_id: catalog id
1037
- :return: list of controls
1038
- :rtype: List[Dict]
1039
- """
1040
- app = Application()
1041
- api = Api()
1042
- url = urljoin(app.config.get("domain"), f"/api/SecurityControls/getList/{catalog_id}")
1043
- response = api.get(url)
1044
- if response.ok:
1045
- return response.json()
1046
- else:
1047
- response.raise_for_status()
1048
- return []
1049
-
1050
-
1051
- def create_control_implementations(
1052
- controls: list,
1053
- parent_id: int,
1054
- parent_module: str,
1055
- existing_implementation_dict: Dict,
1056
- passing_controls: Dict,
1057
- failing_controls: Dict,
1058
- ) -> List[Dict]:
1059
- """
1060
- Creates a list of control implementations
1061
-
1062
- :param list controls: list of controls
1063
- :param int parent_id: parent control id
1064
- :param str parent_module: parent module
1065
- :param Dict existing_implementation_dict: Dictionary of existing control implementations
1066
- :param Dict passing_controls: Dictionary of passing controls
1067
- :param Dict failing_controls: Dictionary of failing controls
1068
- :return: list of control implementations
1069
- :rtype: List[Dict]
1070
- """
1071
- app = Application()
1072
- api = Api()
1073
- user_id = app.config.get("userId")
1074
- domain = app.config.get("domain")
1075
- control_implementations = []
1076
- to_create = []
1077
- to_update = []
1078
- for control in controls:
1079
- lower_case_control_id = control["controlId"].lower()
1080
- status = check_implementation(
1081
- passing_controls=passing_controls,
1082
- failing_controls=failing_controls,
1083
- control_id=lower_case_control_id,
1084
- )
1085
- if control["controlId"] not in existing_implementation_dict.keys():
1086
- cim = ControlImplementation(
1087
- controlOwnerId=user_id,
1088
- dateLastAssessed=get_current_datetime(),
1089
- status=status,
1090
- controlID=control["id"],
1091
- parentId=parent_id,
1092
- parentModule=parent_module,
1093
- createdById=user_id,
1094
- dateCreated=get_current_datetime(),
1095
- lastUpdatedById=user_id,
1096
- dateLastUpdated=get_current_datetime(),
1097
- ).dict()
1098
- cim["controlSource"] = "Baseline"
1099
- to_create.append(cim)
1100
-
1101
- else:
1102
- # update existing control implementation data
1103
- existing_imp = existing_implementation_dict.get(control["controlId"])
1104
- existing_imp["status"] = status
1105
- existing_imp["dateLastAssessed"] = get_current_datetime()
1106
- existing_imp["lastUpdatedById"] = user_id
1107
- existing_imp["dateLastUpdated"] = get_current_datetime()
1108
- del existing_imp["createdBy"]
1109
- del existing_imp["systemRole"]
1110
- del existing_imp["controlOwner"]
1111
- del existing_imp["lastUpdatedBy"]
1112
- to_update.append(existing_imp)
1113
-
1114
- if len(to_create) > 0:
1115
- ci_url = urljoin(domain, "/api/controlImplementation/batchCreate")
1116
- resp = api.post(url=ci_url, json=to_create)
1117
- if resp.ok:
1118
- control_implementations.extend(resp.json())
1119
- logger.info(f"Created {len(to_create)} Control Implementation(s), Successfully!")
1120
- else:
1121
- resp.raise_for_status()
1122
- if len(to_update) > 0:
1123
- ci_url = urljoin(domain, "/api/controlImplementation/batchUpdate")
1124
- resp = api.post(url=ci_url, json=to_update)
1125
- if resp.ok:
1126
- control_implementations.extend(resp.json())
1127
- logger.info(f"Updated {len(to_update)} Control Implementation(s), Successfully!")
1128
- else:
1129
- resp.raise_for_status()
1130
- return control_implementations
1131
-
1132
-
1133
- def check_implementation(passing_controls: Dict, failing_controls: Dict, control_id: str) -> str:
1134
- """
1135
- Checks the status of a control implementation
1136
-
1137
- :param Dict passing_controls: Dictionary of passing controls
1138
- :param Dict failing_controls: Dictionary of failing controls
1139
- :param str control_id: control id
1140
- :return: status of control implementation
1141
- :rtype: str
1142
- """
1143
- if control_id in passing_controls.keys():
1144
- return FULLY_IMPLEMENTED
1145
- elif control_id in failing_controls.keys():
1146
- return IN_REMEDIATION
1147
- else:
1148
- return NOT_IMPLEMENTED
1149
-
1150
-
1151
- def get_existing_control_implementations(parent_id: int) -> Dict:
1152
- """
1153
- fetch existing control implementations
1154
-
1155
- :param int parent_id: parent control id
1156
- :return: Dictionary of existing control implementations
1157
- :rtype: Dict
1158
- """
1159
- app = Application()
1160
- api = Api()
1161
- domain = app.config.get("domain")
1162
- existing_implementation_dict = {}
1163
- get_url = urljoin(domain, f"/api/controlImplementation/getAllByPlan/{parent_id}")
1164
- response = api.get(get_url)
1165
- if response.ok:
1166
- existing_control_implementations_json = response.json()
1167
- for cim in existing_control_implementations_json:
1168
- existing_implementation_dict[cim["controlName"]] = cim
1169
- logger.info(f"Found {len(existing_implementation_dict)} existing control implementations")
1170
- elif response.status_code == 404:
1171
- logger.info(f"No existing control implementations found for {parent_id}")
1172
- else:
1173
- logger.warn(f"Unable to get existing control implementations. {response.text}")
1174
-
1175
- return existing_implementation_dict
1176
-
1177
-
1178
- def get_matched_controls(tenable_controls: List[Dict], catalog_controls: List[Dict]) -> List[Dict]:
1179
- """
1180
- Get controls that match between Tenable and the catalog
1181
-
1182
- :param List[Dict] tenable_controls: List of controls from Tenable
1183
- :param List[Dict] catalog_controls: List of controls from the catalog
1184
- :return: List of matched controls
1185
- :rtype: List[Dict]
1186
- """
1187
- matched_controls = []
1188
- for control in tenable_controls:
1189
- formatted_control = convert_control_id(control)
1190
- logger.info(formatted_control)
1191
- for catalog_control in catalog_controls:
1192
- if catalog_control["controlId"].lower() == formatted_control.lower():
1193
- logger.info(f"Catalog Control {formatted_control} matched")
1194
- matched_controls.append(catalog_control)
1195
- break
1196
- return matched_controls
1197
-
1198
-
1199
- def get_assessment_status_from_implementation_status(status: str) -> str:
1200
- """
1201
- Get the assessment status from the implementation status
1202
-
1203
- :param str status: Implementation status
1204
- :return: Assessment status
1205
- :rtype: str
1206
- """
1207
- if status == FULLY_IMPLEMENTED:
1208
- return "Pass"
1209
- if status == IN_REMEDIATION:
1210
- return "Fail"
1211
- else:
1212
- return "N/A"
1213
-
1214
-
1215
- def create_assessment_from_cim(cim: Dict, user_id: str, control: Dict, check: List[AssetCheck]) -> Dict:
1216
- """
1217
- Create an assessment from a control implementation
1218
-
1219
- :param Dict cim: Control Implementation
1220
- :param str user_id: User ID
1221
- :param Dict control: Control
1222
- :param List[AssetCheck] check: Asset Check
1223
- :return: Assessment
1224
- :rtype: Dict
1225
- """
1226
- assessment_result = get_assessment_status_from_implementation_status(cim.get("status"))
1227
- summary_dict = check[0].dict() if check else dict()
1228
- summary_dict.pop("reference", None)
1229
- title = summary_dict.get("check_name") if summary_dict else control.get("title")
1230
- html_summary = format_dict_to_html(summary_dict)
1231
- document_reviewed = check[0].audit_file if check else None
1232
- check_name = check[0].check_name if check else None
1233
- methodology = check[0].check_info if check else None
1234
- summary_of_results = check[0].description if check else None
1235
- uuid = check[0].asset_uuid if check and check[0].asset_uuid is not None else None
1236
- title_part = f"{title} - {uuid}" if uuid else f"{title}"
1237
- uuid_title = f"{title_part} Automated Assessment test"
1238
- return {
1239
- "leadAssessorId": user_id,
1240
- "title": uuid_title,
1241
- "assessmentType": "Control Testing",
1242
- "plannedStart": get_current_datetime(),
1243
- "plannedFinish": get_current_datetime(),
1244
- "status": "Complete",
1245
- "assessmentResult": assessment_result if assessment_result else "N/A",
1246
- "controlID": cim["id"],
1247
- "actualFinish": get_current_datetime(),
1248
- "assessmentReport": html_summary if html_summary else "Passed",
1249
- "parentId": cim["id"],
1250
- "parentModule": "controls",
1251
- "assessmentPlan": check_name if check_name else None,
1252
- "documentsReviewed": document_reviewed if document_reviewed else None,
1253
- "methodology": methodology if methodology else None,
1254
- "summaryOfResults": summary_of_results if summary_of_results else None,
1255
- }
1256
-
1257
-
1258
- def get_control_assessments(control: Dict, assessments_to_create: List[Dict]) -> List[Dict]:
1259
- """
1260
- Get control assessments
1261
-
1262
- :param Dict control: Control
1263
- :param List[Dict] assessments_to_create: List of assessments to create
1264
- :return: List of control assessments
1265
- :rtype: List[Dict]
1266
- """
1267
- return [
1268
- assess
1269
- for assess in assessments_to_create
1270
- if assess["controlID"] == control["id"] and assess["status"] == "Complete"
1271
- ]
1272
-
1273
-
1274
- def sort_assessments(control_assessments: List[Dict]) -> List[Dict]:
1275
- """
1276
- Sort assessments by actual finish date
1277
-
1278
- :param List[Dict] control_assessments: List of control assessments
1279
- :return: Sorted assessments
1280
- :rtype: List[Dict]
1281
- """
1282
- dt_format = "%Y-%m-%d %H:%M:%S"
1283
- return sorted(
1284
- control_assessments,
1285
- key=lambda x: datetime.strptime(x["actualFinish"], dt_format),
1286
- reverse=True,
1287
- )
1288
-
1289
-
1290
- def update_control_object(control: Dict, sorted_assessments: List[Dict]) -> None:
1291
- """
1292
- Update control object
1293
-
1294
- :param Dict control: Control
1295
- :param List[Dict] sorted_assessments: Sorted assessments
1296
- :rtype: None
1297
- """
1298
- dt_format = "%Y-%m-%d %H:%M:%S"
1299
- app = Application()
1300
- control["dateLastAssessed"] = sorted_assessments[0]["actualFinish"]
1301
- control["lastAssessmentResult"] = sorted_assessments[0]["assessmentResult"]
1302
- if control.get("lastAssessmentResult"):
1303
- control_obj = ControlImplementation(**control)
1304
- if control_obj.lastAssessmentResult == "Fail" and control_obj.status != IN_REMEDIATION:
1305
- control_obj.status = IN_REMEDIATION
1306
- control_obj.plannedImplementationDate = (datetime.now() + timedelta(30)).strftime(dt_format)
1307
- control_obj.stepsToImplement = "n/a"
1308
- elif control_obj.status == IN_REMEDIATION:
1309
- control_obj.plannedImplementationDate = (
1310
- (datetime.now() + timedelta(30)).strftime(dt_format)
1311
- if not control_obj.plannedImplementationDate
1312
- else control_obj.plannedImplementationDate
1313
- )
1314
- control_obj.stepsToImplement = "n/a" if not control_obj.stepsToImplement else control_obj.stepsToImplement
1315
- elif control_obj.lastAssessmentResult == "Pass" and control_obj.status != FULLY_IMPLEMENTED:
1316
- control_obj.status = FULLY_IMPLEMENTED
1317
- ControlImplementation.update(app=app, implementation=control_obj)
1318
-
1319
-
1320
- def update_control_implementations(control_implementations: List[Dict], assessments_to_create: List[Dict]) -> None:
1321
- """
1322
- Update control implementations with assessments
1323
-
1324
- :param List[Dict] control_implementations: List of control implementations
1325
- :param List[Dict] assessments_to_create: List of assessments to create
1326
- :rtype: None
1327
- """
1328
- for control in control_implementations:
1329
- control_assessments = get_control_assessments(control, assessments_to_create)
1330
- if sorted_assessments := sort_assessments(control_assessments):
1331
- update_control_object(control, sorted_assessments)
1332
-
1333
-
1334
- def post_assessments_to_api(assessments_to_create: List[Dict]) -> None:
1335
- """
1336
- Post assessments to the API
1337
-
1338
- :param List[Dict] assessments_to_create: List of assessments to create
1339
- :rtype: None
1340
- """
1341
- app = Application()
1342
- api = Api()
1343
- assessment_url = urljoin(app.config.get("domain", ""), "/api/assessments/batchCreate")
1344
- assessment_response = api.post(url=assessment_url, json=assessments_to_create)
1345
- if assessment_response.ok:
1346
- logger.info(f"Created {len(assessment_response.json())} Assessments!")
1347
- else:
1348
- logger.debug(assessment_response.status_code)
1349
- logger.error(f"Failed to insert Assessment.\n{assessment_response.text}")
1350
-
1351
-
1352
- def create_assessments(
1353
- control_implementations: List[Dict],
1354
- catalog_controls_dict: Dict,
1355
- asset_checks: Dict,
1356
- ) -> None:
1357
- """
1358
- Create assessments from control implementations
1359
-
1360
- :param List[Dict] control_implementations: List of control implementations
1361
- :param Dict catalog_controls_dict: Dictionary of catalog controls
1362
- :param Dict asset_checks: Dictionary of asset checks
1363
- :rtype: None
1364
- :return: None
1365
- """
1366
- app = Application()
1367
- user_id = app.config.get("userId", "")
1368
- assessments_to_create = []
1369
- for cim in control_implementations:
1370
- control = catalog_controls_dict.get(cim["controlID"], {})
1371
- check = asset_checks.get(control["controlId"].lower())
1372
- assessment = create_assessment_from_cim(cim, user_id, control, check)
1373
- assessments_to_create.append(assessment)
1374
- update_control_implementations(control_implementations, assessments_to_create)
1375
- post_assessments_to_api(assessments_to_create)
1376
-
1377
-
1378
- def process_compliance_data(
1379
- framework_data: Dict,
1380
- catalog_id: int,
1381
- ssp_id: int,
1382
- framework: str,
1383
- passing_controls: Dict,
1384
- failing_controls: Dict,
1385
- ) -> None:
1386
- """
1387
- Processes the compliance data from Tenable.io to create control implementations for controls in frameworks
1388
-
1389
- :param Dict framework_data: List of tenable.io controls per framework
1390
- :param int catalog_id: The catalog id
1391
- :param int ssp_id: The ssp id
1392
- :param str framework: The framework name
1393
- :param Dict passing_controls: Dictionary of passing controls
1394
- :param Dict failing_controls: Dictionary of failing controls
1395
- :rtype: None
1396
- """
1397
- if not framework_data:
1398
- return
1399
- framework_controls = framework_data.get("controls", {})
1400
- asset_checks = framework_data.get("asset_checks", {})
1401
- existing_implementation_dict = get_existing_control_implementations(ssp_id)
1402
- catalog_controls = get_controls(catalog_id)
1403
- matched_controls = []
1404
- for tenable_framework, tenable_controls in framework_controls.items():
1405
- logger.info(f"Found {len(tenable_controls)} controls that passed for framework: {tenable_framework}")
1406
- # logger.info(f"tenable_controls: {tenable_controls[0]}") if len(tenable_controls) >0 else None
1407
- if tenable_framework == framework:
1408
- matched_controls = get_matched_controls(tenable_controls, catalog_controls)
1409
-
1410
- logger.info(f"Found {len(matched_controls)} controls that matched")
1411
-
1412
- control_implementations = create_control_implementations(
1413
- controls=matched_controls,
1414
- parent_id=ssp_id,
1415
- parent_module="securityplans",
1416
- existing_implementation_dict=existing_implementation_dict,
1417
- passing_controls=passing_controls,
1418
- failing_controls=failing_controls,
1419
- )
1420
-
1421
- logger.info(f"SSP now has {len(control_implementations)} control implementations")
1422
- catalog_controls_dict = {c["id"]: c for c in catalog_controls}
1423
- create_assessments(control_implementations, catalog_controls_dict, asset_checks)
1424
-
1425
-
1426
- def convert_control_id(control_id: str) -> str:
1427
- """
1428
- Convert the control id to a format that can be used in Tenable.io
1429
-
1430
- :param str control_id: The control id to convert
1431
- :return: The converted control id
1432
- :rtype: str
1433
- """
1434
- # Convert to lowercase
1435
- control_id = control_id.lower()
1436
-
1437
- # Check if there's a parenthesis and replace its content
1438
- if "(" in control_id and ")" in control_id:
1439
- inner_value = control_id.split("(")[1].split(")")[0]
1440
- control_id = control_id.replace(f"({inner_value})", f".{inner_value}")
1441
-
1442
- return control_id
1443
-
1444
-
1445
- @io.command(name="sync_compliance_controls")
1446
- @regscale_ssp_id()
1447
- @click.option(
1448
- "--catalog_id",
1449
- type=click.INT,
1450
- help="The ID number from RegScale Catalog that the System Security Plan's controls belong to",
1451
- prompt="Enter RegScale Catalog ID",
1452
- required=True,
1453
- )
1454
- @click.option(
1455
- "--framework",
1456
- required=True,
1457
- type=click.Choice(["800-53", "800-53r5", "CSF", "800-171"], case_sensitive=True),
1458
- help="The framework to use. from Tenable.io frameworks MUST be the same RegScale Catalog of controls",
1459
- )
1460
- @hidden_file_path(help="The file path to load control data instead of fetching from Tenable.io")
1461
- def sync_compliance_data(regscale_ssp_id: int, catalog_id: int, framework: str, offline: Optional[Path] = None):
1462
- """
1463
- Sync the compliance data from Tenable.io to create control implementations for controls in frameworks.
1464
- """
1465
- _sync_compliance_data(ssp_id=regscale_ssp_id, catalog_id=catalog_id, framework=framework, offline=offline)
1466
-
1467
-
1468
- def _sync_compliance_data(ssp_id: int, catalog_id: int, framework: str, offline: Optional[Path] = None) -> None:
1469
- """
1470
- Sync the compliance data from Tenable.io to create control implementations for controls in frameworks
1471
- :param int ssp_id: The ID number from RegScale of the System Security Plan
1472
- :param int catalog_id: The ID number from RegScale Catalog that the System Security Plan's controls belong to
1473
- :param str framework: The framework to use. from Tenable.io frameworks MUST be the same RegScale Catalog of controls
1474
- :param Optional[Path] offline: The file path to load control data instead of fetching from Tenable.io, defaults to None
1475
- :rtype: None
1476
- """
1477
- logger.info("Note: This command only available for Tenable.io")
1478
- logger.info("Note: This command Requires admin access.")
1479
- app = Application()
1480
- config = app.config
1481
- # we specifically don't gen client here, so we only get the client for Tenable.io as its only supported there
1482
-
1483
- compliance_data = _get_compliance_data(config=config, offline=offline) # type: ignore
1484
-
1485
- dict_of_frameworks_and_asset_checks: Dict = dict()
1486
- framework_controls: Dict[str, List[str]] = {}
1487
- asset_checks: Dict[str, List[AssetCheck]] = {}
1488
- passing_controls: Dict = dict()
1489
- # partial_passing_controls: Dict = dict()
1490
- failing_controls: Dict = dict()
1491
- for findings in compliance_data:
1492
- asset_check = AssetCheck(**findings)
1493
- for ref in asset_check.reference:
1494
- if ref.framework not in framework_controls:
1495
- framework_controls[ref.framework] = []
1496
- if ref.control not in framework_controls[ref.framework]: # Avoid duplicate controls
1497
- framework_controls[ref.framework].append(ref.control)
1498
- formatted_control_id = convert_control_id(ref.control)
1499
- # sort controls by status
1500
- add_control_to_status_dict(
1501
- control_id=formatted_control_id,
1502
- status=asset_check.status,
1503
- dict_obj=failing_controls,
1504
- desired_status="FAILED",
1505
- )
1506
- add_control_to_status_dict(
1507
- control_id=formatted_control_id,
1508
- status=asset_check.status,
1509
- dict_obj=passing_controls,
1510
- desired_status="PASSED",
1511
- )
1512
- remove_passing_controls_if_in_failed_status(passing=passing_controls, failing=failing_controls)
1513
- if formatted_control_id not in asset_checks:
1514
- asset_checks[formatted_control_id] = [asset_check]
1515
- else:
1516
- asset_checks[formatted_control_id].append(asset_check)
1517
- dict_of_frameworks_and_asset_checks = {
1518
- key: {"controls": framework_controls, "asset_checks": asset_checks} for key in framework_controls.keys()
1519
- }
1520
- logger.info(f"Found {len(dict_of_frameworks_and_asset_checks)} findings to process")
1521
- framework_data = dict_of_frameworks_and_asset_checks.get(framework, None)
1522
- process_compliance_data(
1523
- framework_data=framework_data,
1524
- catalog_id=catalog_id,
1525
- ssp_id=ssp_id,
1526
- framework=framework,
1527
- passing_controls=passing_controls,
1528
- failing_controls=failing_controls,
1529
- )
1530
-
1531
-
1532
- def _get_compliance_data(config: dict, offline: Optional[Path] = None) -> Dict:
1533
- """
1534
- Get compliance data from Tenable.io
1535
-
1536
- :param dict config: Configuration dictionary
1537
- :param Optional[Path] offline: File path to load control data instead of fetching from Tenable.io
1538
- :return: Compliance data
1539
- :rtype: Dict
1540
- """
1541
- from tenable.io import TenableIO
1542
-
1543
- if offline:
1544
- with open(offline.absolute(), "r") as f:
1545
- compliance_data = json.load(f)
1546
- else:
1547
- client = TenableIO(
1548
- url=config["tenableUrl"],
1549
- access_key=config["tenableAccessKey"],
1550
- secret_key=config["tenableSecretKey"],
1551
- vendor=REGSCALE_INC,
1552
- product=REGSCALE_CLI,
1553
- build=__version__,
1554
- )
1555
- compliance_data = client.exports.compliance()
1556
- return compliance_data
1557
-
1558
-
1559
- def add_control_to_status_dict(control_id: str, status: str, dict_obj: Dict, desired_status: str) -> None:
1560
- """
1561
- Add a control to a status dictionary
1562
-
1563
- :param str control_id: The control id to add to the dictionary
1564
- :param str status: The status of the control
1565
- :param Dict dict_obj: The dictionary to add the control to
1566
- :param str desired_status: The desired status of the control
1567
- :rtype: None
1568
- """
1569
- friendly_control_id = control_id.lower()
1570
- if status == desired_status and friendly_control_id not in dict_obj:
1571
- dict_obj[friendly_control_id] = desired_status
1572
-
1573
-
1574
- def remove_passing_controls_if_in_failed_status(passing: Dict, failing: Dict) -> None:
1575
- """
1576
- Remove passing controls if they are in failed status
1577
-
1578
- :param Dict passing: Dictionary of passing controls
1579
- :param Dict failing: Dictionary of failing controls
1580
- :rtype: None
1581
- """
1582
- to_remove = []
1583
- for k in passing.keys():
1584
- if k in failing.keys():
1585
- to_remove.append(k)
1586
-
1587
- for k in to_remove:
1588
- del passing[k]
1589
-
1590
-
1591
- def write_io_chunk(
1592
- data: List[dict],
1593
- data_dir: Path,
1594
- export_uuid: str,
1595
- export_type: str,
1596
- export_chunk_id: int,
1597
- ) -> None:
1598
- """
1599
- Write a chunk of data to a file, this function is formatted for use with PyTenable and Tenable IO
1600
-
1601
- :param List[dict] data: Data to write to a file
1602
- :param Path data_dir: Directory to write the file to
1603
- :param str export_uuid: UUID of the export (Tenable IO)
1604
- :param str export_type: Type of export (Tenable IO)
1605
- :param int export_chunk_id: ID of the chunk (Tenable IO)
1606
- :rtype: None
1607
- """
1608
- # create tenable io directory
1609
- data_dir.mkdir(parents=True, exist_ok=True)
1610
- fn = data_dir / f"{export_type}-{export_uuid}-{export_chunk_id}.json"
1611
- # append file to path
1612
- with open(file=fn, mode="w", encoding="utf-8") as file_object:
1613
- json.dump(data, file_object)
1614
-
1615
-
1616
- def process_to_regscale(data_dir: Path, ssp_id: int, existing_assets: List[Asset]) -> None:
1617
- """
1618
- Process the Tenable data to RegScale
1619
-
1620
- :param Path data_dir: Directory to process the data from
1621
- :param int ssp_id: The ID of the System Security Plan
1622
- :param List[Asset] existing_assets: List of existing assets
1623
- :rtype: None
1624
- :return: None
1625
- """
1626
- # get all files in the directory
1627
- files = list(data_dir.glob("*.json"))
1628
- if not files:
1629
- logger.warning("No Tenable files found in %s.", data_dir)
1630
- return
1631
- logger.info("Processing %i chunked file(s) from Tenable...", len(list(files)))
1632
- for file in files:
1633
- logger.info("Processing chunked data: %s", file)
1634
- file_assets = []
1635
- with open(file=file, mode="r", encoding="utf-8") as file_object:
1636
- tenable_io_data = json.load(file_object)
1637
- for asset in tenable_io_data:
1638
- file_assets.append(TenableIOAsset(**asset))
1639
- TenableIOAsset.sync_to_regscale(assets=file_assets, ssp_id=ssp_id, existing_assets=existing_assets)
1640
- # remove processed file
1641
- file.unlink()