regscale-cli 6.20.10.0__py3-none-any.whl → 6.21.1.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 (64) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +12 -5
  3. regscale/core/app/internal/set_permissions.py +58 -27
  4. regscale/integrations/commercial/__init__.py +1 -2
  5. regscale/integrations/commercial/amazon/common.py +79 -2
  6. regscale/integrations/commercial/aws/cli.py +183 -9
  7. regscale/integrations/commercial/aws/scanner.py +544 -9
  8. regscale/integrations/commercial/cpe.py +18 -1
  9. regscale/integrations/commercial/nessus/scanner.py +2 -0
  10. regscale/integrations/commercial/sonarcloud.py +35 -36
  11. regscale/integrations/commercial/synqly/ticketing.py +51 -0
  12. regscale/integrations/commercial/tenablev2/jsonl_scanner.py +2 -1
  13. regscale/integrations/commercial/wizv2/async_client.py +10 -3
  14. regscale/integrations/commercial/wizv2/click.py +102 -26
  15. regscale/integrations/commercial/wizv2/constants.py +249 -1
  16. regscale/integrations/commercial/wizv2/issue.py +2 -2
  17. regscale/integrations/commercial/wizv2/parsers.py +3 -2
  18. regscale/integrations/commercial/wizv2/policy_compliance.py +1858 -0
  19. regscale/integrations/commercial/wizv2/scanner.py +15 -21
  20. regscale/integrations/commercial/wizv2/utils.py +258 -85
  21. regscale/integrations/commercial/wizv2/variables.py +4 -3
  22. regscale/integrations/compliance_integration.py +1455 -0
  23. regscale/integrations/integration_override.py +15 -6
  24. regscale/integrations/public/fedramp/fedramp_five.py +1 -1
  25. regscale/integrations/public/fedramp/markdown_parser.py +7 -1
  26. regscale/integrations/scanner_integration.py +193 -37
  27. regscale/models/app_models/__init__.py +1 -0
  28. regscale/models/integration_models/amazon_models/inspector_scan.py +32 -57
  29. regscale/models/integration_models/aqua.py +92 -78
  30. regscale/models/integration_models/cisa_kev_data.json +117 -5
  31. regscale/models/integration_models/defenderimport.py +64 -59
  32. regscale/models/integration_models/ecr_models/ecr.py +100 -147
  33. regscale/models/integration_models/flat_file_importer/__init__.py +52 -38
  34. regscale/models/integration_models/ibm.py +29 -47
  35. regscale/models/integration_models/nexpose.py +156 -68
  36. regscale/models/integration_models/prisma.py +46 -66
  37. regscale/models/integration_models/qualys.py +99 -93
  38. regscale/models/integration_models/snyk.py +229 -158
  39. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  40. regscale/models/integration_models/veracode.py +15 -20
  41. regscale/{integrations/commercial/wizv2/models.py → models/integration_models/wizv2.py} +4 -12
  42. regscale/models/integration_models/xray.py +276 -82
  43. regscale/models/regscale_models/control_implementation.py +14 -12
  44. regscale/models/regscale_models/file.py +4 -0
  45. regscale/models/regscale_models/issue.py +123 -0
  46. regscale/models/regscale_models/milestone.py +1 -1
  47. regscale/models/regscale_models/rbac.py +22 -0
  48. regscale/models/regscale_models/regscale_model.py +4 -2
  49. regscale/models/regscale_models/security_plan.py +1 -1
  50. regscale/utils/graphql_client.py +3 -1
  51. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/METADATA +9 -9
  52. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/RECORD +64 -60
  53. tests/fixtures/test_fixture.py +58 -2
  54. tests/regscale/core/test_app.py +5 -3
  55. tests/regscale/core/test_version_regscale.py +5 -3
  56. tests/regscale/integrations/test_integration_mapping.py +522 -40
  57. tests/regscale/integrations/test_issue_due_date.py +1 -1
  58. tests/regscale/integrations/test_update_finding_dates.py +336 -0
  59. tests/regscale/integrations/test_wiz_policy_compliance_affected_controls.py +154 -0
  60. tests/regscale/models/test_asset.py +406 -50
  61. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/LICENSE +0 -0
  62. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/WHEEL +0 -0
  63. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/entry_points.txt +0 -0
  64. {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1455 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Abstract Compliance Integration Base Class
5
+
6
+ This module provides a base class for implementing compliance integrations
7
+ that follow common patterns across different compliance tools (Wiz, Tenable, Sicura).
8
+ """
9
+ import logging
10
+ import re
11
+ from abc import ABC, abstractmethod
12
+ from collections import defaultdict
13
+ from typing import Dict, List, Optional, Any, Iterator
14
+
15
+ from regscale.core.app.utils.app_utils import get_current_datetime, regscale_string_to_datetime
16
+ from regscale.integrations.scanner_integration import (
17
+ ScannerIntegration,
18
+ IntegrationAsset,
19
+ IntegrationFinding,
20
+ )
21
+ from regscale.models import regscale_models
22
+ from regscale.models.regscale_models import (
23
+ Catalog,
24
+ SecurityControl,
25
+ ControlImplementation,
26
+ Assessment,
27
+ ImplementationObjective,
28
+ )
29
+
30
+ logger = logging.getLogger("regscale")
31
+
32
+ # Safer, linear-time regex for control-id parsing/normalization used across
33
+ # compliance integrations. Supports: 'AC-4', 'AC-4(2)', 'AC-4 (2)', 'AC-4-2', 'AC-4 2'
34
+ # Distinct branches ('(', '-' or whitespace) avoid ambiguous nested alternation
35
+ # and excessive backtracking that could be used for DoS.
36
+ SAFE_CONTROL_ID_RE = re.compile( # NOSONAR
37
+ r"^([A-Za-z]{2}-\d+)(?:\s*\(\s*(\d+)\s*\)|-\s*(\d+)|\s+(\d+))?$", # NOSONAR
38
+ re.IGNORECASE, # NOSONAR
39
+ )
40
+
41
+
42
+ class ComplianceItem(ABC):
43
+ """
44
+ Abstract base class representing a compliance assessment item.
45
+
46
+ This represents a single compliance check result for a specific
47
+ resource against a specific control.
48
+ """
49
+
50
+ @property
51
+ @abstractmethod
52
+ def resource_id(self) -> str:
53
+ """Unique identifier for the resource being assessed."""
54
+ pass
55
+
56
+ @property
57
+ @abstractmethod
58
+ def resource_name(self) -> str:
59
+ """Human-readable name of the resource."""
60
+ pass
61
+
62
+ @property
63
+ @abstractmethod
64
+ def control_id(self) -> str:
65
+ """Control identifier (e.g., AC-3, SI-2)."""
66
+ pass
67
+
68
+ @property
69
+ @abstractmethod
70
+ def compliance_result(self) -> str:
71
+ """Result of compliance check (PASS, FAIL, etc)."""
72
+ pass
73
+
74
+ @property
75
+ @abstractmethod
76
+ def severity(self) -> Optional[str]:
77
+ """Severity level of the compliance violation (if failed)."""
78
+ pass
79
+
80
+ @property
81
+ @abstractmethod
82
+ def description(self) -> str:
83
+ """Description of the compliance check."""
84
+ pass
85
+
86
+ @property
87
+ @abstractmethod
88
+ def framework(self) -> str:
89
+ """Compliance framework (e.g., NIST800-53R5, CSF)."""
90
+ pass
91
+
92
+
93
+ class ComplianceIntegration(ScannerIntegration, ABC):
94
+ """
95
+ Abstract base class for compliance integrations.
96
+
97
+ This class provides common patterns for:
98
+ - Processing compliance data
99
+ - Creating assets from compliance items
100
+ - Creating findings/issues for failed compliance
101
+ - Mapping compliance items to controls
102
+ - Creating assessments and updating control status
103
+ """
104
+
105
+ # Status mapping constants
106
+ PASS_STATUSES = ["PASS", "PASSED", "pass", "passed"]
107
+ FAIL_STATUSES = ["FAIL", "FAILED", "fail", "failed"]
108
+
109
+ def __init__(
110
+ self,
111
+ plan_id: int,
112
+ catalog_id: Optional[int] = None,
113
+ framework: str = "NIST800-53R5",
114
+ create_issues: bool = True,
115
+ update_control_status: bool = True,
116
+ create_poams: bool = False,
117
+ **kwargs,
118
+ ):
119
+ """
120
+ Initialize compliance integration.
121
+
122
+ :param int plan_id: RegScale plan ID
123
+ :param Optional[int] catalog_id: RegScale catalog ID
124
+ :param str framework: Compliance framework
125
+ :param bool create_issues: Whether to create issues for failed compliance
126
+ :param bool update_control_status: Whether to update control implementation status
127
+ :param bool create_poams: Whether to mark issues as POAMs
128
+ """
129
+ super().__init__(plan_id=plan_id, **kwargs)
130
+
131
+ self.catalog_id = catalog_id
132
+ self.framework = framework
133
+ self.create_issues = create_issues
134
+ self.update_control_status = update_control_status
135
+ self.create_poams = create_poams
136
+
137
+ # Compliance data storage
138
+ self.all_compliance_items: List[ComplianceItem] = []
139
+ self.failed_compliance_items: List[ComplianceItem] = []
140
+ self.passing_controls: Dict[str, ComplianceItem] = {}
141
+ self.failing_controls: Dict[str, ComplianceItem] = {}
142
+
143
+ # Asset mapping for compliance to asset correlation
144
+ self.asset_compliance_map: Dict[str, List[ComplianceItem]] = defaultdict(list)
145
+
146
+ # Initialize caches for existing records to prevent duplicates
147
+ self._existing_assets_cache: Dict[str, regscale_models.Asset] = {}
148
+ self._existing_issues_cache: Dict[str, regscale_models.Issue] = {}
149
+ self._existing_assessments_cache: Dict[str, regscale_models.Assessment] = {}
150
+ self._cache_loaded = False
151
+
152
+ # Mapping caches for linking issues to implementations and assessments
153
+ # Key: canonical control id string (e.g., "AC-2(1)") -> ControlImplementation.id
154
+ self._impl_id_by_control: Dict[str, int] = {}
155
+ # Key: ControlImplementation.id -> Assessment created/updated today
156
+ self._assessment_by_impl_today: Dict[int, regscale_models.Assessment] = {}
157
+
158
+ # Set scan date
159
+ self.scan_date = get_current_datetime()
160
+
161
+ def is_poam(self, finding: IntegrationFinding) -> bool: # type: ignore[override]
162
+ """
163
+ Determines if an issue should be considered a POAM for compliance integrations.
164
+
165
+ - If the integration was initialized with `create_poams=True` (e.g., via `--create-poams/-cp`),
166
+ always return True so newly created and updated issues are POAMs.
167
+ - Otherwise, defer to the generic scanner behavior.
168
+ """
169
+ try:
170
+ if getattr(self, "create_poams", False):
171
+ return True
172
+ if finding.due_date >= get_current_datetime():
173
+ return True
174
+ except Exception:
175
+ pass
176
+ return super().is_poam(finding)
177
+
178
+ def _load_existing_records_cache(self) -> None:
179
+ """
180
+ Load existing RegScale records into cache to prevent duplicates.
181
+ This method populates caches for assets, issues, and assessments.
182
+
183
+ :return: None
184
+ :rtype: None
185
+ """
186
+ if self._cache_loaded:
187
+ return
188
+
189
+ logger.info("Loading existing RegScale records to prevent duplicates...")
190
+
191
+ try:
192
+ # Load existing assets for this plan
193
+ self._load_existing_assets()
194
+
195
+ # Load existing issues for this plan
196
+ self._load_existing_issues()
197
+
198
+ # Load existing assessments for control implementations
199
+ self._load_existing_assessments()
200
+
201
+ self._cache_loaded = True
202
+ logger.info("🗄️ Loaded existing records cache to prevent duplicates:")
203
+ logger.info(f" - Assets: {len(self._existing_assets_cache)}")
204
+ logger.info(f" - Issues: {len(self._existing_issues_cache)}")
205
+ logger.info(f" - Assessments: {len(self._existing_assessments_cache)}")
206
+
207
+ except Exception as e:
208
+ logger.error(f"Error loading existing records cache: {e}")
209
+ # Continue without cache to avoid blocking the integration
210
+ self._cache_loaded = True
211
+
212
+ def _load_existing_assets(self) -> None:
213
+ """
214
+ Load existing assets into cache.
215
+
216
+ :return: None
217
+ :rtype: None
218
+ """
219
+ try:
220
+ # Get all assets for this plan
221
+ existing_assets = regscale_models.Asset.get_all_by_parent(
222
+ parent_id=self.plan_id, parent_module=self.parent_module
223
+ )
224
+
225
+ for asset in existing_assets:
226
+ # Cache by external_id, identifier, and other_tracking_number for flexible lookup
227
+ if hasattr(asset, "externalId") and asset.externalId:
228
+ self._existing_assets_cache[asset.externalId] = asset
229
+ if hasattr(asset, "identifier") and asset.identifier:
230
+ self._existing_assets_cache[asset.identifier] = asset
231
+ if hasattr(asset, "otherTrackingNumber") and asset.otherTrackingNumber:
232
+ self._existing_assets_cache[asset.otherTrackingNumber] = asset
233
+
234
+ except Exception as e:
235
+ logger.debug(f"Error loading existing assets: {e}")
236
+
237
+ def _load_existing_issues(self) -> None:
238
+ """
239
+ Load existing issues into cache.
240
+
241
+ :return: None
242
+ :rtype: None
243
+ """
244
+ try:
245
+ # Get all issues for this plan
246
+ existing_issues = regscale_models.Issue.get_all_by_parent(
247
+ parent_id=self.plan_id, parent_module=self.parent_module
248
+ )
249
+
250
+ for issue in existing_issues:
251
+ # Cache by external_id and other_identifier for flexible lookup
252
+ if hasattr(issue, "externalId") and issue.externalId:
253
+ self._existing_issues_cache[issue.externalId] = issue
254
+ if hasattr(issue, "otherIdentifier") and issue.otherIdentifier:
255
+ self._existing_issues_cache[issue.otherIdentifier] = issue
256
+
257
+ except Exception as e:
258
+ logger.debug(f"Error loading existing issues: {e}")
259
+
260
+ def _load_existing_assessments(self) -> None:
261
+ """
262
+ Load existing assessments into cache.
263
+
264
+ :return: None
265
+ :rtype: None
266
+ """
267
+ try:
268
+ # Get control implementations for this plan to find their assessments
269
+ implementations = ControlImplementation.get_all_by_parent(
270
+ parent_id=self.plan_id, parent_module=self.parent_module
271
+ )
272
+
273
+ for implementation in implementations:
274
+ try:
275
+ # Get assessments for this implementation
276
+ assessments = regscale_models.Assessment.get_all_by_parent(
277
+ parent_id=implementation.id, parent_module="controls"
278
+ )
279
+
280
+ for assessment in assessments:
281
+ # Create cache key: impl_id + day (YYYY-MM-DD)
282
+ if hasattr(assessment, "actualFinish") and assessment.actualFinish:
283
+ try:
284
+ # actualFinish may be a string; normalize to date-only key
285
+ if hasattr(assessment.actualFinish, "date"):
286
+ day_key = assessment.actualFinish.date().isoformat()
287
+ else:
288
+ day_key = regscale_string_to_datetime(assessment.actualFinish).date().isoformat()
289
+ cache_key = f"{implementation.id}_{day_key}"
290
+ self._existing_assessments_cache[cache_key] = assessment
291
+ except Exception:
292
+ continue
293
+
294
+ except Exception as e:
295
+ logger.debug(f"Error loading assessments for implementation {implementation.id}: {e}")
296
+
297
+ except Exception as e:
298
+ logger.debug(f"Error loading existing assessments: {e}")
299
+
300
+ def _find_existing_asset_cached(self, resource_id: str) -> Optional[regscale_models.Asset]:
301
+ """
302
+ Find existing asset by resource ID using cache.
303
+
304
+ :param str resource_id: Resource identifier to search for
305
+ :return: Existing asset or None if not found
306
+ :rtype: Optional[regscale_models.Asset]
307
+ """
308
+ return self._existing_assets_cache.get(resource_id)
309
+
310
+ def _find_existing_issue_cached(self, external_id: str) -> Optional[regscale_models.Issue]:
311
+ """
312
+ Find existing issue by external ID using cache.
313
+
314
+ :param str external_id: External identifier to search for
315
+ :return: Existing issue or None if not found
316
+ :rtype: Optional[regscale_models.Issue]
317
+ """
318
+ return self._existing_issues_cache.get(external_id)
319
+
320
+ def _find_existing_assessment_cached(
321
+ self, implementation_id: int, scan_date
322
+ ) -> Optional[regscale_models.Assessment]:
323
+ """
324
+ Find existing assessment by implementation ID and date using cache.
325
+
326
+ :param int implementation_id: Control implementation ID
327
+ :param scan_date: Scan date to check against existing assessments
328
+ :return: Existing assessment or None if not found
329
+ :rtype: Optional[regscale_models.Assessment]
330
+ """
331
+ # Normalize to date-only key
332
+ try:
333
+ if hasattr(scan_date, "date"):
334
+ day_key = scan_date.date().isoformat()
335
+ else:
336
+ day_key = regscale_string_to_datetime(str(scan_date)).date().isoformat()
337
+ except Exception:
338
+ day_key = str(scan_date).split(" ")[0]
339
+ cache_key = f"{implementation_id}_{day_key}"
340
+ return self._existing_assessments_cache.get(cache_key)
341
+
342
+ @abstractmethod
343
+ def fetch_compliance_data(self) -> List[Any]:
344
+ """
345
+ Fetch raw compliance data from the external system.
346
+
347
+ :return: List of raw compliance data (will be converted to ComplianceItems)
348
+ :rtype: List[Any]
349
+ """
350
+ pass
351
+
352
+ @abstractmethod
353
+ def create_compliance_item(self, raw_data: Any) -> ComplianceItem:
354
+ """
355
+ Create a ComplianceItem from raw compliance data.
356
+
357
+ :param Any raw_data: Raw compliance data from external system
358
+ :return: ComplianceItem instance
359
+ :rtype: ComplianceItem
360
+ """
361
+ pass
362
+
363
+ def process_compliance_data(self) -> None:
364
+ """
365
+ Process compliance data and categorize items.
366
+
367
+ Separates passing and failing compliance items and builds
368
+ control status mappings.
369
+
370
+ :return: None
371
+ :rtype: None
372
+ """
373
+ logger.info("Processing compliance data...")
374
+
375
+ # Reset state to avoid double counting on repeated calls
376
+ self.all_compliance_items = []
377
+ self.failed_compliance_items = []
378
+ self.passing_controls = {}
379
+ self.failing_controls = {}
380
+ self.asset_compliance_map.clear()
381
+
382
+ # Build allowed control IDs from plan/catalog controls to restrict scope
383
+ allowed_controls_normalized: set[str] = set()
384
+ try:
385
+ controls = self._get_controls()
386
+ for ctl in controls:
387
+ cid = (ctl.get("controlId") or "").strip()
388
+ if not cid:
389
+ continue
390
+ base, sub = self._normalize_control_id(cid)
391
+ normalized = f"{base}({sub})" if sub else base
392
+ allowed_controls_normalized.add(normalized)
393
+ except Exception:
394
+ # If controls cannot be loaded, proceed without additional filtering
395
+ allowed_controls_normalized = set()
396
+
397
+ # Fetch raw compliance data
398
+ raw_compliance_data = self.fetch_compliance_data()
399
+
400
+ # Convert to ComplianceItem objects
401
+ for raw_item in raw_compliance_data:
402
+ try:
403
+ compliance_item = self.create_compliance_item(raw_item)
404
+ # Skip items that do not resolve to a control or resource
405
+ if not getattr(compliance_item, "control_id", "") or not getattr(compliance_item, "resource_id", ""):
406
+ continue
407
+
408
+ # If we have an allowed set, restrict to only controls in current plan/catalog
409
+ if allowed_controls_normalized:
410
+ base, sub = self._normalize_control_id(getattr(compliance_item, "control_id", ""))
411
+ norm_item = f"{base}({sub})" if sub else base
412
+ if norm_item not in allowed_controls_normalized:
413
+ continue
414
+ self.all_compliance_items.append(compliance_item)
415
+
416
+ # Build asset mapping
417
+ self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
418
+
419
+ # Categorize by result
420
+ if compliance_item.compliance_result in self.FAIL_STATUSES:
421
+ self.failed_compliance_items.append(compliance_item)
422
+ # Track failing controls (control can fail if ANY asset fails)
423
+ control_key = compliance_item.control_id.lower()
424
+ self.failing_controls[control_key] = compliance_item
425
+ # Remove from passing if it was there
426
+ self.passing_controls.pop(control_key, None)
427
+
428
+ elif compliance_item.compliance_result in self.PASS_STATUSES:
429
+ control_key = compliance_item.control_id.lower()
430
+ # Only mark as passing if not already failing
431
+ if control_key not in self.failing_controls:
432
+ self.passing_controls[control_key] = compliance_item
433
+
434
+ except Exception as e:
435
+ logger.error(f"Error processing compliance item: {e}")
436
+ continue
437
+
438
+ logger.info(
439
+ f"Processed {len(self.all_compliance_items)} compliance items: "
440
+ f"{len(self.all_compliance_items) - len(self.failed_compliance_items)} passing, "
441
+ f"{len(self.failed_compliance_items)} failing"
442
+ )
443
+
444
+ def create_asset_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[IntegrationAsset]:
445
+ """
446
+ Create an IntegrationAsset from a compliance item.
447
+
448
+ :param ComplianceItem compliance_item: The compliance item
449
+ :return: IntegrationAsset or None
450
+ :rtype: Optional[IntegrationAsset]
451
+ """
452
+ try:
453
+ # Check if asset already exists
454
+ existing_asset = self._find_existing_asset_by_resource_id(compliance_item.resource_id)
455
+ if existing_asset:
456
+ return None
457
+
458
+ asset_type = self._map_resource_type_to_asset_type(compliance_item)
459
+
460
+ asset = IntegrationAsset(
461
+ name=compliance_item.resource_name,
462
+ identifier=compliance_item.resource_id,
463
+ external_id=compliance_item.resource_id,
464
+ other_tracking_number=compliance_item.resource_id, # For deduplication
465
+ asset_type=asset_type,
466
+ asset_category=regscale_models.AssetCategory.Hardware,
467
+ description=f"Asset from {self.title} compliance scan",
468
+ parent_id=self.plan_id,
469
+ parent_module=self.parent_module,
470
+ status=regscale_models.AssetStatus.Active,
471
+ date_last_updated=self.scan_date,
472
+ )
473
+
474
+ return asset
475
+
476
+ except Exception as e:
477
+ logger.error(f"Error creating asset from compliance item: {e}")
478
+ return None
479
+
480
+ def create_finding_from_compliance_item(self, compliance_item: ComplianceItem) -> Optional[IntegrationFinding]:
481
+ """
482
+ Create an IntegrationFinding from a failed compliance item.
483
+
484
+ :param ComplianceItem compliance_item: The compliance item
485
+ :return: IntegrationFinding or None
486
+ :rtype: Optional[IntegrationFinding]
487
+ """
488
+ try:
489
+ control_labels = [compliance_item.control_id] if compliance_item.control_id else []
490
+ severity = self._map_severity(compliance_item.severity)
491
+
492
+ finding = IntegrationFinding(
493
+ control_labels=control_labels,
494
+ title=f"Compliance Violation: {compliance_item.control_id}",
495
+ category="Compliance",
496
+ plugin_name=f"{self.title} Compliance Scanner",
497
+ severity=severity,
498
+ description=compliance_item.description,
499
+ status=regscale_models.IssueStatus.Open,
500
+ priority=self._map_severity_to_priority(severity),
501
+ external_id=f"{self.title.lower()}-{compliance_item.control_id}-{compliance_item.resource_id}",
502
+ first_seen=self.scan_date,
503
+ last_seen=self.scan_date,
504
+ scan_date=self.scan_date,
505
+ asset_identifier=compliance_item.resource_id,
506
+ vulnerability_type="Compliance Violation",
507
+ rule_id=compliance_item.control_id,
508
+ baseline=compliance_item.framework,
509
+ affected_controls=",".join(compliance_item.control_id),
510
+ )
511
+
512
+ # Ensure affected controls are set to the normalized control label (e.g., RA-5, AC-2(1))
513
+ if compliance_item.control_id:
514
+ base, sub = self._normalize_control_id(compliance_item.control_id)
515
+ finding.affected_controls = f"{base}({sub})" if sub else base
516
+
517
+ return finding
518
+
519
+ except Exception as e:
520
+ logger.error(f"Error creating finding from compliance item: {e}")
521
+ return None
522
+
523
+ def fetch_assets(self, *args, **kwargs) -> Iterator[IntegrationAsset]:
524
+ """
525
+ Fetch assets from compliance items, avoiding duplicates.
526
+
527
+ :param args: Variable positional arguments
528
+ :param kwargs: Variable keyword arguments
529
+ :return: Iterator of integration assets
530
+ :rtype: Iterator[IntegrationAsset]
531
+ """
532
+ logger.info("Fetching assets from compliance items...")
533
+
534
+ # Load cache if not already loaded
535
+ self._load_existing_records_cache()
536
+
537
+ processed_resources = set()
538
+ for compliance_item in self.all_compliance_items:
539
+ if compliance_item.resource_id not in processed_resources:
540
+ # Check if asset already exists in RegScale
541
+ existing_asset = self._find_existing_asset_cached(compliance_item.resource_id)
542
+ if existing_asset:
543
+ logger.debug(f"Asset already exists for resource {compliance_item.resource_id}, skipping creation")
544
+ processed_resources.add(compliance_item.resource_id)
545
+ continue
546
+
547
+ asset = self.create_asset_from_compliance_item(compliance_item)
548
+ if asset:
549
+ processed_resources.add(compliance_item.resource_id)
550
+ yield asset
551
+
552
+ def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
553
+ """
554
+ Fetch findings from failed compliance items.
555
+
556
+ :param args: Variable positional arguments
557
+ :param kwargs: Variable keyword arguments
558
+ :return: Iterator of integration findings
559
+ :rtype: Iterator[IntegrationFinding]
560
+ """
561
+ logger.info("Fetching findings from failed compliance items...")
562
+
563
+ total = len(self.failed_compliance_items)
564
+ task_id = self.finding_progress.add_task(
565
+ f"[#f68d1f]Creating findings from {total} failed compliance item(s)...",
566
+ total=total or None,
567
+ )
568
+
569
+ for compliance_item in self.failed_compliance_items:
570
+ finding = self.create_finding_from_compliance_item(compliance_item)
571
+ if finding:
572
+ self.finding_progress.advance(task_id, 1)
573
+ yield finding
574
+
575
+ # Ensure task completes if total is known
576
+ if total:
577
+ self.finding_progress.update(task_id, completed=total)
578
+
579
+ def sync_compliance(self) -> None:
580
+ """
581
+ Main method to sync compliance data.
582
+
583
+ This method orchestrates the entire compliance sync process:
584
+ 1. Process compliance data
585
+ 2. Create assets and findings
586
+ 3. Create/update control assessments
587
+ 4. Update control implementation status
588
+
589
+ :return: None
590
+ :rtype: None
591
+ """
592
+ logger.info(f"Starting {self.title} compliance sync...")
593
+
594
+ try:
595
+ # Create scan history
596
+ scan_history = self.create_scan_history()
597
+
598
+ # Process compliance data
599
+ self.process_compliance_data()
600
+
601
+ # Create assets for all compliance items
602
+ assets = list(self.fetch_assets())
603
+ if assets:
604
+ assets_processed = self.update_regscale_assets(iter(assets))
605
+ # Use batch results recorded during bulk_save
606
+ results = getattr(self, "_results", {}).get("assets", {})
607
+ created = results.get("created_count", 0)
608
+ updated = results.get("updated_count", 0)
609
+ deleted = results.get("deleted_count", 0) if isinstance(results, dict) else 0
610
+ if deleted:
611
+ logger.info(
612
+ f"Assets processed: {assets_processed} (created: {created}, updated: {updated}, deleted: {deleted})"
613
+ )
614
+ else:
615
+ logger.info(f"Assets processed: {assets_processed} (created: {created}, updated: {updated})")
616
+
617
+ # Create/update control assessments first so issues can link controlId/assessmentId
618
+ if self.update_control_status:
619
+ self._process_control_assessments()
620
+
621
+ # Create issues only (no vulnerabilities) for failed compliance items
622
+ if self.create_issues:
623
+ findings = list(self.fetch_findings())
624
+ if findings:
625
+ for finding in findings:
626
+ try:
627
+ asset = self.get_asset_by_identifier(finding.asset_identifier)
628
+ if not asset:
629
+ # Attempt to create the asset on-demand from cached compliance data
630
+ asset = self._ensure_asset_for_finding(finding)
631
+ if not asset:
632
+ logger.error(
633
+ f"Asset not found for identifier {finding.asset_identifier} — "
634
+ "skipping issue creation for this finding"
635
+ )
636
+ continue
637
+
638
+ # Directly create/update issue without vulnerability processing
639
+ issue_title = self.get_issue_title(finding)
640
+ self.create_or_update_issue_from_finding(title=issue_title, finding=finding)
641
+ except Exception as e:
642
+ logger.error(f"Error processing finding: {e}")
643
+
644
+ # Do not write scan history for Wiz policy compliance when disabled
645
+ try:
646
+ if getattr(self, "enable_scan_history", True):
647
+ self._update_scan_history(scan_history)
648
+ except Exception:
649
+ self._update_scan_history(scan_history)
650
+
651
+ logger.info(f"Completed {self.title} compliance sync")
652
+
653
+ except Exception as e:
654
+ logger.error(f"Error during compliance sync: {e}")
655
+ raise
656
+
657
+ def _ensure_asset_for_finding(self, finding: IntegrationFinding) -> Optional[regscale_models.Asset]:
658
+ """
659
+ Ensure an asset exists for the given finding.
660
+
661
+ Attempts to locate the asset by identifier. If missing, it will try to
662
+ build an IntegrationAsset from the first compliance item associated with
663
+ the resource id and upsert it into RegScale, then return the created asset.
664
+
665
+ :param IntegrationFinding finding: Finding referencing the asset identifier
666
+ :return: The located or newly created Asset, or None if it cannot be created
667
+ :rtype: Optional[regscale_models.Asset]
668
+ """
669
+ try:
670
+ resource_id = getattr(finding, "asset_identifier", None)
671
+ if not resource_id:
672
+ return None
673
+
674
+ # Re-check cache/DB
675
+ asset = self.get_asset_by_identifier(resource_id)
676
+ if asset:
677
+ return asset
678
+
679
+ # Use compliance items we already processed to construct an asset
680
+ related_items = self.asset_compliance_map.get(resource_id, [])
681
+ if not related_items:
682
+ return None
683
+
684
+ candidate_item = related_items[0]
685
+ integration_asset = self.create_asset_from_compliance_item(candidate_item)
686
+ if not integration_asset:
687
+ return None
688
+
689
+ # Persist the asset and refresh lookup
690
+ _ = self.update_regscale_assets(iter([integration_asset]))
691
+ return self.get_asset_by_identifier(resource_id)
692
+
693
+ except Exception as ensure_exc:
694
+ logger.debug(
695
+ f"On-demand asset creation failed for {getattr(finding, 'asset_identifier', None)}: {ensure_exc}"
696
+ )
697
+ return None
698
+
699
+ def _process_control_assessments(self) -> None:
700
+ """
701
+ Process control assessments based on compliance results.
702
+
703
+ This follows the same pattern as the original Wiz compliance integration:
704
+ 1. Get control implementations
705
+ 2. For each implementation, get the security control using controlID
706
+ 3. Match the security control's controlId with the extracted control ID from compliance items
707
+
708
+ :return: None
709
+ :rtype: None
710
+ """
711
+ logger.info("Processing control assessments...")
712
+
713
+ # Ensure existing records cache (including assessments) is loaded to prevent duplicates
714
+ self._load_existing_records_cache()
715
+
716
+ implementations = self._get_control_implementations()
717
+ if not implementations:
718
+ logger.warning("No control implementations found for assessment processing")
719
+ return
720
+
721
+ all_control_ids = set(self.passing_controls.keys()) | set(self.failing_controls.keys())
722
+ logger.info(f"Processing assessments for {len(all_control_ids)} controls with compliance data")
723
+ logger.info(f"Control IDs with data: {sorted(list(all_control_ids))}")
724
+ self._log_sample_controls(implementations)
725
+
726
+ assessments_created = 0
727
+ processed_impl_today: set[int] = set()
728
+ for control_id in all_control_ids:
729
+ created = self._process_single_control_assessment(
730
+ control_id=control_id,
731
+ implementations=implementations,
732
+ processed_impl_today=processed_impl_today,
733
+ )
734
+ assessments_created += created
735
+
736
+ logger.info(f"Successfully created {assessments_created} control assessments")
737
+ passing_assessments = len([cid for cid in all_control_ids if cid not in self.failing_controls])
738
+ failing_assessments = len([cid for cid in all_control_ids if cid in self.failing_controls])
739
+ logger.info(f"Assessment breakdown: {passing_assessments} passing, {failing_assessments} failing")
740
+ logger.info(f"Control implementation mappings created: {len(self._impl_id_by_control)}")
741
+ if self._impl_id_by_control:
742
+ logger.debug(f"Sample mappings: {dict(list(self._impl_id_by_control.items())[:5])}")
743
+ logger.info(f"Today's assessments by implementation: {len(self._assessment_by_impl_today)}")
744
+ if self._assessment_by_impl_today:
745
+ logger.debug(f"Sample assessment mappings: {dict(list(self._assessment_by_impl_today.items())[:5])}")
746
+
747
+ def _get_control_implementations(self) -> List[ControlImplementation]:
748
+ """
749
+ Get all control implementations for the current plan.
750
+
751
+ :return: List of control implementations
752
+ :rtype: List[ControlImplementation]
753
+ """
754
+ implementations: List[ControlImplementation] = ControlImplementation.get_all_by_parent(
755
+ parent_module=self.parent_module, parent_id=self.plan_id
756
+ )
757
+ logger.info(f"Found {len(implementations)} control implementations")
758
+ return implementations
759
+
760
+ def _log_sample_controls(self, implementations: List[ControlImplementation]) -> None:
761
+ """
762
+ Log sample control IDs for debugging purposes.
763
+
764
+ :param List[ControlImplementation] implementations: List of implementations to sample from
765
+ :return: None
766
+ :rtype: None
767
+ """
768
+ sample_regscale_controls: List[str] = []
769
+ for impl in implementations[:10]:
770
+ try:
771
+ sec_control = SecurityControl.get_object(object_id=impl.controlID)
772
+ if sec_control and sec_control.controlId:
773
+ sample_regscale_controls.append(f"{sec_control.controlId}")
774
+ else:
775
+ sample_regscale_controls.append(f"NoControlId-impl:{impl.id}-controlID:{impl.controlID}")
776
+ except Exception as e: # noqa: BLE001
777
+ sample_regscale_controls.append(f"ERROR-impl:{impl.id}-controlID:{impl.controlID}-error:{str(e)[:50]}")
778
+ logger.error(
779
+ f"Error fetching SecurityControl for implementation {impl.id} with controlID {impl.controlID}: {e}"
780
+ )
781
+ logger.info(f"Sample RegScale control IDs available: {sample_regscale_controls}")
782
+
783
+ def _process_single_control_assessment(
784
+ self,
785
+ *,
786
+ control_id: str,
787
+ implementations: List[ControlImplementation],
788
+ processed_impl_today: set[int],
789
+ ) -> int:
790
+ """
791
+ Process assessment for a single control.
792
+
793
+ :param str control_id: Control identifier to process
794
+ :param List[ControlImplementation] implementations: Available control implementations
795
+ :param set[int] processed_impl_today: Set of implementation IDs already processed today
796
+ :return: Number of assessments created (0 or 1)
797
+ :rtype: int
798
+ """
799
+ try:
800
+ logger.debug(f"Processing control assessment for '{control_id}'")
801
+ impl, sec_control = self._find_matching_implementation(control_id, implementations)
802
+ if not impl or not sec_control:
803
+ self._log_no_match(control_id, implementations)
804
+ return 0
805
+
806
+ result = self._determine_overall_result(control_id)
807
+ items = self._get_control_compliance_items(control_id)
808
+ logger.debug(f"Control '{control_id}' assessment: {result} (based on {len(items)} policy assessments)")
809
+
810
+ if impl.id in processed_impl_today and self._find_existing_assessment_cached(impl.id, self.scan_date):
811
+ logger.debug(f"Skipping duplicate assessment for implementation {impl.id} (already processed today)")
812
+ else:
813
+ self._create_control_assessment(
814
+ implementation=impl,
815
+ catalog_control={"id": sec_control.id, "controlId": sec_control.controlId},
816
+ result=result,
817
+ control_id=control_id,
818
+ compliance_items=items,
819
+ )
820
+ processed_impl_today.add(impl.id)
821
+
822
+ self._record_control_mapping(control_id, impl.id)
823
+ self._map_assets_to_control_component(sec_control, items)
824
+ return 1
825
+ except Exception as e: # noqa: BLE001
826
+ logger.error(f"Error processing control assessment for '{control_id}': {e}")
827
+ import traceback
828
+
829
+ logger.debug(traceback.format_exc())
830
+ return 0
831
+
832
+ def _find_matching_implementation(
833
+ self, control_id: str, implementations: List[ControlImplementation]
834
+ ) -> tuple[Optional[ControlImplementation], Optional[SecurityControl]]:
835
+ """
836
+ Find matching implementation and security control for a control ID.
837
+
838
+ :param str control_id: Control identifier to match
839
+ :param List[ControlImplementation] implementations: Available implementations
840
+ :return: Tuple of matching implementation and security control, or (None, None)
841
+ :rtype: tuple[Optional[ControlImplementation], Optional[SecurityControl]]
842
+ """
843
+ matching_implementation = None
844
+ matching_security_control = None
845
+ for implementation in implementations:
846
+ try:
847
+ security_control = SecurityControl.get_object(object_id=implementation.controlID)
848
+ if not security_control:
849
+ logger.debug(
850
+ f"No security control found for implementation {implementation.id} with controlID: {implementation.controlID}"
851
+ )
852
+ continue
853
+ security_control_id = security_control.controlId
854
+ if not security_control_id:
855
+ logger.debug(f"Security control {security_control.id} has no controlId")
856
+ continue
857
+ logger.debug(
858
+ f"Comparing extracted '{control_id}' with RegScale control '{security_control_id}' (impl: {implementation.id})"
859
+ )
860
+ if self._control_ids_match(control_id, security_control_id):
861
+ matching_implementation = implementation
862
+ matching_security_control = security_control
863
+ logger.info(
864
+ f"✅ MATCH FOUND: '{security_control_id}' == '{control_id}' (implementation: {implementation.id})"
865
+ )
866
+ break
867
+ except Exception as e: # noqa: BLE001
868
+ logger.error(
869
+ f"Error processing implementation {implementation.id} with controlID {implementation.controlID}: {e}"
870
+ )
871
+ continue
872
+ return matching_implementation, matching_security_control
873
+
874
+ def _log_no_match(self, control_id: str, implementations: List[ControlImplementation]) -> None:
875
+ """
876
+ Log when no matching implementation is found for a control.
877
+
878
+ :param str control_id: Control identifier that couldn't be matched
879
+ :param List[ControlImplementation] implementations: Available implementations for context
880
+ :return: None
881
+ :rtype: None
882
+ """
883
+ logger.warning(f"No matching implementation found for control ID '{control_id}'")
884
+ sample_impl_controls = []
885
+ for impl in implementations[:5]:
886
+ try:
887
+ sec_control = SecurityControl.get_object(object_id=impl.controlID)
888
+ if sec_control and sec_control.controlId:
889
+ sample_impl_controls.append(f"{sec_control.controlId} (impl:{impl.id})")
890
+ except Exception:
891
+ sample_impl_controls.append(f"Unknown (impl:{impl.id})")
892
+ logger.debug(f"Sample implementation control IDs (first 5): {sample_impl_controls}")
893
+
894
+ def _determine_overall_result(self, control_id: str) -> str:
895
+ """
896
+ Determine overall assessment result for a control.
897
+
898
+ :param str control_id: Control identifier to check
899
+ :return: Assessment result ('Pass' or 'Fail')
900
+ :rtype: str
901
+ """
902
+ is_failing = (
903
+ control_id in self.failing_controls
904
+ or control_id.lower() in self.failing_controls
905
+ or control_id.upper() in self.failing_controls
906
+ )
907
+ return "Fail" if is_failing else "Pass"
908
+
909
+ def _get_control_compliance_items(self, control_id: str) -> List[ComplianceItem]:
910
+ """
911
+ Get all compliance items for a specific control.
912
+
913
+ :param str control_id: Control identifier to filter by
914
+ :return: List of compliance items for the control
915
+ :rtype: List[ComplianceItem]
916
+ """
917
+ items: List[ComplianceItem] = []
918
+ for item in self.all_compliance_items:
919
+ if hasattr(item, "control_ids"):
920
+ item_control_ids = getattr(item, "control_ids", [])
921
+ if any(cid.lower() == control_id.lower() for cid in item_control_ids):
922
+ items.append(item)
923
+ elif hasattr(item, "control_id") and item.control_id.lower() == control_id.lower():
924
+ items.append(item)
925
+ return items
926
+
927
+ def _record_control_mapping(self, control_id: str, implementation_id: int) -> None:
928
+ """
929
+ Record mapping between normalized control ID and implementation ID.
930
+
931
+ :param str control_id: Control identifier to map
932
+ :param int implementation_id: Implementation ID to associate
933
+ :return: None
934
+ :rtype: None
935
+ """
936
+ try:
937
+ base, sub = self._normalize_control_id(control_id)
938
+ canonical = f"{base}({sub})" if sub else base
939
+ self._impl_id_by_control[canonical] = implementation_id
940
+ logger.debug(f"Mapped control '{canonical}' -> implementation ID {implementation_id}")
941
+ except Exception:
942
+ pass
943
+
944
+ def _map_assets_to_control_component(self, sec_control: SecurityControl, items: List[ComplianceItem]) -> None:
945
+ """
946
+ Map assets to control-specific components for organization.
947
+
948
+ :param SecurityControl sec_control: Security control to create component for
949
+ :param List[ComplianceItem] items: Compliance items with asset references
950
+ :return: None
951
+ :rtype: None
952
+ """
953
+ try:
954
+ component_title = f"Control {sec_control.controlId}"
955
+ component = self.components_by_title.get(component_title) if hasattr(self, "components_by_title") else None
956
+ if not component:
957
+ component = regscale_models.Component(
958
+ title=component_title,
959
+ componentType=regscale_models.ComponentType.Hardware,
960
+ securityPlansId=self.plan_id,
961
+ description=component_title,
962
+ componentOwnerId=self.get_assessor_id(),
963
+ ).get_or_create()
964
+ regscale_models.ComponentMapping(
965
+ componentId=component.id,
966
+ securityPlanId=self.plan_id,
967
+ ).get_or_create()
968
+ if hasattr(self, "components_by_title"):
969
+ self.components_by_title[component_title] = component
970
+
971
+ for item in items:
972
+ asset = self.get_asset_by_identifier(getattr(item, "resource_id", ""))
973
+ if not asset:
974
+ continue
975
+ regscale_models.AssetMapping(
976
+ assetId=asset.id,
977
+ componentId=component.id,
978
+ ).get_or_create_with_status()
979
+ except Exception as map_exc: # noqa: BLE001
980
+ logger.debug(f"Control-to-asset mapping skipped due to: {map_exc}")
981
+
982
+ @staticmethod
983
+ def _parse_control_id(control_id: str) -> tuple[str, Optional[str]]:
984
+ """
985
+ Parse a control id like 'AC-2(1)', 'AC-2 (1)', 'AC-2-1' into (base, sub).
986
+
987
+ Returns (base, None) when no subcontrol.
988
+
989
+ :param str control_id: Control identifier to parse
990
+ :return: Tuple of (base_control, subcontrol) where subcontrol may be None
991
+ :rtype: tuple[str, Optional[str]]
992
+ """
993
+ cid = control_id.strip()
994
+ # Use precompiled safe regex to avoid catastrophic backtracking on crafted input
995
+ m = SAFE_CONTROL_ID_RE.match(cid)
996
+ if not m:
997
+ return cid.upper(), None
998
+ base = m.group(1).upper()
999
+ # Subcontrol may be captured in group 2, 3, or 4 depending on the branch matched
1000
+ sub = m.group(2) or m.group(3) or m.group(4)
1001
+ return base, sub
1002
+
1003
+ @classmethod
1004
+ def _control_ids_match(cls, a: str, b: str) -> bool:
1005
+ """
1006
+ Strict match of control ids. Exact match if equal.
1007
+ If subcontrols exist on either side, both must exist and be equal.
1008
+
1009
+ :param str a: First control ID to compare
1010
+ :param str b: Second control ID to compare
1011
+ :return: True if control IDs match according to strict rules
1012
+ :rtype: bool
1013
+ """
1014
+ if not a or not b:
1015
+ return False
1016
+ if a.strip().lower() == b.strip().lower():
1017
+ return True
1018
+ base_a, sub_a = cls._parse_control_id(a)
1019
+ base_b, sub_b = cls._parse_control_id(b)
1020
+ if base_a != base_b:
1021
+ return False
1022
+ # If either has a subcontrol, require both and equality
1023
+ if sub_a or sub_b:
1024
+ return (sub_a is not None) and (sub_b is not None) and (sub_a == sub_b)
1025
+ # No subcontrols -> base equals
1026
+ return True
1027
+
1028
+ @staticmethod
1029
+ def _normalize_control_id(control_id: str) -> tuple[str, Optional[str]]:
1030
+ """
1031
+ Normalize control id to a canonical tuple (BASE, SUB) for set membership.
1032
+
1033
+ :param str control_id: Control identifier to normalize
1034
+ :return: Tuple of (base_control, subcontrol) in canonical form
1035
+ :rtype: tuple[str, Optional[str]]
1036
+ """
1037
+ cid = (control_id or "").strip()
1038
+ # Use precompiled safe regex to avoid catastrophic backtracking on crafted input
1039
+ m = SAFE_CONTROL_ID_RE.match(cid)
1040
+ if not m:
1041
+ return cid.upper(), None
1042
+ base = m.group(1).upper()
1043
+ sub = m.group(2) or m.group(3) or m.group(4)
1044
+ return base, sub
1045
+
1046
+ def _create_control_assessment(
1047
+ self,
1048
+ implementation: ControlImplementation,
1049
+ catalog_control: Dict,
1050
+ result: str,
1051
+ control_id: str,
1052
+ compliance_items: List[ComplianceItem] = None,
1053
+ ) -> None:
1054
+ """
1055
+ Create or update an assessment for a control implementation.
1056
+ If an assessment for the same day exists, update it instead of creating a duplicate.
1057
+
1058
+ :param ControlImplementation implementation: The control implementation to assess
1059
+ :param Dict catalog_control: The catalog control data dictionary
1060
+ :param str result: Assessment result ('Pass' or 'Fail')
1061
+ :param str control_id: Control identifier string
1062
+ :param List[ComplianceItem] compliance_items: Pre-aggregated compliance items for this control
1063
+ :return: None
1064
+ :rtype: None
1065
+ """
1066
+ try:
1067
+ # Use provided compliance items or get them for this control (backward compatibility)
1068
+ if compliance_items is None:
1069
+ compliance_items = []
1070
+ if (
1071
+ control_id in self.failing_controls
1072
+ or control_id.lower() in self.failing_controls
1073
+ or control_id.upper() in self.failing_controls
1074
+ ):
1075
+ compliance_items = [
1076
+ item for item in self.failed_compliance_items if item.control_id.lower() == control_id.lower()
1077
+ ]
1078
+ else:
1079
+ compliance_items = [
1080
+ item for item in self.all_compliance_items if item.control_id.lower() == control_id.lower()
1081
+ ]
1082
+
1083
+ # Create assessment report
1084
+ assessment_report = self._create_assessment_report(control_id, result, compliance_items)
1085
+
1086
+ # Check for existing assessment on the same day using cache
1087
+ existing_assessment = self._find_existing_assessment_cached(implementation.id, self.scan_date)
1088
+
1089
+ if existing_assessment:
1090
+ # Update existing assessment
1091
+ existing_assessment.assessmentResult = result
1092
+ existing_assessment.assessmentReport = assessment_report
1093
+ existing_assessment.actualFinish = get_current_datetime()
1094
+ existing_assessment.dateLastUpdated = get_current_datetime()
1095
+ existing_assessment.save()
1096
+ logger.debug(f"Updated existing assessment {existing_assessment.id} for control {control_id}")
1097
+ # Refresh cache for today
1098
+ try:
1099
+ day_key = regscale_string_to_datetime(self.scan_date).date().isoformat()
1100
+ except Exception:
1101
+ day_key = str(self.scan_date).split(" ")[0]
1102
+ self._existing_assessments_cache[f"{implementation.id}_{day_key}"] = existing_assessment
1103
+ # Track today's assessment by implementation id for linking to issues later
1104
+ try:
1105
+ self._assessment_by_impl_today[implementation.id] = existing_assessment
1106
+ except Exception:
1107
+ pass
1108
+ else:
1109
+ # Create new assessment
1110
+ assessment = Assessment(
1111
+ leadAssessorId=implementation.createdById,
1112
+ title=f"{self.title} compliance assessment for {control_id.upper()}",
1113
+ assessmentType="Control Testing",
1114
+ plannedStart=get_current_datetime(),
1115
+ plannedFinish=get_current_datetime(),
1116
+ actualFinish=get_current_datetime(),
1117
+ assessmentResult=result,
1118
+ assessmentReport=assessment_report,
1119
+ status="Complete",
1120
+ parentId=implementation.id,
1121
+ parentModule="controls",
1122
+ isPublic=True,
1123
+ ).create()
1124
+ logger.debug(f"Created new assessment {assessment.id} for control {control_id}")
1125
+ # Add to cache for today to prevent duplicate creation in subsequent processing
1126
+ try:
1127
+ day_key = regscale_string_to_datetime(self.scan_date).date().isoformat()
1128
+ except Exception:
1129
+ day_key = str(self.scan_date).split(" ")[0]
1130
+ self._existing_assessments_cache[f"{implementation.id}_{day_key}"] = assessment
1131
+ # Track today's assessment by implementation id for linking to issues later
1132
+ try:
1133
+ self._assessment_by_impl_today[implementation.id] = assessment
1134
+ except Exception:
1135
+ pass
1136
+
1137
+ # Update implementation status if needed
1138
+ if self.update_control_status:
1139
+ self._update_implementation_status(implementation, result)
1140
+
1141
+ except Exception as e:
1142
+ logger.error(f"Error creating control assessment: {e}")
1143
+
1144
+ def _find_existing_assessment(self, implementation: ControlImplementation, scan_date) -> Optional:
1145
+ """
1146
+ Find existing assessment for the same implementation on the same day.
1147
+ DEPRECATED: Use _find_existing_assessment_cached instead.
1148
+
1149
+ :param ControlImplementation implementation: The control implementation
1150
+ :param scan_date: The scan date to check
1151
+ :return: Existing assessment or None
1152
+ :rtype: Optional[regscale_models.Assessment]
1153
+ """
1154
+ logger.warning("_find_existing_assessment is deprecated, use _find_existing_assessment_cached")
1155
+ return self._find_existing_assessment_cached(implementation.id, scan_date)
1156
+
1157
+ def _create_assessment_report(self, control_id: str, result: str, compliance_items: List[ComplianceItem]) -> str:
1158
+ """
1159
+ Create HTML assessment report.
1160
+
1161
+ :param str control_id: Control identifier
1162
+ :param str result: Assessment result ('Pass' or 'Fail')
1163
+ :param List[ComplianceItem] compliance_items: Compliance items for this control
1164
+ :return: Formatted HTML report string
1165
+ :rtype: str
1166
+ """
1167
+ result_color = "#d32f2f" if result == "Fail" else "#2e7d32"
1168
+
1169
+ html_parts = [
1170
+ f"""
1171
+ <div style="margin-bottom: 20px; padding: 15px; border: 2px solid {result_color};
1172
+ border-radius: 5px; background-color: {'#ffebee' if result == 'Fail' else '#e8f5e8'};">
1173
+ <h3 style="margin: 0 0 10px 0; color: {result_color};">
1174
+ {self.title} Compliance Assessment for Control {control_id.upper()}
1175
+ </h3>
1176
+ <p><strong>Overall Result:</strong>
1177
+ <span style="color: {result_color}; font-weight: bold;">{result}</span></p>
1178
+ <p><strong>Assessment Date:</strong> {self.scan_date}</p>
1179
+ <p><strong>Framework:</strong> {self.framework}</p>
1180
+ <p><strong>Total Policy Assessments:</strong> {len(compliance_items)}</p>
1181
+ </div>
1182
+ """
1183
+ ] # NOSONAR
1184
+
1185
+ if compliance_items:
1186
+ # Group by result for summary
1187
+ pass_count = len([item for item in compliance_items if item.compliance_result in self.PASS_STATUSES])
1188
+ fail_count = len(compliance_items) - pass_count
1189
+
1190
+ # Count unique resources across all policy assessments for this control
1191
+ unique_resources = set()
1192
+ unique_policies = set()
1193
+
1194
+ for item in compliance_items:
1195
+ unique_resources.add(item.resource_id)
1196
+ # Get policy name for aggregation
1197
+ if hasattr(item, "description"):
1198
+ unique_policies.add(
1199
+ item.description[:50] + "..." if len(item.description) > 50 else item.description
1200
+ )
1201
+ elif hasattr(item, "policy") and isinstance(item.policy, dict):
1202
+ policy_name = item.policy.get("name", "Unknown Policy")
1203
+ unique_policies.add(policy_name[:50] + "..." if len(policy_name) > 50 else policy_name)
1204
+
1205
+ html_parts.append(
1206
+ f"""
1207
+ <div style="margin-top: 20px;">
1208
+ <h4>Aggregated Assessment Summary</h4>
1209
+ <p><strong>Policy Assessments:</strong> {len(compliance_items)} total</p>
1210
+ <p><strong>Unique Policies Tested:</strong> {len(unique_policies)}</p>
1211
+ <p><strong>Unique Resources Assessed:</strong> {len(unique_resources)}</p>
1212
+ <p><strong>Passing Assessments:</strong> <span style="color: #2e7d32;">{pass_count}</span></p>
1213
+ <p><strong>Failing Assessments:</strong> <span style="color: #d32f2f;">{fail_count}</span></p>
1214
+ <p><strong>Overall Control Result:</strong> <span style="color: {result_color}; font-weight: bold;">{result}</span></p>
1215
+ </div>
1216
+ """
1217
+ )
1218
+
1219
+ return "\n".join(html_parts)
1220
+
1221
+ def _update_implementation_status(self, implementation: ControlImplementation, result: str) -> None:
1222
+ """
1223
+ Update control implementation status based on assessment result.
1224
+
1225
+ :param ControlImplementation implementation: Control implementation to update
1226
+ :param str result: Assessment result ('Pass' or 'Fail')
1227
+ :return: None
1228
+ :rtype: None
1229
+ """
1230
+ try:
1231
+ if result == "Pass":
1232
+ new_status = "Fully Implemented"
1233
+ else:
1234
+ new_status = "In Remediation"
1235
+
1236
+ # Update implementation status
1237
+ implementation.status = new_status
1238
+ implementation.dateLastAssessed = get_current_datetime()
1239
+ implementation.lastAssessmentResult = result
1240
+ implementation.save()
1241
+
1242
+ # Update objectives if they exist
1243
+ objectives = ImplementationObjective.get_all_by_parent(
1244
+ parent_module=implementation.get_module_slug(),
1245
+ parent_id=implementation.id,
1246
+ )
1247
+
1248
+ for objective in objectives:
1249
+ objective.status = new_status
1250
+ objective.save()
1251
+
1252
+ logger.debug(f"Updated implementation status to {new_status}")
1253
+
1254
+ except Exception as e:
1255
+ logger.error(f"Error updating implementation status: {e}")
1256
+
1257
+ def _get_controls(self) -> List[Dict]:
1258
+ """
1259
+ Get controls from catalog or plan.
1260
+
1261
+ :return: List of control dictionaries from catalog or plan
1262
+ :rtype: List[Dict]
1263
+ """
1264
+ if self.catalog_id:
1265
+ catalog = Catalog.get_with_all_details(catalog_id=self.catalog_id)
1266
+ return catalog.get("controls", []) if catalog else []
1267
+ else:
1268
+ return SecurityControl.get_controls_by_parent_id_and_module(
1269
+ parent_module=self.parent_module, parent_id=self.plan_id, return_dicts=True
1270
+ )
1271
+
1272
+ def _find_existing_asset_by_resource_id(self, resource_id: str) -> Optional[regscale_models.Asset]:
1273
+ """
1274
+ Find existing asset by resource ID.
1275
+
1276
+ :param str resource_id: Resource identifier to search for
1277
+ :return: Existing asset or None if not found
1278
+ :rtype: Optional[regscale_models.Asset]
1279
+ """
1280
+ try:
1281
+ if hasattr(self, "asset_map_by_identifier") and self.asset_map_by_identifier:
1282
+ return self.asset_map_by_identifier.get(resource_id)
1283
+
1284
+ # Query database
1285
+ existing_assets = regscale_models.Asset.get_all_by_parent(
1286
+ parent_id=self.plan_id,
1287
+ parent_module=self.parent_module,
1288
+ )
1289
+
1290
+ for asset in existing_assets:
1291
+ if hasattr(asset, "otherTrackingNumber") and asset.otherTrackingNumber == resource_id:
1292
+ return asset
1293
+
1294
+ return None
1295
+
1296
+ except Exception as e:
1297
+ logger.error(f"Error finding existing asset: {e}")
1298
+ return None
1299
+
1300
+ def _map_resource_type_to_asset_type(self, compliance_item: ComplianceItem) -> str:
1301
+ """
1302
+ Map compliance item resource type to RegScale asset type.
1303
+
1304
+ :param ComplianceItem compliance_item: Compliance item with resource type information
1305
+ :return: Asset type string suitable for RegScale
1306
+ :rtype: str
1307
+ """
1308
+ # Default implementation - can be overridden by subclasses
1309
+ return "Cloud Resource"
1310
+
1311
+ def _map_severity(self, severity: Optional[str]) -> regscale_models.IssueSeverity:
1312
+ """
1313
+ Map compliance severity to RegScale severity.
1314
+
1315
+ :param Optional[str] severity: Severity string from compliance source
1316
+ :return: Mapped RegScale severity enum value
1317
+ :rtype: regscale_models.IssueSeverity
1318
+ """
1319
+ if not severity:
1320
+ return regscale_models.IssueSeverity.Moderate
1321
+
1322
+ severity_mapping = {
1323
+ "CRITICAL": regscale_models.IssueSeverity.Critical,
1324
+ "HIGH": regscale_models.IssueSeverity.High,
1325
+ "MEDIUM": regscale_models.IssueSeverity.Moderate,
1326
+ "LOW": regscale_models.IssueSeverity.Low,
1327
+ }
1328
+
1329
+ return severity_mapping.get(severity.upper(), regscale_models.IssueSeverity.Moderate)
1330
+
1331
+ def _map_severity_to_priority(self, severity: regscale_models.IssueSeverity) -> str:
1332
+ """
1333
+ Map severity to priority string.
1334
+
1335
+ :param regscale_models.IssueSeverity severity: Issue severity enum value
1336
+ :return: Priority string for issues
1337
+ :rtype: str
1338
+ """
1339
+ priority_mapping = {
1340
+ regscale_models.IssueSeverity.Critical: "Critical",
1341
+ regscale_models.IssueSeverity.High: "High",
1342
+ regscale_models.IssueSeverity.Moderate: "Medium",
1343
+ regscale_models.IssueSeverity.Low: "Low",
1344
+ }
1345
+
1346
+ return priority_mapping.get(severity, "Medium")
1347
+
1348
+ def _update_scan_history(self, scan_history: regscale_models.ScanHistory) -> None:
1349
+ """
1350
+ Update scan history with results.
1351
+
1352
+ :param regscale_models.ScanHistory scan_history: Scan history record to update
1353
+ :return: None
1354
+ :rtype: None
1355
+ """
1356
+ try:
1357
+ scan_history.dateLastUpdated = get_current_datetime()
1358
+ scan_history.save()
1359
+ logger.debug(f"Updated scan history {scan_history.id}")
1360
+ except Exception as e:
1361
+ logger.error(f"Error updating scan history: {e}")
1362
+
1363
+ def create_scan_history(self) -> regscale_models.ScanHistory:
1364
+ """
1365
+ Create or reuse a ScanHistory for the same day and tool.
1366
+
1367
+ If a scan history exists for this plan/module with the same
1368
+ scanning tool and scan date (day-level), update and reuse it
1369
+ instead of creating a duplicate.
1370
+
1371
+ :return: Created or reused scan history record
1372
+ :rtype: regscale_models.ScanHistory
1373
+ """
1374
+ try:
1375
+ # Load existing scans for the plan/module
1376
+ existing_scans = regscale_models.ScanHistory.get_all_by_parent(
1377
+ parent_id=self.plan_id, parent_module=self.parent_module
1378
+ )
1379
+
1380
+ # Normalize target date to date component only
1381
+ target_dt = self.scan_date
1382
+ target_date_only = target_dt.split("T")[0] if isinstance(target_dt, str) else str(target_dt)[:10]
1383
+
1384
+ # Find an existing scan for today and this tool
1385
+ for scan in existing_scans:
1386
+ try:
1387
+ if getattr(scan, "scanningTool", None) == self.title and getattr(scan, "scanDate", None):
1388
+ scan_date = str(scan.scanDate)
1389
+ scan_date_only = scan_date.split("T")[0]
1390
+ if scan_date_only == target_date_only:
1391
+ # Reuse this scan history; refresh last updated
1392
+ scan.dateLastUpdated = get_current_datetime()
1393
+ scan.save()
1394
+ return scan
1395
+ except Exception:
1396
+ # Skip any malformed scan records
1397
+ continue
1398
+
1399
+ # No existing same-day scan found, create new via base behavior
1400
+ return super().create_scan_history()
1401
+ except Exception:
1402
+ # Fallback: create new scan history
1403
+ return super().create_scan_history()
1404
+
1405
+ def create_or_update_issue_from_finding(self, title: str, finding: IntegrationFinding) -> regscale_models.Issue:
1406
+ """
1407
+ Create or update an issue from a finding, using cache to prevent duplicates.
1408
+
1409
+ :param str title: Issue title
1410
+ :param IntegrationFinding finding: The finding to create issue from
1411
+ :return: Created or updated issue
1412
+ :rtype: regscale_models.Issue
1413
+ """
1414
+ # Load cache if not already loaded
1415
+ self._load_existing_records_cache()
1416
+
1417
+ # Check for existing issue by external_id first
1418
+ external_id = finding.external_id
1419
+ existing_issue = self._find_existing_issue_cached(external_id)
1420
+
1421
+ if existing_issue:
1422
+ logger.debug(
1423
+ f"Found existing issue {existing_issue.id} for external_id {external_id}, updating instead of creating"
1424
+ )
1425
+
1426
+ # Update existing issue with new finding data
1427
+ existing_issue.title = title
1428
+ existing_issue.description = finding.description
1429
+ existing_issue.severity = finding.severity
1430
+ existing_issue.status = finding.status
1431
+ # Ensure affectedControls is updated from the finding's control id
1432
+ try:
1433
+ if getattr(finding, "control_labels", None):
1434
+ existing_issue.affectedControls = ",".join(finding.control_labels)
1435
+ else:
1436
+ # Fall back to normalized control id from rule_id/control_labels
1437
+ ctl = None
1438
+ if getattr(finding, "rule_id", None):
1439
+ ctl = finding.rule_id
1440
+ elif getattr(finding, "control_labels", None):
1441
+ labels = list(finding.control_labels)
1442
+ ctl = labels[0] if labels else None
1443
+ if ctl:
1444
+ base, sub = self._normalize_control_id(ctl)
1445
+ existing_issue.affectedControls = f"{base}({sub})" if sub else base
1446
+ except Exception:
1447
+ pass
1448
+ existing_issue.dateLastUpdated = self.scan_date
1449
+ existing_issue.save()
1450
+
1451
+ return existing_issue
1452
+ else:
1453
+ # No existing issue found, create new one using parent method
1454
+ logger.debug(f"No existing issue found for external_id {external_id}, creating new issue")
1455
+ return super().create_or_update_issue_from_finding(title, finding)