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.
- regscale/_version.py +1 -1
- regscale/core/app/application.py +12 -5
- regscale/core/app/internal/set_permissions.py +58 -27
- regscale/integrations/commercial/__init__.py +1 -2
- regscale/integrations/commercial/amazon/common.py +79 -2
- regscale/integrations/commercial/aws/cli.py +183 -9
- regscale/integrations/commercial/aws/scanner.py +544 -9
- regscale/integrations/commercial/cpe.py +18 -1
- regscale/integrations/commercial/nessus/scanner.py +2 -0
- regscale/integrations/commercial/sonarcloud.py +35 -36
- regscale/integrations/commercial/synqly/ticketing.py +51 -0
- regscale/integrations/commercial/tenablev2/jsonl_scanner.py +2 -1
- regscale/integrations/commercial/wizv2/async_client.py +10 -3
- regscale/integrations/commercial/wizv2/click.py +102 -26
- regscale/integrations/commercial/wizv2/constants.py +249 -1
- regscale/integrations/commercial/wizv2/issue.py +2 -2
- regscale/integrations/commercial/wizv2/parsers.py +3 -2
- regscale/integrations/commercial/wizv2/policy_compliance.py +1858 -0
- regscale/integrations/commercial/wizv2/scanner.py +15 -21
- regscale/integrations/commercial/wizv2/utils.py +258 -85
- regscale/integrations/commercial/wizv2/variables.py +4 -3
- regscale/integrations/compliance_integration.py +1455 -0
- regscale/integrations/integration_override.py +15 -6
- regscale/integrations/public/fedramp/fedramp_five.py +1 -1
- regscale/integrations/public/fedramp/markdown_parser.py +7 -1
- regscale/integrations/scanner_integration.py +193 -37
- regscale/models/app_models/__init__.py +1 -0
- regscale/models/integration_models/amazon_models/inspector_scan.py +32 -57
- regscale/models/integration_models/aqua.py +92 -78
- regscale/models/integration_models/cisa_kev_data.json +117 -5
- regscale/models/integration_models/defenderimport.py +64 -59
- regscale/models/integration_models/ecr_models/ecr.py +100 -147
- regscale/models/integration_models/flat_file_importer/__init__.py +52 -38
- regscale/models/integration_models/ibm.py +29 -47
- regscale/models/integration_models/nexpose.py +156 -68
- regscale/models/integration_models/prisma.py +46 -66
- regscale/models/integration_models/qualys.py +99 -93
- regscale/models/integration_models/snyk.py +229 -158
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/integration_models/veracode.py +15 -20
- regscale/{integrations/commercial/wizv2/models.py → models/integration_models/wizv2.py} +4 -12
- regscale/models/integration_models/xray.py +276 -82
- regscale/models/regscale_models/control_implementation.py +14 -12
- regscale/models/regscale_models/file.py +4 -0
- regscale/models/regscale_models/issue.py +123 -0
- regscale/models/regscale_models/milestone.py +1 -1
- regscale/models/regscale_models/rbac.py +22 -0
- regscale/models/regscale_models/regscale_model.py +4 -2
- regscale/models/regscale_models/security_plan.py +1 -1
- regscale/utils/graphql_client.py +3 -1
- {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/METADATA +9 -9
- {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/RECORD +64 -60
- tests/fixtures/test_fixture.py +58 -2
- tests/regscale/core/test_app.py +5 -3
- tests/regscale/core/test_version_regscale.py +5 -3
- tests/regscale/integrations/test_integration_mapping.py +522 -40
- tests/regscale/integrations/test_issue_due_date.py +1 -1
- tests/regscale/integrations/test_update_finding_dates.py +336 -0
- tests/regscale/integrations/test_wiz_policy_compliance_affected_controls.py +154 -0
- tests/regscale/models/test_asset.py +406 -50
- {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.20.10.0.dist-info → regscale_cli-6.21.1.0.dist-info}/entry_points.txt +0 -0
- {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)
|