regscale-cli 6.25.0.1__py3-none-any.whl → 6.26.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of regscale-cli might be problematic. Click here for more details.
- regscale/_version.py +1 -1
- regscale/airflow/hierarchy.py +2 -2
- regscale/core/app/application.py +18 -3
- regscale/core/app/internal/login.py +0 -1
- regscale/core/app/utils/catalog_utils/common.py +1 -1
- regscale/integrations/commercial/sicura/api.py +14 -13
- regscale/integrations/commercial/sicura/commands.py +8 -2
- regscale/integrations/commercial/sicura/scanner.py +49 -39
- regscale/integrations/commercial/stigv2/ckl_parser.py +5 -5
- regscale/integrations/commercial/synqly/assets.py +17 -0
- regscale/integrations/commercial/wizv2/click.py +26 -26
- regscale/integrations/commercial/wizv2/compliance_report.py +152 -157
- regscale/integrations/commercial/wizv2/constants.py +20 -71
- regscale/integrations/commercial/wizv2/scanner.py +3 -3
- regscale/integrations/compliance_integration.py +67 -2
- regscale/integrations/control_matcher.py +358 -0
- regscale/integrations/due_date_handler.py +118 -6
- regscale/integrations/milestone_manager.py +291 -0
- regscale/integrations/public/__init__.py +1 -0
- regscale/integrations/public/cci_importer.py +37 -38
- regscale/integrations/public/fedramp/click.py +60 -2
- regscale/integrations/public/fedramp/poam_export_v5.py +888 -0
- regscale/integrations/scanner_integration.py +199 -130
- regscale/models/integration_models/cisa_kev_data.json +199 -4
- regscale/models/integration_models/nexpose.py +36 -10
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/locking.py +12 -8
- regscale/models/platform.py +1 -2
- regscale/models/regscale_models/control_implementation.py +46 -21
- regscale/models/regscale_models/issue.py +256 -94
- regscale/models/regscale_models/milestone.py +1 -1
- regscale/models/regscale_models/regscale_model.py +6 -1
- regscale/templates/__init__.py +0 -0
- regscale/utils/threading/threadhandler.py +20 -15
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/RECORD +84 -37
- tests/regscale/integrations/commercial/__init__.py +0 -0
- tests/regscale/integrations/commercial/conftest.py +28 -0
- tests/regscale/integrations/commercial/microsoft_defender/__init__.py +1 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender.py +1517 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_api.py +1748 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_constants.py +327 -0
- tests/regscale/integrations/commercial/microsoft_defender/test_defender_scanner.py +487 -0
- tests/regscale/integrations/commercial/test_aws.py +3731 -0
- tests/regscale/integrations/commercial/test_burp.py +48 -0
- tests/regscale/integrations/commercial/test_crowdstrike.py +49 -0
- tests/regscale/integrations/commercial/test_dependabot.py +341 -0
- tests/regscale/integrations/commercial/test_gcp.py +1543 -0
- tests/regscale/integrations/commercial/test_gitlab.py +549 -0
- tests/regscale/integrations/commercial/test_ip_mac_address_length.py +84 -0
- tests/regscale/integrations/commercial/test_jira.py +1814 -0
- tests/regscale/integrations/commercial/test_npm_audit.py +42 -0
- tests/regscale/integrations/commercial/test_okta.py +1228 -0
- tests/regscale/integrations/commercial/test_sarif_converter.py +251 -0
- tests/regscale/integrations/commercial/test_sicura.py +350 -0
- tests/regscale/integrations/commercial/test_snow.py +423 -0
- tests/regscale/integrations/commercial/test_sonarcloud.py +394 -0
- tests/regscale/integrations/commercial/test_sqlserver.py +186 -0
- tests/regscale/integrations/commercial/test_stig.py +33 -0
- tests/regscale/integrations/commercial/test_stig_mapper.py +153 -0
- tests/regscale/integrations/commercial/test_stigv2.py +406 -0
- tests/regscale/integrations/commercial/test_wiz.py +1469 -0
- tests/regscale/integrations/commercial/test_wiz_inventory.py +256 -0
- tests/regscale/integrations/commercial/wizv2/__init__.py +339 -0
- tests/regscale/integrations/commercial/wizv2/test_compliance_report_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_issue.py +343 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_click_client_id.py +165 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_report.py +1351 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_compliance_unit.py +341 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_control_normalization.py +138 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_policy_compliance.py +750 -0
- tests/regscale/integrations/commercial/wizv2/test_wiz_status_mapping.py +149 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2.py +264 -0
- tests/regscale/integrations/commercial/wizv2/test_wizv2_utils.py +624 -0
- tests/regscale/integrations/public/fedramp/__init__.py +1 -0
- tests/regscale/integrations/public/fedramp/test_poam_export_v5.py +1293 -0
- tests/regscale/integrations/test_control_matcher.py +1314 -0
- tests/regscale/integrations/test_control_matching.py +155 -0
- tests/regscale/integrations/test_milestone_manager.py +408 -0
- tests/regscale/models/test_issue.py +378 -1
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/entry_points.txt +0 -0
- {regscale_cli-6.25.0.1.dist-info → regscale_cli-6.26.0.0.dist-info}/top_level.txt +0 -0
|
@@ -13,6 +13,7 @@ from collections import defaultdict
|
|
|
13
13
|
from typing import Dict, List, Optional, Any, Iterator
|
|
14
14
|
|
|
15
15
|
from regscale.core.app.utils.app_utils import get_current_datetime, regscale_string_to_datetime
|
|
16
|
+
from regscale.integrations.control_matcher import ControlMatcher
|
|
16
17
|
from regscale.integrations.scanner_integration import (
|
|
17
18
|
ScannerIntegration,
|
|
18
19
|
IntegrationAsset,
|
|
@@ -166,6 +167,9 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
166
167
|
self._compliance_settings = None
|
|
167
168
|
self._security_plan = None
|
|
168
169
|
|
|
170
|
+
# Initialize control matcher for robust control ID matching
|
|
171
|
+
self._control_matcher = ControlMatcher()
|
|
172
|
+
|
|
169
173
|
def is_poam(self, finding: IntegrationFinding) -> bool: # type: ignore[override]
|
|
170
174
|
"""
|
|
171
175
|
Determines if an issue should be considered a POAM for compliance integrations.
|
|
@@ -1245,13 +1249,22 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
1245
1249
|
"""
|
|
1246
1250
|
Find matching implementation and security control for a control ID.
|
|
1247
1251
|
|
|
1252
|
+
Uses ControlMatcher for robust control ID matching with leading zero normalization.
|
|
1253
|
+
|
|
1248
1254
|
:param str control_id: Control identifier to match
|
|
1249
1255
|
:param List[ControlImplementation] implementations: Available implementations
|
|
1250
1256
|
:return: Tuple of matching implementation and security control, or (None, None)
|
|
1251
1257
|
:rtype: tuple[Optional[ControlImplementation], Optional[SecurityControl]]
|
|
1252
1258
|
"""
|
|
1259
|
+
# Generate all variations of the search control ID for matching
|
|
1260
|
+
search_variations = self._control_matcher._get_control_id_variations(control_id)
|
|
1261
|
+
if not search_variations:
|
|
1262
|
+
logger.debug(f"Could not generate control ID variations for: {control_id}")
|
|
1263
|
+
return None, None
|
|
1264
|
+
|
|
1253
1265
|
matching_implementation = None
|
|
1254
1266
|
matching_security_control = None
|
|
1267
|
+
|
|
1255
1268
|
for implementation in implementations:
|
|
1256
1269
|
try:
|
|
1257
1270
|
security_control = SecurityControl.get_object(object_id=implementation.controlID)
|
|
@@ -1264,10 +1277,16 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
1264
1277
|
if not security_control_id:
|
|
1265
1278
|
logger.debug(f"Security control {security_control.id} has no controlId")
|
|
1266
1279
|
continue
|
|
1280
|
+
|
|
1281
|
+
# Get variations of the security control ID
|
|
1282
|
+
control_variations = self._control_matcher._get_control_id_variations(security_control_id)
|
|
1283
|
+
|
|
1267
1284
|
logger.debug(
|
|
1268
|
-
f"Comparing
|
|
1285
|
+
f"Comparing control '{control_id}' variations {list(search_variations)[:3]} with RegScale control '{security_control_id}' variations {list(control_variations)[:3]} (impl: {implementation.id})"
|
|
1269
1286
|
)
|
|
1270
|
-
|
|
1287
|
+
|
|
1288
|
+
# Check if any variation matches (set intersection)
|
|
1289
|
+
if search_variations & control_variations:
|
|
1271
1290
|
matching_implementation = implementation
|
|
1272
1291
|
matching_security_control = security_control
|
|
1273
1292
|
logger.info(
|
|
@@ -1364,12 +1383,19 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
1364
1383
|
component_title = f"Control {sec_control.controlId}"
|
|
1365
1384
|
component = self.components_by_title.get(component_title) if hasattr(self, "components_by_title") else None
|
|
1366
1385
|
if not component:
|
|
1386
|
+
# Get complianceSettingsId from the security plan
|
|
1387
|
+
security_plan = self._get_security_plan()
|
|
1388
|
+
compliance_settings_id = getattr(security_plan, "complianceSettingsId", None) if security_plan else None
|
|
1389
|
+
if not compliance_settings_id:
|
|
1390
|
+
compliance_setting = self._get_compliance_settings()
|
|
1391
|
+
compliance_settings_id = getattr(compliance_setting, "id", None) if compliance_setting else None
|
|
1367
1392
|
component = regscale_models.Component(
|
|
1368
1393
|
title=component_title,
|
|
1369
1394
|
componentType=regscale_models.ComponentType.Hardware,
|
|
1370
1395
|
securityPlansId=self.plan_id,
|
|
1371
1396
|
description=component_title,
|
|
1372
1397
|
componentOwnerId=self.get_assessor_id(),
|
|
1398
|
+
complianceSettingsId=compliance_settings_id,
|
|
1373
1399
|
).get_or_create()
|
|
1374
1400
|
regscale_models.ComponentMapping(
|
|
1375
1401
|
componentId=component.id,
|
|
@@ -2050,6 +2076,8 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
2050
2076
|
"""
|
|
2051
2077
|
Create or update an issue from a finding, using cache to prevent duplicates.
|
|
2052
2078
|
|
|
2079
|
+
Properly handles milestone creation for compliance integrations.
|
|
2080
|
+
|
|
2053
2081
|
:param str title: Issue title
|
|
2054
2082
|
:param IntegrationFinding finding: The finding to create issue from
|
|
2055
2083
|
:return: Created or updated issue
|
|
@@ -2068,6 +2096,9 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
2068
2096
|
f"Found existing issue {existing_issue.id} (other_identifier: '{existing_issue.otherIdentifier}') for lookup external_id '{external_id}', updating instead of creating"
|
|
2069
2097
|
)
|
|
2070
2098
|
|
|
2099
|
+
# Store original status for milestone comparison
|
|
2100
|
+
original_status = existing_issue.status
|
|
2101
|
+
|
|
2071
2102
|
# Update existing issue with new finding data
|
|
2072
2103
|
existing_issue.title = title
|
|
2073
2104
|
existing_issue.description = finding.description
|
|
@@ -2095,8 +2126,42 @@ class ComplianceIntegration(ScannerIntegration, ABC):
|
|
|
2095
2126
|
existing_issue.orgId = self.determine_issue_organization_id(existing_issue.issueOwnerId)
|
|
2096
2127
|
existing_issue.save()
|
|
2097
2128
|
|
|
2129
|
+
# Create milestone if status changed
|
|
2130
|
+
# Reconstruct original issue state for comparison
|
|
2131
|
+
original_issue = regscale_models.Issue()
|
|
2132
|
+
original_issue.status = original_status
|
|
2133
|
+
self._create_milestones_for_updated_issue(existing_issue, finding, original_issue)
|
|
2134
|
+
|
|
2098
2135
|
return existing_issue
|
|
2099
2136
|
else:
|
|
2100
2137
|
# No existing issue found, create new one using parent method
|
|
2101
2138
|
logger.debug(f"No existing issue found for external_id {external_id}, creating new issue")
|
|
2102
2139
|
return super().create_or_update_issue_from_finding(title, finding)
|
|
2140
|
+
|
|
2141
|
+
def _create_milestones_for_updated_issue(
|
|
2142
|
+
self,
|
|
2143
|
+
issue: regscale_models.Issue,
|
|
2144
|
+
finding: IntegrationFinding,
|
|
2145
|
+
original_issue: regscale_models.Issue,
|
|
2146
|
+
) -> None:
|
|
2147
|
+
"""
|
|
2148
|
+
Create milestones for an updated issue in compliance integration.
|
|
2149
|
+
|
|
2150
|
+
This method handles both status transition milestones and backfilling of missing
|
|
2151
|
+
creation milestones for existing issues.
|
|
2152
|
+
|
|
2153
|
+
:param regscale_models.Issue issue: The updated issue
|
|
2154
|
+
:param IntegrationFinding finding: The finding data
|
|
2155
|
+
:param regscale_models.Issue original_issue: Original state for comparison
|
|
2156
|
+
"""
|
|
2157
|
+
milestone_manager = self.get_milestone_manager()
|
|
2158
|
+
|
|
2159
|
+
# First, ensure the issue has a creation milestone (backfill if missing)
|
|
2160
|
+
milestone_manager.ensure_creation_milestone_exists(issue=issue, finding=finding)
|
|
2161
|
+
|
|
2162
|
+
# Then, handle status transition milestones
|
|
2163
|
+
milestone_manager.create_milestones_for_issue(
|
|
2164
|
+
issue=issue,
|
|
2165
|
+
finding=finding,
|
|
2166
|
+
existing_issue=original_issue,
|
|
2167
|
+
)
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Control Matcher - A utility class for identifying and matching control implementations
|
|
5
|
+
across different RegScale entities based on control ID strings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from regscale.core.app.api import Api
|
|
13
|
+
from regscale.core.app.application import Application
|
|
14
|
+
from regscale.models.regscale_models.control_implementation import ControlImplementation
|
|
15
|
+
from regscale.models.regscale_models.security_control import SecurityControl
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("regscale")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ControlMatcher:
|
|
21
|
+
"""
|
|
22
|
+
A class to identify control IDs and match them across different RegScale entities.
|
|
23
|
+
|
|
24
|
+
This class provides control matching capabilities:
|
|
25
|
+
- Parse control ID strings to extract NIST control identifiers
|
|
26
|
+
- Match controls from catalogs to security plans
|
|
27
|
+
- Find control implementations based on control IDs
|
|
28
|
+
- Support multiple control ID formats (e.g., AC-1, AC-1(1), AC-1.1)
|
|
29
|
+
|
|
30
|
+
Note: This class is focused on finding and matching existing controls only.
|
|
31
|
+
Control creation/modification should be handled by calling code.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, app: Optional[Application] = None):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the ControlMatcher.
|
|
37
|
+
|
|
38
|
+
:param Optional[Application] app: RegScale Application instance
|
|
39
|
+
"""
|
|
40
|
+
self.app = app or Application()
|
|
41
|
+
self.api = Api()
|
|
42
|
+
self._catalog_cache: Dict[int, List[SecurityControl]] = {}
|
|
43
|
+
self._control_impl_cache: Dict[Tuple[int, str], Dict[str, ControlImplementation]] = {}
|
|
44
|
+
|
|
45
|
+
def parse_control_id(self, control_string: str) -> Optional[str]:
|
|
46
|
+
"""
|
|
47
|
+
Parse a control ID string and extract the standardized control identifier.
|
|
48
|
+
|
|
49
|
+
Handles various formats:
|
|
50
|
+
- NIST format: AC-1, AC-1(1), AC-1.1
|
|
51
|
+
- With leading zeros: AC-01, AC-17(02)
|
|
52
|
+
- With text: "Access Control AC-1"
|
|
53
|
+
- Multiple controls: "AC-1, AC-2"
|
|
54
|
+
|
|
55
|
+
:param str control_string: Raw control ID string
|
|
56
|
+
:return: Standardized control ID or None if not found
|
|
57
|
+
:rtype: Optional[str]
|
|
58
|
+
"""
|
|
59
|
+
if not control_string:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# Clean the string
|
|
63
|
+
control_string = control_string.strip().upper()
|
|
64
|
+
|
|
65
|
+
# Common NIST control ID pattern
|
|
66
|
+
# Matches: AC-1, AC-01, AC-1(1), AC-1(01), AC-1.1, AC-1.01, etc.
|
|
67
|
+
pattern = r"([A-Z]{2,3}-\d+(?:\(\d+\)|\.\d+)?)"
|
|
68
|
+
|
|
69
|
+
matches = re.findall(pattern, control_string)
|
|
70
|
+
if matches:
|
|
71
|
+
# Normalize parentheses to dots for consistency
|
|
72
|
+
control_id = matches[0]
|
|
73
|
+
control_id = control_id.replace("(", ".").replace(")", "")
|
|
74
|
+
return control_id
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def find_control_in_catalog(self, control_id: str, catalog_id: int) -> Optional[SecurityControl]:
|
|
79
|
+
"""
|
|
80
|
+
Find a security control in a specific catalog by control ID.
|
|
81
|
+
|
|
82
|
+
:param str control_id: The control ID to search for
|
|
83
|
+
:param int catalog_id: The catalog ID to search in
|
|
84
|
+
:return: SecurityControl object if found, None otherwise
|
|
85
|
+
:rtype: Optional[SecurityControl]
|
|
86
|
+
"""
|
|
87
|
+
controls = self._get_catalog_controls(catalog_id)
|
|
88
|
+
|
|
89
|
+
# Generate all possible variations of the control ID
|
|
90
|
+
search_ids = self._get_control_id_variations(control_id)
|
|
91
|
+
|
|
92
|
+
# Try exact match with any variation
|
|
93
|
+
for control in controls:
|
|
94
|
+
if control.controlId in search_ids:
|
|
95
|
+
return control
|
|
96
|
+
|
|
97
|
+
# Try matching control variations against search variations
|
|
98
|
+
for control in controls:
|
|
99
|
+
control_variations = self._get_control_id_variations(control.controlId)
|
|
100
|
+
if control_variations & search_ids: # Set intersection
|
|
101
|
+
return control
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def find_control_implementation(
|
|
106
|
+
self, control_id: str, parent_id: int, parent_module: str = "securityplans", catalog_id: Optional[int] = None
|
|
107
|
+
) -> Optional[ControlImplementation]:
|
|
108
|
+
"""
|
|
109
|
+
Find a control implementation based on control ID and parent context.
|
|
110
|
+
|
|
111
|
+
:param str control_id: The control ID to match
|
|
112
|
+
:param int parent_id: Parent entity ID (e.g., security plan ID)
|
|
113
|
+
:param str parent_module: Parent module type (default: securityplans)
|
|
114
|
+
:param Optional[int] catalog_id: Optional catalog ID for better matching
|
|
115
|
+
:return: ControlImplementation if found, None otherwise
|
|
116
|
+
:rtype: Optional[ControlImplementation]
|
|
117
|
+
"""
|
|
118
|
+
# Get control implementations for the parent
|
|
119
|
+
implementations = self._get_control_implementations(parent_id, parent_module)
|
|
120
|
+
|
|
121
|
+
# Get all variations of the control ID for matching
|
|
122
|
+
search_variations = self._get_control_id_variations(control_id)
|
|
123
|
+
if not search_variations:
|
|
124
|
+
logger.warning(f"Could not parse control ID: {control_id}")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Try to find matching implementation with variation matching
|
|
128
|
+
for impl_key, impl in implementations.items():
|
|
129
|
+
impl_variations = self._get_control_id_variations(impl_key)
|
|
130
|
+
if impl_variations & search_variations: # Set intersection
|
|
131
|
+
return impl
|
|
132
|
+
|
|
133
|
+
# If catalog ID provided, try to find via security control
|
|
134
|
+
if catalog_id:
|
|
135
|
+
control = self.find_control_in_catalog(control_id, catalog_id)
|
|
136
|
+
if control:
|
|
137
|
+
# Search implementations by control ID
|
|
138
|
+
for impl in implementations.values():
|
|
139
|
+
if impl.controlID == control.id:
|
|
140
|
+
return impl
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def match_controls_to_implementations(
|
|
145
|
+
self,
|
|
146
|
+
control_ids: List[str],
|
|
147
|
+
parent_id: int,
|
|
148
|
+
parent_module: str = "securityplans",
|
|
149
|
+
catalog_id: Optional[int] = None,
|
|
150
|
+
) -> Dict[str, Optional[ControlImplementation]]:
|
|
151
|
+
"""
|
|
152
|
+
Match multiple control IDs to their implementations.
|
|
153
|
+
|
|
154
|
+
:param List[str] control_ids: List of control ID strings
|
|
155
|
+
:param int parent_id: Parent entity ID
|
|
156
|
+
:param str parent_module: Parent module type
|
|
157
|
+
:param Optional[int] catalog_id: Optional catalog ID
|
|
158
|
+
:return: Dictionary mapping control IDs to implementations
|
|
159
|
+
:rtype: Dict[str, Optional[ControlImplementation]]
|
|
160
|
+
"""
|
|
161
|
+
results = {}
|
|
162
|
+
|
|
163
|
+
for control_id in control_ids:
|
|
164
|
+
impl = self.find_control_implementation(control_id, parent_id, parent_module, catalog_id)
|
|
165
|
+
results[control_id] = impl
|
|
166
|
+
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
def get_security_plan_controls(self, security_plan_id: int) -> Dict[str, ControlImplementation]:
|
|
170
|
+
"""
|
|
171
|
+
Get all control implementations for a security plan.
|
|
172
|
+
|
|
173
|
+
:param int security_plan_id: The security plan ID
|
|
174
|
+
:return: Dictionary of control implementations keyed by control ID
|
|
175
|
+
:rtype: Dict[str, ControlImplementation]
|
|
176
|
+
"""
|
|
177
|
+
return self._get_control_implementations(security_plan_id, "securityplans")
|
|
178
|
+
|
|
179
|
+
def find_controls_by_pattern(self, pattern: str, catalog_id: int) -> List[SecurityControl]:
|
|
180
|
+
"""
|
|
181
|
+
Find all controls in a catalog matching a pattern.
|
|
182
|
+
|
|
183
|
+
:param str pattern: Regex pattern or substring to match
|
|
184
|
+
:param int catalog_id: Catalog ID to search in
|
|
185
|
+
:return: List of matching SecurityControl objects
|
|
186
|
+
:rtype: List[SecurityControl]
|
|
187
|
+
"""
|
|
188
|
+
controls = self._get_catalog_controls(catalog_id)
|
|
189
|
+
matched = []
|
|
190
|
+
|
|
191
|
+
for control in controls:
|
|
192
|
+
if (re.search(pattern, control.controlId, re.IGNORECASE)) or (
|
|
193
|
+
control.title and re.search(pattern, control.title, re.IGNORECASE)
|
|
194
|
+
):
|
|
195
|
+
matched.append(control)
|
|
196
|
+
|
|
197
|
+
return matched
|
|
198
|
+
|
|
199
|
+
def bulk_match_controls(
|
|
200
|
+
self,
|
|
201
|
+
control_mappings: Dict[str, str],
|
|
202
|
+
parent_id: int,
|
|
203
|
+
parent_module: str = "securityplans",
|
|
204
|
+
catalog_id: Optional[int] = None,
|
|
205
|
+
) -> Dict[str, Optional[ControlImplementation]]:
|
|
206
|
+
"""
|
|
207
|
+
Bulk match control IDs to their implementations.
|
|
208
|
+
|
|
209
|
+
:param Dict[str, str] control_mappings: Dict of {external_id: control_id}
|
|
210
|
+
:param int parent_id: Parent entity ID
|
|
211
|
+
:param str parent_module: Parent module type
|
|
212
|
+
:param Optional[int] catalog_id: Catalog ID for controls
|
|
213
|
+
:return: Dictionary mapping external IDs to ControlImplementations (None if not found)
|
|
214
|
+
:rtype: Dict[str, Optional[ControlImplementation]]
|
|
215
|
+
"""
|
|
216
|
+
results = {}
|
|
217
|
+
|
|
218
|
+
for external_id, control_id in control_mappings.items():
|
|
219
|
+
impl = self.find_control_implementation(control_id, parent_id, parent_module, catalog_id)
|
|
220
|
+
results[external_id] = impl
|
|
221
|
+
|
|
222
|
+
return results
|
|
223
|
+
|
|
224
|
+
def _get_catalog_controls(self, catalog_id: int) -> List[SecurityControl]:
|
|
225
|
+
"""
|
|
226
|
+
Get all controls for a catalog (with caching).
|
|
227
|
+
|
|
228
|
+
:param int catalog_id: Catalog ID
|
|
229
|
+
:return: List of SecurityControl objects
|
|
230
|
+
:rtype: List[SecurityControl]
|
|
231
|
+
"""
|
|
232
|
+
if catalog_id not in self._catalog_cache:
|
|
233
|
+
try:
|
|
234
|
+
controls = SecurityControl.get_list_by_catalog(catalog_id)
|
|
235
|
+
self._catalog_cache[catalog_id] = controls
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Failed to get controls for catalog {catalog_id}: {e}")
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
return self._catalog_cache.get(catalog_id, [])
|
|
241
|
+
|
|
242
|
+
def _normalize_control_id(self, control_id: str) -> Optional[str]:
|
|
243
|
+
"""
|
|
244
|
+
Normalize a control ID by removing leading zeros from all numeric parts.
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
- AC-01 -> AC-1
|
|
248
|
+
- AC-17(02) -> AC-17.2
|
|
249
|
+
- AC-1.01 -> AC-1.1
|
|
250
|
+
|
|
251
|
+
:param str control_id: The control ID to normalize
|
|
252
|
+
:return: Normalized control ID or None if invalid
|
|
253
|
+
:rtype: Optional[str]
|
|
254
|
+
"""
|
|
255
|
+
parsed = self.parse_control_id(control_id)
|
|
256
|
+
if not parsed:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
# Split by '-' to get family and number parts
|
|
260
|
+
parts = parsed.split("-")
|
|
261
|
+
if len(parts) != 2:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
family = parts[0]
|
|
265
|
+
number_part = parts[1]
|
|
266
|
+
|
|
267
|
+
# Handle enhancement notation (both . and parentheses are normalized to .)
|
|
268
|
+
if "." in number_part:
|
|
269
|
+
main_num, enhancement = number_part.split(".", 1)
|
|
270
|
+
# Remove leading zeros from both parts
|
|
271
|
+
main_num = str(int(main_num))
|
|
272
|
+
enhancement = str(int(enhancement))
|
|
273
|
+
return f"{family}-{main_num}.{enhancement}"
|
|
274
|
+
else:
|
|
275
|
+
# Just main control number
|
|
276
|
+
main_num = str(int(number_part))
|
|
277
|
+
return f"{family}-{main_num}"
|
|
278
|
+
|
|
279
|
+
def _get_control_id_variations(self, control_id: str) -> set:
|
|
280
|
+
"""
|
|
281
|
+
Generate all valid variations of a control ID (with and without leading zeros).
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
- AC-1 -> {AC-1, AC-01}
|
|
285
|
+
- AC-17.2 -> {AC-17.2, AC-17.02, AC-17(2), AC-17(02)}
|
|
286
|
+
|
|
287
|
+
:param str control_id: The control ID to generate variations for
|
|
288
|
+
:return: Set of all valid variations
|
|
289
|
+
:rtype: set
|
|
290
|
+
"""
|
|
291
|
+
parsed = self.parse_control_id(control_id)
|
|
292
|
+
if not parsed:
|
|
293
|
+
return set()
|
|
294
|
+
|
|
295
|
+
variations = set()
|
|
296
|
+
|
|
297
|
+
# Split by '-' to get family and number parts
|
|
298
|
+
parts = parsed.split("-")
|
|
299
|
+
if len(parts) != 2:
|
|
300
|
+
return set()
|
|
301
|
+
|
|
302
|
+
family = parts[0]
|
|
303
|
+
number_part = parts[1]
|
|
304
|
+
|
|
305
|
+
# Handle enhancement notation
|
|
306
|
+
if "." in number_part:
|
|
307
|
+
main_num, enhancement = number_part.split(".", 1)
|
|
308
|
+
main_int = int(main_num)
|
|
309
|
+
enh_int = int(enhancement)
|
|
310
|
+
|
|
311
|
+
# Generate all combinations: with/without leading zeros, dot/parenthesis notation
|
|
312
|
+
for main_fmt in [str(main_int), f"{main_int:02d}"]:
|
|
313
|
+
for enh_fmt in [str(enh_int), f"{enh_int:02d}"]:
|
|
314
|
+
variations.add(f"{family}-{main_fmt}.{enh_fmt}")
|
|
315
|
+
variations.add(f"{family}-{main_fmt}({enh_fmt})")
|
|
316
|
+
else:
|
|
317
|
+
# Just main control number
|
|
318
|
+
main_int = int(number_part)
|
|
319
|
+
variations.add(f"{family}-{main_int}")
|
|
320
|
+
variations.add(f"{family}-{main_int:02d}")
|
|
321
|
+
|
|
322
|
+
# Add uppercase versions to ensure consistency
|
|
323
|
+
return {v.upper() for v in variations}
|
|
324
|
+
|
|
325
|
+
def _get_control_implementations(self, parent_id: int, parent_module: str) -> Dict[str, ControlImplementation]:
|
|
326
|
+
"""
|
|
327
|
+
Get control implementations for a parent (with caching).
|
|
328
|
+
|
|
329
|
+
:param int parent_id: Parent ID
|
|
330
|
+
:param str parent_module: Parent module
|
|
331
|
+
:return: Dict of implementations keyed by control ID
|
|
332
|
+
:rtype: Dict[str, ControlImplementation]
|
|
333
|
+
"""
|
|
334
|
+
cache_key = (parent_id, parent_module)
|
|
335
|
+
|
|
336
|
+
if cache_key not in self._control_impl_cache:
|
|
337
|
+
try:
|
|
338
|
+
# Get the label map which maps control IDs to implementation IDs
|
|
339
|
+
label_map = ControlImplementation.get_control_label_map_by_parent(parent_id, parent_module)
|
|
340
|
+
|
|
341
|
+
implementations = {}
|
|
342
|
+
for control_label, impl_id in label_map.items():
|
|
343
|
+
impl = ControlImplementation.get_object(impl_id)
|
|
344
|
+
if impl:
|
|
345
|
+
implementations[control_label] = impl
|
|
346
|
+
|
|
347
|
+
self._control_impl_cache[cache_key] = implementations
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Failed to get implementations for {parent_module}/{parent_id}: {e}")
|
|
350
|
+
return {}
|
|
351
|
+
|
|
352
|
+
return self._control_impl_cache.get(cache_key, {})
|
|
353
|
+
|
|
354
|
+
def clear_cache(self):
|
|
355
|
+
"""Clear all cached data."""
|
|
356
|
+
self._catalog_cache.clear()
|
|
357
|
+
self._control_impl_cache.clear()
|
|
358
|
+
logger.info("Cleared control matcher cache")
|
|
@@ -7,13 +7,16 @@ from datetime import datetime
|
|
|
7
7
|
from typing import Any, Dict, Optional
|
|
8
8
|
|
|
9
9
|
from regscale.core.app.application import Application
|
|
10
|
-
from regscale.core.utils.date import
|
|
10
|
+
from regscale.core.utils.date import get_day_increment
|
|
11
11
|
from regscale.integrations.public.cisa import pull_cisa_kev
|
|
12
12
|
from regscale.models import regscale_models
|
|
13
13
|
from regscale.utils.threading import ThreadSafeDict
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger("regscale")
|
|
16
16
|
|
|
17
|
+
# Date format constant for consistent datetime string formatting
|
|
18
|
+
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
class DueDateHandler:
|
|
19
22
|
"""
|
|
@@ -21,6 +24,19 @@ class DueDateHandler:
|
|
|
21
24
|
1. Init.yaml timeline configurations per integration
|
|
22
25
|
2. KEV (Known Exploited Vulnerabilities) dates from CISA
|
|
23
26
|
3. Default severity-based timelines
|
|
27
|
+
4. Configurable past due date validation (noPastDueDates setting)
|
|
28
|
+
|
|
29
|
+
Configuration Options:
|
|
30
|
+
- Global setting: issues.noPastDueDates (default: true)
|
|
31
|
+
- Per-integration: issues.{integration_name}.noPastDueDates
|
|
32
|
+
|
|
33
|
+
When noPastDueDates=true (default):
|
|
34
|
+
- Due dates calculated in the past are automatically adjusted to future dates
|
|
35
|
+
- Prevents API validation errors for past due dates
|
|
36
|
+
|
|
37
|
+
When noPastDueDates=false:
|
|
38
|
+
- Original due dates are preserved, even if they're in the past
|
|
39
|
+
- Useful for historical data or when past dates are intentionally required
|
|
24
40
|
"""
|
|
25
41
|
|
|
26
42
|
def __init__(self, integration_name: str, config: Optional[Dict[str, Any]] = None):
|
|
@@ -45,6 +61,9 @@ class DueDateHandler:
|
|
|
45
61
|
# Load integration-specific timelines from config
|
|
46
62
|
self.integration_timelines = self._load_integration_timelines()
|
|
47
63
|
|
|
64
|
+
# Load noPastDueDates setting (defaults to True)
|
|
65
|
+
self.no_past_due_dates = self._load_no_past_due_dates_setting()
|
|
66
|
+
|
|
48
67
|
def _load_integration_timelines(self) -> Dict[regscale_models.IssueSeverity, int]:
|
|
49
68
|
"""
|
|
50
69
|
Load timeline configurations for this integration from init.yaml
|
|
@@ -75,6 +94,37 @@ class DueDateHandler:
|
|
|
75
94
|
|
|
76
95
|
return timelines
|
|
77
96
|
|
|
97
|
+
def _load_no_past_due_dates_setting(self) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
Load noPastDueDates setting for this integration from init.yaml
|
|
100
|
+
|
|
101
|
+
Configuration hierarchy:
|
|
102
|
+
1. Integration-specific setting: issues.{integration_name}.noPastDueDates
|
|
103
|
+
2. Global setting: issues.noPastDueDates
|
|
104
|
+
3. Default: True
|
|
105
|
+
|
|
106
|
+
:return: True if past due dates should be automatically adjusted to future dates
|
|
107
|
+
:rtype: bool
|
|
108
|
+
"""
|
|
109
|
+
issues_config = self.config.get("issues", {})
|
|
110
|
+
integration_config = issues_config.get(self.integration_name, {})
|
|
111
|
+
|
|
112
|
+
# Check integration-specific setting first
|
|
113
|
+
if "noPastDueDates" in integration_config:
|
|
114
|
+
setting = integration_config["noPastDueDates"]
|
|
115
|
+
logger.debug(f"Using integration-specific noPastDueDates={setting} for {self.integration_name}")
|
|
116
|
+
return bool(setting)
|
|
117
|
+
|
|
118
|
+
# Fall back to global setting
|
|
119
|
+
if "noPastDueDates" in issues_config:
|
|
120
|
+
setting = issues_config["noPastDueDates"]
|
|
121
|
+
logger.debug(f"Using global noPastDueDates={setting} for {self.integration_name}")
|
|
122
|
+
return bool(setting)
|
|
123
|
+
|
|
124
|
+
# Default to True (prevent past due dates)
|
|
125
|
+
logger.debug(f"Using default noPastDueDates=True for {self.integration_name}")
|
|
126
|
+
return True
|
|
127
|
+
|
|
78
128
|
def _get_kev_data(self) -> ThreadSafeDict:
|
|
79
129
|
"""
|
|
80
130
|
Get KEV data from CISA, using cache if available
|
|
@@ -160,17 +210,26 @@ class DueDateHandler:
|
|
|
160
210
|
kev_date = date_parse(kev_due_date).date()
|
|
161
211
|
created_dt = date_parse(created_date).date()
|
|
162
212
|
|
|
163
|
-
# If KEV due date is after creation date, use KEV date
|
|
213
|
+
# If KEV due date is after creation date, use KEV date but ensure it's not in the past
|
|
164
214
|
# If KEV due date is before creation date, add the difference to creation date
|
|
165
215
|
if kev_date >= created_dt:
|
|
166
|
-
|
|
167
|
-
|
|
216
|
+
# Ensure KEV due date is not in the past
|
|
217
|
+
kev_due_validated = self._ensure_future_due_date(
|
|
218
|
+
kev_due_date, 30
|
|
219
|
+
) # Use 30 days as fallback for KEV
|
|
220
|
+
logger.debug(f"Using KEV due date {kev_due_validated} for CVE {cve}")
|
|
221
|
+
return kev_due_validated
|
|
168
222
|
else:
|
|
169
223
|
# KEV date has passed, calculate new due date from creation
|
|
170
224
|
days_diff = (created_dt - kev_date).days
|
|
171
225
|
# Give at least 30 days from creation for critical KEV items
|
|
172
226
|
adjusted_days = max(30, days_diff)
|
|
173
|
-
|
|
227
|
+
calculated_due_date_obj = get_day_increment(start=created_date, days=adjusted_days)
|
|
228
|
+
calculated_due_date = datetime.combine(calculated_due_date_obj, datetime.min.time()).strftime(
|
|
229
|
+
DATETIME_FORMAT
|
|
230
|
+
)
|
|
231
|
+
# Ensure the adjusted due date is not in the past
|
|
232
|
+
due_date = self._ensure_future_due_date(calculated_due_date, adjusted_days)
|
|
174
233
|
logger.debug(f"KEV date passed, using adjusted due date {due_date} for CVE {cve}")
|
|
175
234
|
return due_date
|
|
176
235
|
|
|
@@ -179,12 +238,64 @@ class DueDateHandler:
|
|
|
179
238
|
|
|
180
239
|
# Fall back to severity-based timeline from integration config
|
|
181
240
|
days = self.integration_timelines.get(severity, self.default_timelines[severity])
|
|
182
|
-
|
|
241
|
+
calculated_due_date_obj = get_day_increment(start=created_date, days=days)
|
|
242
|
+
calculated_due_date = datetime.combine(calculated_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
|
|
243
|
+
|
|
244
|
+
# Ensure due date is never in the past (allow yesterday for timezone differences)
|
|
245
|
+
due_date = self._ensure_future_due_date(calculated_due_date, days)
|
|
183
246
|
|
|
184
247
|
logger.debug(f"Using {self.integration_name} timeline: {severity.name} = {days} days, due date = {due_date}")
|
|
185
248
|
|
|
186
249
|
return due_date
|
|
187
250
|
|
|
251
|
+
def _ensure_future_due_date(self, calculated_due_date: str, original_days: int) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Ensure the due date is not in the past. If it is, set it to today + original timeline days.
|
|
254
|
+
Behavior is controlled by the noPastDueDates configuration setting.
|
|
255
|
+
|
|
256
|
+
:param str calculated_due_date: The originally calculated due date
|
|
257
|
+
:param int original_days: The original number of days for this severity
|
|
258
|
+
:return: A due date that respects the noPastDueDates setting
|
|
259
|
+
:rtype: str
|
|
260
|
+
"""
|
|
261
|
+
# If noPastDueDates is disabled, return the original date without validation
|
|
262
|
+
if not self.no_past_due_dates:
|
|
263
|
+
logger.debug(
|
|
264
|
+
f"noPastDueDates=False for {self.integration_name}, returning original due date: {calculated_due_date}"
|
|
265
|
+
)
|
|
266
|
+
return calculated_due_date
|
|
267
|
+
|
|
268
|
+
from dateutil.parser import parse as date_parse
|
|
269
|
+
from datetime import date
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
calculated_date = date_parse(calculated_due_date).date()
|
|
273
|
+
today = date.today()
|
|
274
|
+
|
|
275
|
+
if calculated_date >= today:
|
|
276
|
+
return calculated_due_date
|
|
277
|
+
else:
|
|
278
|
+
# Due date is in the past, calculate new due date from today
|
|
279
|
+
# Use minimum 1 day to ensure it's always in the future
|
|
280
|
+
safe_days = max(1, original_days)
|
|
281
|
+
new_due_date_obj = get_day_increment(start=today, days=safe_days)
|
|
282
|
+
new_due_date = datetime.combine(new_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
|
|
283
|
+
logger.debug(
|
|
284
|
+
f"Due date {calculated_due_date} was in the past for {self.integration_name}. "
|
|
285
|
+
f"Adjusted to {new_due_date} ({safe_days} days from today)."
|
|
286
|
+
)
|
|
287
|
+
return new_due_date
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning(f"Failed to validate due date {calculated_due_date}: {e}")
|
|
291
|
+
# If we can't parse the date, return a safe fallback only if noPastDueDates is enabled
|
|
292
|
+
if self.no_past_due_dates:
|
|
293
|
+
safe_days = max(1, original_days)
|
|
294
|
+
fallback_due_date_obj = get_day_increment(start=date.today(), days=safe_days)
|
|
295
|
+
return datetime.combine(fallback_due_date_obj, datetime.min.time()).strftime(DATETIME_FORMAT)
|
|
296
|
+
else:
|
|
297
|
+
return calculated_due_date
|
|
298
|
+
|
|
188
299
|
def get_integration_config(self) -> Dict[str, Any]:
|
|
189
300
|
"""
|
|
190
301
|
Get the full integration configuration from init.yaml
|
|
@@ -205,6 +316,7 @@ class DueDateHandler:
|
|
|
205
316
|
return {
|
|
206
317
|
"integration_name": self.integration_name,
|
|
207
318
|
"use_kev": self._should_use_kev(),
|
|
319
|
+
"no_past_due_dates": self.no_past_due_dates,
|
|
208
320
|
"timelines": {severity.name: days for severity, days in self.integration_timelines.items()},
|
|
209
321
|
"config_source": "init.yaml",
|
|
210
322
|
}
|