regscale-cli 6.24.0.1__py3-none-any.whl → 6.25.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 (30) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/api.py +1 -1
  3. regscale/core/app/application.py +5 -3
  4. regscale/core/app/internal/evidence.py +308 -202
  5. regscale/dev/code_gen.py +84 -3
  6. regscale/integrations/commercial/__init__.py +2 -0
  7. regscale/integrations/commercial/microsoft_defender/defender.py +326 -5
  8. regscale/integrations/commercial/microsoft_defender/defender_api.py +348 -14
  9. regscale/integrations/commercial/microsoft_defender/defender_constants.py +157 -0
  10. regscale/integrations/commercial/synqly/assets.py +99 -16
  11. regscale/integrations/commercial/synqly/query_builder.py +533 -0
  12. regscale/integrations/commercial/synqly/vulnerabilities.py +134 -14
  13. regscale/integrations/commercial/wizv2/compliance_report.py +22 -0
  14. regscale/integrations/compliance_integration.py +17 -0
  15. regscale/integrations/scanner_integration.py +16 -0
  16. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  17. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +12 -2
  18. regscale/models/integration_models/synqly_models/filter_parser.py +332 -0
  19. regscale/models/integration_models/synqly_models/synqly_model.py +47 -3
  20. regscale/models/regscale_models/compliance_settings.py +28 -0
  21. regscale/models/regscale_models/component.py +1 -0
  22. regscale/models/regscale_models/control_implementation.py +130 -1
  23. regscale/regscale.py +1 -1
  24. regscale/validation/record.py +23 -1
  25. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/METADATA +1 -1
  26. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/RECORD +30 -28
  27. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/LICENSE +0 -0
  28. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/WHEEL +0 -0
  29. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/entry_points.txt +0 -0
  30. {regscale_cli-6.24.0.1.dist-info → regscale_cli-6.25.0.0.dist-info}/top_level.txt +0 -0
regscale/dev/code_gen.py CHANGED
@@ -7,9 +7,13 @@ if TYPE_CHECKING:
7
7
 
8
8
  from regscale.models.integration_models.synqly_models.connector_types import ConnectorType
9
9
  from regscale.models.integration_models.synqly_models.param import Param
10
+ from regscale.models.integration_models.synqly_models.filter_parser import FilterParser
10
11
 
11
12
  SUPPORTED_CONNECTORS = [ConnectorType.Ticketing, ConnectorType.Vulnerabilities, ConnectorType.Assets, ConnectorType.Edr]
12
13
 
14
+ # Initialize FilterParser once at module level
15
+ filter_parser = FilterParser()
16
+
13
17
 
14
18
  def generate_dags() -> None:
15
19
  """Generate Airflow DAGs for the platform"""
@@ -189,6 +193,22 @@ def _build_op_kwargs_and_docstring(
189
193
  ),
190
194
  }
191
195
  config[param_type] = {**config[param_type], **vuln_params}
196
+
197
+ # Add filter parameter for Assets and Vulnerabilities connectors
198
+ if (
199
+ ConnectorType.Assets.lower() in integration or ConnectorType.Vulnerabilities.lower() in integration
200
+ ) and param_type == "optional_params":
201
+ # Use 'asset_filter' for vulnerabilities, 'filter' for assets
202
+ param_name = "asset_filter" if ConnectorType.Vulnerabilities.lower() in integration else "filter"
203
+ filter_param = {
204
+ param_name: Param(
205
+ name=param_name,
206
+ type="string",
207
+ description="Semicolon separated filters of format filter[operator]value",
208
+ default=None,
209
+ )
210
+ }
211
+ config[param_type] = {**config.get(param_type, {}), **filter_param}
192
212
  if config.get(param_type):
193
213
  if proper_type not in doc_string:
194
214
  doc_string += f"{proper_type}:\n"
@@ -290,6 +310,7 @@ def add_connector_specific_params(
290
310
  sync_attachments_jinja = "'sync_attachments'"
291
311
  op_kwargs += f'\n "sync_attachments": "{{{{ dag_run.conf[{sync_attachments_jinja}] if {sync_attachments_jinja} in dag_run.conf else False }}}}",'
292
312
  doc_string += " sync_attachments: BOOLEAN Whether to sync attachments between integration and RegScale\n"
313
+
293
314
  return op_kwargs, doc_string
294
315
 
295
316
 
@@ -353,6 +374,44 @@ def {connector}() -> None:
353
374
  pass
354
375
  """
355
376
 
377
+ # Add build-query command for Assets and Vulnerabilities connectors
378
+ if connector in [ConnectorType.Assets, ConnectorType.Vulnerabilities]:
379
+ cli_code += f"""
380
+
381
+ @{connector}.command(name="build-query")
382
+ @click.option(
383
+ '--provider',
384
+ required=False,
385
+ help='Provider ID (e.g., {connector}_armis_centrix). If not specified, starts interactive mode.'
386
+ )
387
+ @click.option(
388
+ '--validate',
389
+ help='Validate a filter string against provider capabilities'
390
+ )
391
+ @click.option(
392
+ '--list-fields',
393
+ is_flag=True,
394
+ default=False,
395
+ help='List all available fields for the provider'
396
+ )
397
+ def build_query(provider, validate, list_fields):
398
+ \"\"\"
399
+ Build and validate filter queries for {connector.capitalize()} connectors.
400
+
401
+ Examples:
402
+ # Build a filter query
403
+ regscale {connector} build-query
404
+
405
+ # List all fields for a specific provider
406
+ regscale {connector} build-query --provider {connector}_armis_centrix --list-fields
407
+
408
+ # Validate a filter string
409
+ regscale {connector} build-query --provider {connector}_armis_centrix --validate "device.ip[eq]192.168.1.1"
410
+ \"\"\"
411
+ from regscale.integrations.commercial.synqly.query_builder import handle_build_query
412
+ handle_build_query('{connector}', provider, validate, list_fields)
413
+ """
414
+
356
415
  # replace the integration config with a flattened version
357
416
  for integration, config in integration_configs.items():
358
417
  capabilities = config.get("capabilities", [])
@@ -363,6 +422,7 @@ def {connector}() -> None:
363
422
  connector=connector,
364
423
  integration_name=integration_name,
365
424
  capabilities=capabilities,
425
+ provider_id=integration, # Pass the full provider ID for filter support
366
426
  )
367
427
  cli_code += f"\n\n{click_options_and_command}"
368
428
  integrations_count += 1
@@ -374,12 +434,15 @@ def {connector}() -> None:
374
434
  print(f"Generated click commands for {integrations_count} {connector} connector(s).")
375
435
 
376
436
 
377
- def _build_all_params(integration_name: str, connector: str) -> tuple[list[str], list[str], list[str]]:
437
+ def _build_all_params(
438
+ integration_name: str, connector: str, provider_id: str = None
439
+ ) -> tuple[list[str], list[str], list[str]]:
378
440
  """
379
441
  Function to build the click options, function params, and function kwargs for the integration
380
442
 
381
443
  :param str integration_name: The name of the integration
382
444
  :param str connector: The connector type
445
+ :param str provider_id: The provider ID for filter support (e.g., 'assets_armis_centrix')
383
446
  :return: The click options, function params, and function kwargs
384
447
  :rtype: tuple[list[str], list[str], list[str]]
385
448
  """
@@ -395,19 +458,36 @@ def _build_all_params(integration_name: str, connector: str) -> tuple[list[str],
395
458
  "scan_date=scan_date",
396
459
  "all_scans=all_scans",
397
460
  ]
461
+
462
+ # Add filter option if provider supports filtering
463
+ if provider_id and filter_parser.has_filters(provider_id):
464
+ filter_option = "@click.option(\n '--asset_filter',\n help='STRING: Apply filters to asset queries. Can be a single filter \"field[operator]value\" or semicolon-separated filters \"field1[op]value1;field2[op]value2\"',\n required=False,\n type=str,\n default=None)\n"
465
+ click_options.append(filter_option)
466
+ function_params.append("asset_filter: str")
467
+ function_kwargs.append("filter=asset_filter.split(';') if asset_filter else []")
468
+
398
469
  elif connector == ConnectorType.Ticketing:
399
470
  click_options = ["@regscale_id()", "@regscale_module()"]
400
471
  function_params = ["regscale_id: int", "regscale_module: str"]
401
472
  function_kwargs = ["regscale_id=regscale_id", "regscale_module=regscale_module"]
402
473
  else:
474
+ # Assets and other connectors
403
475
  click_options = ["@regscale_ssp_id()"]
404
476
  function_params = ["regscale_ssp_id: int"]
405
477
  function_kwargs = ["regscale_ssp_id=regscale_ssp_id"]
478
+
479
+ # Add filter option for Assets if provider supports filtering
480
+ if connector == ConnectorType.Assets and provider_id and filter_parser.has_filters(provider_id):
481
+ filter_option = "@click.option(\n '--filter',\n help='STRING: Apply filters to the query. Can be a single filter \"field[operator]value\" or semicolon-separated filters \"field1[op]value1;field2[op]value2\"',\n required=False,\n type=str,\n default=None)\n"
482
+ click_options.append(filter_option)
483
+ function_params.append("filter: str")
484
+ function_kwargs.append("filter=filter.split(';') if filter else []")
485
+
406
486
  return click_options, function_params, function_kwargs
407
487
 
408
488
 
409
489
  def _build_click_options_and_command(
410
- config: dict, connector: str, integration_name: str, capabilities: list[str]
490
+ config: dict, connector: str, integration_name: str, capabilities: list[str], provider_id: str = None
411
491
  ) -> str:
412
492
  """
413
493
  Function to use the config to build the click options and command for the integration
@@ -416,12 +496,13 @@ def _build_click_options_and_command(
416
496
  :param str connector: The connector type
417
497
  :param str integration_name: The name of the integration
418
498
  :param list[str] capabilities: The capabilities of the integration
499
+ :param str provider_id: The provider ID for filter support (e.g., 'assets_armis_centrix')
419
500
  :return: The click options as a string
420
501
  :rtype: str
421
502
  """
422
503
  doc_string_name = integration_name.replace("_", " ").title()
423
504
  # add regscale_ssp_id as a default option
424
- click_options, function_params, function_kwargs = _build_all_params(doc_string_name, connector)
505
+ click_options, function_params, function_kwargs = _build_all_params(doc_string_name, connector, provider_id)
425
506
  for param_type in ["expected_params", "optional_params"]:
426
507
  for param in config.get(param_type, []):
427
508
  param_data = config[param_type][param]
@@ -118,6 +118,8 @@ def crowdstrike():
118
118
  "sync_cloud_alerts": "regscale.integrations.commercial.microsoft_defender.defender.sync_cloud_alerts",
119
119
  "sync_cloud_recommendations": "regscale.integrations.commercial.microsoft_defender.defender.sync_cloud_recommendations",
120
120
  "import_alerts": "regscale.integrations.commercial.microsoft_defender.defender.import_alerts",
121
+ "collect_entra_evidence": "regscale.integrations.commercial.microsoft_defender.defender.collect_entra_evidence",
122
+ "show_entra_mappings": "regscale.integrations.commercial.microsoft_defender.defender.show_entra_mappings",
121
123
  },
122
124
  name="defender",
123
125
  )
@@ -2,6 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """RegScale Microsoft Defender recommendations and alerts integration"""
4
4
  # standard python imports
5
+
5
6
  import logging
6
7
  from datetime import datetime, timedelta
7
8
  from os import PathLike
@@ -10,6 +11,7 @@ from typing import Literal, Optional, Tuple, Union
10
11
 
11
12
  import click
12
13
  from rich.console import Console
14
+ from rich.table import Table
13
15
 
14
16
  from regscale.core.app.api import Api
15
17
  from regscale.core.app.internal.login import is_valid
@@ -24,6 +26,7 @@ from regscale.core.app.utils.app_utils import (
24
26
  uncamel_case,
25
27
  )
26
28
  from regscale.integrations.commercial.microsoft_defender.defender_api import DefenderApi
29
+ from regscale.integrations.commercial.microsoft_defender.defender_constants import EVIDENCE_TO_CONTROLS_MAPPING
27
30
  from regscale.models import File, Issue, regscale_id, regscale_module, ssp_or_component_id
28
31
  from regscale.models.app_models.click import NotRequiredIf
29
32
  from regscale.models.integration_models.defender_data import DefenderData
@@ -68,12 +71,12 @@ def defender():
68
71
  @defender.command(name="authenticate")
69
72
  @click.option(
70
73
  "--system",
71
- type=click.Choice(["cloud", "365"], case_sensitive=False),
72
- help="Pull recommendations from Microsoft Defender 365 or Microsoft Defender for Cloud.",
74
+ type=click.Choice(["cloud", "365", "entra"], case_sensitive=False),
75
+ help="Pull recommendations from Microsoft Defender 365, Microsoft Defender for Cloud, or Azure Entra.",
73
76
  prompt="Please choose a system",
74
77
  required=True,
75
78
  )
76
- def authenticate_in_defender(system: Literal["cloud", "365"]):
79
+ def authenticate_in_defender(system: Literal["cloud", "365", "entra"]):
77
80
  """Obtains an access token using the credentials provided in init.yaml."""
78
81
  authenticate(system=system)
79
82
 
@@ -225,6 +228,85 @@ def import_alerts(
225
228
  )
226
229
 
227
230
 
231
+ @defender.command(name="collect_entra_evidence")
232
+ @ssp_or_component_id()
233
+ @click.option(
234
+ "--days_back",
235
+ "-d",
236
+ type=click.INT,
237
+ help="Number of days back to collect audit logs",
238
+ default=30,
239
+ )
240
+ @click.option(
241
+ "--evidence_type",
242
+ "-t",
243
+ type=click.Choice(
244
+ ["all", "users_groups", "rbac_pim", "conditional_access", "authentication", "audit_logs", "access_reviews"],
245
+ case_sensitive=False,
246
+ ),
247
+ help="Type of evidence to collect",
248
+ default="all",
249
+ )
250
+ def collect_entra_evidence(regscale_ssp_id: int, component_id: int, days_back: int, evidence_type: str):
251
+ """
252
+ Collect Azure Entra evidence for FedRAMP compliance controls and upload to RegScale
253
+ """
254
+ # Validate parent module for evidence collection
255
+ from regscale.validation.record import validate_component_or_ssp
256
+
257
+ validate_component_or_ssp(ssp_id=regscale_ssp_id, component_id=component_id)
258
+ parent_id = regscale_ssp_id or component_id
259
+ parent_module = "securityplans" if regscale_ssp_id else "components"
260
+
261
+ collect_and_upload_entra_evidence(
262
+ parent_id=parent_id, parent_module=parent_module, days_back=days_back, evidence_type=evidence_type
263
+ )
264
+
265
+
266
+ @defender.command(name="show_entra_mappings")
267
+ @click.option(
268
+ "--evidence_type",
269
+ "-t",
270
+ type=click.Choice(
271
+ ["all", "users_groups", "rbac_pim", "conditional_access", "authentication", "audit_logs", "access_reviews"],
272
+ case_sensitive=False,
273
+ ),
274
+ help="Show mappings for specific evidence type",
275
+ default="all",
276
+ )
277
+ def show_entra_mappings(evidence_type: str):
278
+ """
279
+ Show which FedRAMP controls are mapped to each Azure Entra evidence type
280
+ """
281
+ if evidence_type == "all":
282
+ evidence_types_to_show = EVIDENCE_TO_CONTROLS_MAPPING.keys()
283
+ else:
284
+ # Map category to specific evidence types
285
+ category_to_evidence = {
286
+ "users_groups": ["users", "guest_users", "security_groups"],
287
+ "rbac_pim": ["role_assignments", "role_definitions", "pim_assignments", "pim_eligibility"],
288
+ "conditional_access": ["conditional_access"],
289
+ "authentication": ["auth_methods_policy", "user_mfa_registration", "mfa_registered_users"],
290
+ "audit_logs": ["sign_in_logs", "directory_audits", "provisioning_logs"],
291
+ "access_reviews": ["access_review_definitions"],
292
+ }
293
+ evidence_types_to_show = category_to_evidence.get(evidence_type, [evidence_type])
294
+ # create a table using rich and add a row for each evidence type
295
+ table = Table(title="Azure Entra Evidence to FedRAMP Controls Mapping", show_lines=True)
296
+ table.add_column("Evidence Type", style="#10c4d3")
297
+ table.add_column("Controls", style="#18a8e9")
298
+ table.add_column("Total Controls", style="#ff9d20")
299
+ for evidence_key in evidence_types_to_show:
300
+ if evidence_key in EVIDENCE_TO_CONTROLS_MAPPING:
301
+ controls = EVIDENCE_TO_CONTROLS_MAPPING[evidence_key]
302
+ table.add_row(evidence_key.replace("_", " ").title(), ", ".join(controls), str(len(controls)))
303
+ console.print(table)
304
+
305
+ console.print(
306
+ "[dim]Use 'regscale defender collect_entra_evidence' to collect and upload evidence to these controls[/dim]"
307
+ )
308
+
309
+
228
310
  def import_defender_alerts(
229
311
  folder_path: PathLike[str],
230
312
  regscale_ssp_id: int,
@@ -268,11 +350,11 @@ def import_defender_alerts(
268
350
  )
269
351
 
270
352
 
271
- def authenticate(system: Literal["cloud", "365"]) -> None:
353
+ def authenticate(system: Literal["cloud", "365", "entra"]) -> None:
272
354
  """
273
355
  Obtains an access token using the credentials provided in init.yaml
274
356
 
275
- :param Literal["cloud", "365"] system: The system to authenticate for, either Defender 365 or Defender for Cloud
357
+ :param Literal["cloud", "365", "entra"] system: The system to authenticate for, either Defender 365, Defender for Cloud, or Azure Entra
276
358
  :rtype: None
277
359
  """
278
360
  _ = check_license()
@@ -947,3 +1029,242 @@ def fetch_save_and_upload_query(
947
1029
  api=defender_api.api,
948
1030
  ):
949
1031
  logger.info(f"Successfully uploaded {file_path.name} to {parent_module} #{parent_id} in RegScale.")
1032
+
1033
+
1034
+ def collect_and_upload_entra_evidence(
1035
+ parent_id: int, parent_module: str, days_back: int = 30, evidence_type: str = "all"
1036
+ ) -> None:
1037
+ """
1038
+ Collect Azure Entra evidence for FedRAMP compliance controls and upload to RegScale
1039
+
1040
+ :param int parent_id: The RegScale ID to upload evidence to
1041
+ :param str parent_module: The RegScale module to upload evidence to
1042
+ :param int days_back: Number of days back to collect audit logs
1043
+ :param str evidence_type: Type of evidence to collect
1044
+ :rtype: None
1045
+ """
1046
+ app = check_license()
1047
+ api = Api()
1048
+
1049
+ if not is_valid(app=app):
1050
+ error_and_exit(LOGIN_ERROR)
1051
+
1052
+ logger.info(f"Starting Azure Entra evidence collection for {evidence_type}...")
1053
+
1054
+ defender_api = DefenderApi(system="entra")
1055
+
1056
+ try:
1057
+ if evidence_type == "all":
1058
+ evidence_data = defender_api.collect_all_entra_evidence(days_back=days_back)
1059
+ else:
1060
+ evidence_data = collect_specific_evidence_type(defender_api, evidence_type, days_back)
1061
+
1062
+ upload_evidence_files(evidence_data, parent_id, parent_module, api, evidence_type)
1063
+
1064
+ except Exception as e:
1065
+ error_and_exit(f"Error collecting Azure Entra evidence: {e}")
1066
+
1067
+
1068
+ def collect_specific_evidence_type(
1069
+ defender_api: DefenderApi, evidence_type: str, days_back: int
1070
+ ) -> dict[str, list[Path]]:
1071
+ """
1072
+ Collect specific type of Azure Entra evidence
1073
+
1074
+ :param DefenderApi defender_api: The Defender API instance
1075
+ :param str evidence_type: Type of evidence to collect
1076
+ :param int days_back: Number of days back for audit logs
1077
+ :return: Dictionary containing evidence data and file paths to saved csv evidence files
1078
+ :rtype: dict[str, list[Path]]
1079
+ """
1080
+ evidence_data = {}
1081
+ start_date = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%dT00:00:00Z")
1082
+
1083
+ if evidence_type == "users_groups":
1084
+ evidence_data["users"] = defender_api.get_and_save_entra_evidence("users")
1085
+ evidence_data["guest_users"] = defender_api.get_and_save_entra_evidence("guest_users")
1086
+ evidence_data["groups_and_members"] = defender_api.get_and_save_entra_evidence("groups_and_members")
1087
+ evidence_data["security_groups"] = defender_api.get_and_save_entra_evidence("security_groups")
1088
+
1089
+ elif evidence_type == "rbac_pim":
1090
+ evidence_data["role_assignments"] = defender_api.get_and_save_entra_evidence("role_assignments")
1091
+ evidence_data["role_definitions"] = defender_api.get_and_save_entra_evidence("role_definitions")
1092
+ evidence_data["pim_assignments"] = defender_api.get_and_save_entra_evidence("pim_assignments")
1093
+ evidence_data["pim_eligibility"] = defender_api.get_and_save_entra_evidence("pim_eligibility")
1094
+
1095
+ elif evidence_type == "conditional_access":
1096
+ evidence_data["conditional_access"] = defender_api.get_and_save_entra_evidence("conditional_access")
1097
+
1098
+ elif evidence_type == "authentication":
1099
+ evidence_data["auth_methods_policy"] = defender_api.get_and_save_entra_evidence("auth_methods_policy")
1100
+ evidence_data["user_mfa_registration"] = defender_api.get_and_save_entra_evidence("user_mfa_registration")
1101
+ evidence_data["mfa_registered_users"] = defender_api.get_and_save_entra_evidence("mfa_registered_users")
1102
+
1103
+ elif evidence_type == "audit_logs":
1104
+ evidence_data["sign_in_logs"] = defender_api.get_and_save_entra_evidence("sign_in_logs", start_date=start_date)
1105
+ evidence_data["directory_audits"] = defender_api.get_and_save_entra_evidence(
1106
+ "directory_audits", start_date=start_date
1107
+ )
1108
+ evidence_data["provisioning_logs"] = defender_api.get_and_save_entra_evidence(
1109
+ "provisioning_logs", start_date=start_date
1110
+ )
1111
+
1112
+ elif evidence_type == "access_reviews":
1113
+ evidence_data["access_review_definitions"] = defender_api.collect_entra_access_reviews()
1114
+
1115
+ return evidence_data
1116
+
1117
+
1118
+ def get_control_implementations_map(parent_id: int, parent_module: str) -> dict[str, int]:
1119
+ """
1120
+ Get a mapping of control identifiers to control implementation IDs
1121
+
1122
+ :param int parent_id: RegScale parent ID
1123
+ :param str parent_module: RegScale parent module
1124
+ :return: Dictionary mapping control identifiers (e.g., "AC-2") to control implementation IDs
1125
+ :rtype: dict[str, int]
1126
+ """
1127
+ from regscale.models import ControlImplementation
1128
+
1129
+ try:
1130
+ control_implementations = ControlImplementation.get_list_by_parent(parent_id, parent_module)
1131
+ if not control_implementations:
1132
+ logger.warning(f"No control implementations found for {parent_module} #{parent_id}")
1133
+ return {}
1134
+
1135
+ control_map = {}
1136
+ for control_impl in control_implementations:
1137
+ # Try to get control identifier from the control object
1138
+ control_id = control_impl.get("controlId") if isinstance(control_impl, dict) else control_impl.controlId
1139
+ id_number = control_impl.get("id") if isinstance(control_impl, dict) else control_impl.id
1140
+ control_map[control_id] = id_number
1141
+ logger.debug(f"Mapped control #{id_number}: {control_id} to implementation.")
1142
+
1143
+ logger.info(f"Found {len(control_map)} control implementations for evidence mapping")
1144
+ return control_map
1145
+
1146
+ except Exception as e:
1147
+ logger.error(f"Error fetching control implementations: {e}")
1148
+ return {}
1149
+
1150
+
1151
+ def upload_evidence_to_controls(
1152
+ evidence_key: str,
1153
+ evidence_file_list: list[Path],
1154
+ control_implementations_map: dict[str, int],
1155
+ api: Api,
1156
+ ) -> int:
1157
+ """
1158
+ Upload evidence file to specific control implementations
1159
+
1160
+ :param str evidence_key: Type of evidence (e.g., "users", "sign_in_logs")
1161
+ :param list evidence_file_list: List of evidence files
1162
+ :param dict control_implementations_map: Map of control identifiers to implementation IDs
1163
+ :param Api api: API instance
1164
+ :return: Number of successful uploads
1165
+ :rtype: int
1166
+ """
1167
+ # Get the controls this evidence type maps to
1168
+ mapped_controls = EVIDENCE_TO_CONTROLS_MAPPING.get(evidence_key, [])
1169
+ if not mapped_controls:
1170
+ logger.warning(f"No control mapping found for evidence type: {evidence_key}")
1171
+ return 0
1172
+
1173
+ # Write evidence data to CSV file
1174
+ successful_uploads = 0
1175
+ for file_path in evidence_file_list:
1176
+ controls_uploaded_to = []
1177
+ file_name = file_path.name
1178
+
1179
+ for control_identifier in mapped_controls:
1180
+ if control_identifier in control_implementations_map:
1181
+ control_impl_id = control_implementations_map[control_identifier]
1182
+
1183
+ # Upload file to specific control implementation
1184
+ if File.upload_file_to_regscale(
1185
+ file_name=file_path.absolute(), # type: ignore
1186
+ parent_id=control_impl_id,
1187
+ parent_module="controls",
1188
+ api=api,
1189
+ ):
1190
+ successful_uploads += 1
1191
+ controls_uploaded_to.append(control_identifier)
1192
+ logger.debug(
1193
+ f"Successfully uploaded {file_name} to control {control_identifier} (ID: {control_impl_id})"
1194
+ )
1195
+ else:
1196
+ logger.error(
1197
+ f"Failed to upload {file_name} to control {control_identifier} (ID: {control_impl_id})"
1198
+ )
1199
+
1200
+ if controls_uploaded_to:
1201
+ logger.info(
1202
+ f"Successfully uploaded {file_name} to {len(controls_uploaded_to)} controls: {', '.join(controls_uploaded_to)}"
1203
+ )
1204
+ else:
1205
+ logger.warning(f"No matching control implementations found for {evidence_key} evidence")
1206
+
1207
+ return successful_uploads
1208
+
1209
+
1210
+ def upload_evidence_files(
1211
+ evidence_data: dict[str, list[Path]], parent_id: int, parent_module: str, api: Api, evidence_type: str
1212
+ ) -> None:
1213
+ """
1214
+ Upload evidence files to specific RegScale control implementations
1215
+
1216
+ :param dict[str, list[Path]] evidence_data: Dictionary containing evidence data
1217
+ :param int parent_id: RegScale parent ID
1218
+ :param str parent_module: RegScale parent module
1219
+ :param Api api: API instance
1220
+ :param str evidence_type: Type of evidence collected
1221
+ :rtype: None
1222
+ """
1223
+ from regscale.integrations.commercial.microsoft_defender.defender_constants import EVIDENCE_CATEGORIES
1224
+
1225
+ artifacts_dir = Path("./artifacts")
1226
+ artifacts_dir.mkdir(exist_ok=True)
1227
+
1228
+ # Get control implementations mapping for evidence targeting
1229
+ control_implementations_map = get_control_implementations_map(parent_id, parent_module)
1230
+
1231
+ if not control_implementations_map:
1232
+ logger.error(
1233
+ f"No control implementations found for {parent_module} #{parent_id}. Cannot map evidence to controls."
1234
+ )
1235
+ return
1236
+
1237
+ total_successful_uploads = 0
1238
+ total_evidence_items = 0
1239
+ evidence_summary = []
1240
+
1241
+ for evidence_key, evidence_list in evidence_data.items():
1242
+ if not evidence_list:
1243
+ logger.warning(f"No data found for {evidence_key}")
1244
+ continue
1245
+
1246
+ total_evidence_items += len(evidence_list)
1247
+
1248
+ # Upload evidence to specific control implementations
1249
+ uploads_for_evidence = upload_evidence_to_controls(
1250
+ evidence_key=evidence_key,
1251
+ evidence_file_list=evidence_list,
1252
+ control_implementations_map=control_implementations_map,
1253
+ api=api,
1254
+ )
1255
+
1256
+ total_successful_uploads += uploads_for_evidence
1257
+ evidence_summary.append(f"{evidence_key}: {len(evidence_list)} items → {uploads_for_evidence} control uploads")
1258
+
1259
+ # Summary
1260
+ category_name = EVIDENCE_CATEGORIES.get(evidence_type, f"Azure Entra {evidence_type.replace('_', ' ').title()}")
1261
+ logger.info(
1262
+ f"Azure Entra evidence collection complete for {category_name}. "
1263
+ f"Collected {total_evidence_items} total items across {total_successful_uploads} control-specific uploads."
1264
+ )
1265
+
1266
+ # Detailed summary
1267
+ if evidence_summary:
1268
+ logger.info("Evidence upload summary:")
1269
+ for summary in evidence_summary:
1270
+ logger.info(f" - {summary}")