preflight-engine 0.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.
- preflight/__init__.py +3 -0
- preflight/cli.py +536 -0
- preflight/config.py +107 -0
- preflight/engine.py +115 -0
- preflight/github.py +124 -0
- preflight/models.py +274 -0
- preflight/plan.py +165 -0
- preflight/policy.py +67 -0
- preflight/redaction.py +54 -0
- preflight/reporting.py +404 -0
- preflight/rules/__init__.py +85 -0
- preflight/rules/aws.py +580 -0
- preflight/rules/azure.py +309 -0
- preflight/rules/base.py +24 -0
- preflight/rules/gcp.py +250 -0
- preflight/rules/generic.py +298 -0
- preflight/rules/kubernetes.py +264 -0
- preflight/scoring.py +140 -0
- preflight_engine-0.1.0.dist-info/METADATA +271 -0
- preflight_engine-0.1.0.dist-info/RECORD +23 -0
- preflight_engine-0.1.0.dist-info/WHEEL +4 -0
- preflight_engine-0.1.0.dist-info/entry_points.txt +2 -0
- preflight_engine-0.1.0.dist-info/licenses/LICENSE +175 -0
preflight/rules/azure.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Azure-specific rules"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from preflight.models import (
|
|
6
|
+
ActionType,
|
|
7
|
+
Finding,
|
|
8
|
+
ResourceChange,
|
|
9
|
+
RiskFactors,
|
|
10
|
+
Severity,
|
|
11
|
+
)
|
|
12
|
+
from preflight.rules.base import Rule, RuleContext
|
|
13
|
+
from preflight.scoring import score_finding
|
|
14
|
+
|
|
15
|
+
PUBLIC_SOURCES = {"*", "0.0.0.0/0", "::/0", "internet"}
|
|
16
|
+
ADMIN_PORTS = {22, 3389}
|
|
17
|
+
|
|
18
|
+
AZURE_DATABASE_TYPES = {
|
|
19
|
+
"azurerm_mssql_database",
|
|
20
|
+
"azurerm_mssql_server",
|
|
21
|
+
"azurerm_cosmosdb_account",
|
|
22
|
+
"azurerm_postgresql_flexible_server",
|
|
23
|
+
"azurerm_postgresql_server",
|
|
24
|
+
"azurerm_mysql_flexible_server",
|
|
25
|
+
"azurerm_mysql_server",
|
|
26
|
+
"azurerm_mariadb_server",
|
|
27
|
+
"azurerm_redis_cache",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _port_spec_matches_admin(port_spec: Any) -> tuple[bool, str]:
|
|
32
|
+
if port_spec is None:
|
|
33
|
+
return False, ""
|
|
34
|
+
spec_str = str(port_spec).strip()
|
|
35
|
+
if spec_str == "*":
|
|
36
|
+
return True, "all ports (*)"
|
|
37
|
+
if "-" in spec_str:
|
|
38
|
+
parts = spec_str.split("-", 1)
|
|
39
|
+
try:
|
|
40
|
+
low, high = int(parts[0]), int(parts[1])
|
|
41
|
+
found: list[str] = []
|
|
42
|
+
if low <= 22 <= high:
|
|
43
|
+
found.append("SSH:22")
|
|
44
|
+
if low <= 3389 <= high:
|
|
45
|
+
found.append("RDP:3389")
|
|
46
|
+
if found:
|
|
47
|
+
return True, ", ".join(found)
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
val = int(spec_str)
|
|
53
|
+
if val == 22:
|
|
54
|
+
return True, "SSH:22"
|
|
55
|
+
if val == 3389:
|
|
56
|
+
return True, "RDP:3389"
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
return False, ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_azure_nsg_rule(rule_data: dict[str, Any]) -> tuple[bool, str, list[str]]:
|
|
63
|
+
direction = str(rule_data.get("direction", "")).lower()
|
|
64
|
+
access = str(rule_data.get("access", "")).lower()
|
|
65
|
+
if direction != "inbound" or access != "allow":
|
|
66
|
+
return False, "", []
|
|
67
|
+
|
|
68
|
+
sources: list[str] = []
|
|
69
|
+
src = rule_data.get("source_address_prefix")
|
|
70
|
+
if src:
|
|
71
|
+
sources.append(str(src))
|
|
72
|
+
src_list = rule_data.get("source_address_prefixes")
|
|
73
|
+
if isinstance(src_list, list):
|
|
74
|
+
sources.extend(str(s) for s in src_list)
|
|
75
|
+
|
|
76
|
+
matched_sources = [s for s in sources if s.strip().lower() in PUBLIC_SOURCES]
|
|
77
|
+
if not matched_sources:
|
|
78
|
+
return False, "", []
|
|
79
|
+
|
|
80
|
+
ports: list[Any] = []
|
|
81
|
+
port = rule_data.get("destination_port_range")
|
|
82
|
+
if port is not None:
|
|
83
|
+
ports.append(port)
|
|
84
|
+
port_list = rule_data.get("destination_port_ranges")
|
|
85
|
+
if isinstance(port_list, list):
|
|
86
|
+
ports.extend(port_list)
|
|
87
|
+
|
|
88
|
+
for p in ports:
|
|
89
|
+
matches, desc = _port_spec_matches_admin(p)
|
|
90
|
+
if matches:
|
|
91
|
+
return True, desc, matched_sources
|
|
92
|
+
|
|
93
|
+
return False, "", []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AzureNsgPublicAdminIngressRule(Rule):
|
|
97
|
+
rule_id = "AZR-NET-001"
|
|
98
|
+
name = "Azure NSG Public Administrative Ingress"
|
|
99
|
+
description = "Detects NSG rules allowing Internet access to administrative ports (22, 3389) or wildcard ports."
|
|
100
|
+
default_severity = Severity.CRITICAL
|
|
101
|
+
|
|
102
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
103
|
+
if resource.action == ActionType.DELETE or not resource.after:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
findings: list[Finding] = []
|
|
107
|
+
|
|
108
|
+
if resource.type == "azurerm_network_security_rule":
|
|
109
|
+
matches, port_desc, sources = _check_azure_nsg_rule(resource.after)
|
|
110
|
+
if matches:
|
|
111
|
+
findings.append(self._build_finding(resource, context, port_desc, sources))
|
|
112
|
+
|
|
113
|
+
elif resource.type == "azurerm_network_security_group":
|
|
114
|
+
rules = resource.after.get("security_rule", [])
|
|
115
|
+
if isinstance(rules, list):
|
|
116
|
+
for rule in rules:
|
|
117
|
+
if isinstance(rule, dict):
|
|
118
|
+
matches, port_desc, sources = _check_azure_nsg_rule(rule)
|
|
119
|
+
if matches:
|
|
120
|
+
findings.append(
|
|
121
|
+
self._build_finding(resource, context, port_desc, sources)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return findings
|
|
125
|
+
|
|
126
|
+
def _build_finding(
|
|
127
|
+
self,
|
|
128
|
+
resource: ResourceChange,
|
|
129
|
+
context: RuleContext,
|
|
130
|
+
port_desc: str,
|
|
131
|
+
sources: list[str],
|
|
132
|
+
) -> Finding:
|
|
133
|
+
base_factors = RiskFactors(
|
|
134
|
+
change_impact=3.5,
|
|
135
|
+
security_exposure=5.0,
|
|
136
|
+
reversibility=1.0,
|
|
137
|
+
uncertainty=0.5,
|
|
138
|
+
)
|
|
139
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
140
|
+
return Finding(
|
|
141
|
+
rule_id=self.rule_id,
|
|
142
|
+
rule_name=self.name,
|
|
143
|
+
severity=self.default_severity,
|
|
144
|
+
resource_address=resource.address,
|
|
145
|
+
resource_type=resource.type,
|
|
146
|
+
action=resource.action,
|
|
147
|
+
what_changed=f"NSG rule on '{resource.address}' allows inbound {port_desc} from public source ({', '.join(sources)}).",
|
|
148
|
+
why_it_matters="Exposing SSH or RDP to the public internet enables brute force credential attacks and automated compromise.",
|
|
149
|
+
remediation="Restrict NSG source prefixes to corporate VPNs, Azure Bastion, or private virtual network IP ranges.",
|
|
150
|
+
risk_factors=resolved_factors,
|
|
151
|
+
calculated_score=score,
|
|
152
|
+
details={"sources": sources, "ports": port_desc},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AzureStoragePublicAccessRule(Rule):
|
|
157
|
+
rule_id = "AZR-STG-001"
|
|
158
|
+
name = "Azure Storage Account Public Access"
|
|
159
|
+
description = (
|
|
160
|
+
"Detects storage accounts with public network access or nested blob public access enabled."
|
|
161
|
+
)
|
|
162
|
+
default_severity = Severity.HIGH
|
|
163
|
+
|
|
164
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
165
|
+
if resource.type != "azurerm_storage_account" or resource.action == ActionType.DELETE:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
after = resource.after or {}
|
|
169
|
+
reasons: list[str] = []
|
|
170
|
+
|
|
171
|
+
if after.get("allow_nested_items_to_be_public") is True:
|
|
172
|
+
reasons.append("allow_nested_items_to_be_public is enabled")
|
|
173
|
+
if after.get("allow_blob_public_access") is True:
|
|
174
|
+
reasons.append("allow_blob_public_access is enabled")
|
|
175
|
+
|
|
176
|
+
network_rules = after.get("network_rules")
|
|
177
|
+
if isinstance(network_rules, dict):
|
|
178
|
+
if str(network_rules.get("default_action", "")).lower() == "allow":
|
|
179
|
+
ip_rules = network_rules.get("ip_rules") or []
|
|
180
|
+
subnet_ids = network_rules.get("virtual_network_subnet_ids") or []
|
|
181
|
+
if not ip_rules and not subnet_ids:
|
|
182
|
+
reasons.append(
|
|
183
|
+
"network_rules default_action is Allow without network restrictions"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if not reasons:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
base_factors = RiskFactors(
|
|
190
|
+
change_impact=3.5,
|
|
191
|
+
security_exposure=4.5,
|
|
192
|
+
reversibility=2.0,
|
|
193
|
+
uncertainty=0.5,
|
|
194
|
+
)
|
|
195
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
Finding(
|
|
199
|
+
rule_id=self.rule_id,
|
|
200
|
+
rule_name=self.name,
|
|
201
|
+
severity=self.default_severity,
|
|
202
|
+
resource_address=resource.address,
|
|
203
|
+
resource_type=resource.type,
|
|
204
|
+
action=resource.action,
|
|
205
|
+
what_changed=f"Storage account '{resource.address}' enables public access: {'; '.join(reasons)}.",
|
|
206
|
+
why_it_matters="Public access on storage accounts risks accidental data leaks and sensitive blob exposure.",
|
|
207
|
+
remediation="Set allow_nested_items_to_be_public to false and restrict network_rules default_action to Deny.",
|
|
208
|
+
risk_factors=resolved_factors,
|
|
209
|
+
calculated_score=score,
|
|
210
|
+
details={"reasons": reasons},
|
|
211
|
+
)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AzureKeyVaultPurgeProtectionRule(Rule):
|
|
216
|
+
rule_id = "AZR-KV-001"
|
|
217
|
+
name = "Azure Key Vault Purge Protection Disabled"
|
|
218
|
+
description = (
|
|
219
|
+
"Detects Key Vaults created or updated without purge protection or soft delete enabled."
|
|
220
|
+
)
|
|
221
|
+
default_severity = Severity.HIGH
|
|
222
|
+
|
|
223
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
224
|
+
if resource.type != "azurerm_key_vault" or resource.action == ActionType.DELETE:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
after = resource.after or {}
|
|
228
|
+
purge_enabled = after.get("purge_protection_enabled")
|
|
229
|
+
soft_delete_days = after.get("soft_delete_retention_days")
|
|
230
|
+
|
|
231
|
+
disabled_reasons: list[str] = []
|
|
232
|
+
if purge_enabled is False:
|
|
233
|
+
disabled_reasons.append("purge_protection_enabled is false")
|
|
234
|
+
if soft_delete_days is not None and soft_delete_days < 7:
|
|
235
|
+
disabled_reasons.append(
|
|
236
|
+
f"soft_delete_retention_days is {soft_delete_days} (less than 7)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not disabled_reasons:
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
base_factors = RiskFactors(
|
|
243
|
+
change_impact=4.0,
|
|
244
|
+
security_exposure=3.5,
|
|
245
|
+
reversibility=4.5,
|
|
246
|
+
uncertainty=0.5,
|
|
247
|
+
)
|
|
248
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
Finding(
|
|
252
|
+
rule_id=self.rule_id,
|
|
253
|
+
rule_name=self.name,
|
|
254
|
+
severity=self.default_severity,
|
|
255
|
+
resource_address=resource.address,
|
|
256
|
+
resource_type=resource.type,
|
|
257
|
+
action=resource.action,
|
|
258
|
+
what_changed=f"Key Vault '{resource.address}' lacks protection: {'; '.join(disabled_reasons)}.",
|
|
259
|
+
why_it_matters="Without purge protection, secrets, keys, and certificates can be permanently purged immediately without recovery.",
|
|
260
|
+
remediation="Enable purge_protection_enabled = true and configure adequate soft delete retention.",
|
|
261
|
+
risk_factors=resolved_factors,
|
|
262
|
+
calculated_score=score,
|
|
263
|
+
details={"reasons": disabled_reasons},
|
|
264
|
+
)
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class AzureDatabaseDeletionReplacementRule(Rule):
|
|
269
|
+
rule_id = "AZR-DB-001"
|
|
270
|
+
name = "Azure Database Deletion or Replacement"
|
|
271
|
+
description = (
|
|
272
|
+
"Detects deletion or forced replacement of stateful Azure database and cache services."
|
|
273
|
+
)
|
|
274
|
+
default_severity = Severity.CRITICAL
|
|
275
|
+
|
|
276
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
277
|
+
if resource.type not in AZURE_DATABASE_TYPES:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
if resource.action not in (ActionType.DELETE, ActionType.REPLACE):
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
base_factors = RiskFactors(
|
|
284
|
+
change_impact=5.0,
|
|
285
|
+
security_exposure=1.0,
|
|
286
|
+
reversibility=5.0,
|
|
287
|
+
uncertainty=1.0,
|
|
288
|
+
)
|
|
289
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
290
|
+
|
|
291
|
+
action_label = (
|
|
292
|
+
"deleted" if resource.action == ActionType.DELETE else "replaced (destroy and recreate)"
|
|
293
|
+
)
|
|
294
|
+
return [
|
|
295
|
+
Finding(
|
|
296
|
+
rule_id=self.rule_id,
|
|
297
|
+
rule_name=self.name,
|
|
298
|
+
severity=self.default_severity,
|
|
299
|
+
resource_address=resource.address,
|
|
300
|
+
resource_type=resource.type,
|
|
301
|
+
action=resource.action,
|
|
302
|
+
what_changed=f"Azure database resource '{resource.address}' ({resource.type}) is scheduled to be {action_label}.",
|
|
303
|
+
why_it_matters="Deleting or replacing databases risks data loss and extended downtime unless failover replicas exist.",
|
|
304
|
+
remediation="Review immutable attribute modifications causing recreation, ensure recent backups exist, and verify deletion intentions.",
|
|
305
|
+
risk_factors=resolved_factors,
|
|
306
|
+
calculated_score=score,
|
|
307
|
+
details={"resource_type": resource.type, "action": resource.action.value},
|
|
308
|
+
)
|
|
309
|
+
]
|
preflight/rules/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Base rule interface and evaluation context."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from preflight.models import Environment, Finding, ResourceChange, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RuleContext:
|
|
11
|
+
environment: Environment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Rule(ABC):
|
|
15
|
+
"""Abstract base class for all Preflight detection rules."""
|
|
16
|
+
|
|
17
|
+
rule_id: str
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
default_severity: Severity
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
24
|
+
"""Evaluate a resource change against this rule and return any findings."""
|
preflight/rules/gcp.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""GCP specific rules."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from preflight.models import (
|
|
6
|
+
ActionType,
|
|
7
|
+
Finding,
|
|
8
|
+
ResourceChange,
|
|
9
|
+
RiskFactors,
|
|
10
|
+
Severity,
|
|
11
|
+
)
|
|
12
|
+
from preflight.rules.base import Rule, RuleContext
|
|
13
|
+
from preflight.scoring import score_finding
|
|
14
|
+
|
|
15
|
+
PUBLIC_CIDRS = {"0.0.0.0/0", "::/0"}
|
|
16
|
+
PUBLIC_IDENTITIES = {"allusers", "allauthenticatedusers"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _gcp_firewall_allows_admin(allow_blocks: list[Any]) -> tuple[bool, str]:
|
|
20
|
+
for block in allow_blocks:
|
|
21
|
+
if not isinstance(block, dict):
|
|
22
|
+
continue
|
|
23
|
+
proto = str(block.get("protocol", "")).lower()
|
|
24
|
+
ports = block.get("ports", [])
|
|
25
|
+
|
|
26
|
+
if proto in ("all", "-1") or (proto == "tcp" and not ports):
|
|
27
|
+
return True, f"all traffic ({proto})"
|
|
28
|
+
|
|
29
|
+
port_list = [str(p) for p in ports] if isinstance(ports, list) else []
|
|
30
|
+
found_ports: list[str] = []
|
|
31
|
+
for p in port_list:
|
|
32
|
+
if p == "22":
|
|
33
|
+
found_ports.append("SSH:22")
|
|
34
|
+
elif p == "3389":
|
|
35
|
+
found_ports.append("RDP:3389")
|
|
36
|
+
elif "-" in p:
|
|
37
|
+
parts = p.split("-", 1)
|
|
38
|
+
try:
|
|
39
|
+
low, high = int(parts[0]), int(parts[1])
|
|
40
|
+
if low <= 22 <= high:
|
|
41
|
+
found_ports.append("SSH:22")
|
|
42
|
+
if low <= 3389 <= high:
|
|
43
|
+
found_ports.append("RDP:3389")
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
if found_ports:
|
|
47
|
+
return True, ", ".join(found_ports)
|
|
48
|
+
|
|
49
|
+
return False, ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GcpFirewallPublicAdminIngressRule(Rule):
|
|
53
|
+
rule_id = "GCP-FW-001"
|
|
54
|
+
name = "GCP Firewall Public Administrative Ingress"
|
|
55
|
+
description = "Detects compute firewall rules exposing management ports (22, 3389) or all traffic to 0.0.0.0/0."
|
|
56
|
+
default_severity = Severity.CRITICAL
|
|
57
|
+
|
|
58
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
59
|
+
if resource.type != "google_compute_firewall" or resource.action == ActionType.DELETE:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
after = resource.after or {}
|
|
63
|
+
direction = str(after.get("direction", "INGRESS")).upper()
|
|
64
|
+
if direction != "INGRESS":
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
ranges = after.get("source_ranges") or []
|
|
68
|
+
range_set = {str(r).strip() for r in ranges} if isinstance(ranges, list) else set()
|
|
69
|
+
matched_cidrs = range_set.intersection(PUBLIC_CIDRS)
|
|
70
|
+
if not matched_cidrs:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
allow_blocks = after.get("allow") or []
|
|
74
|
+
if not isinstance(allow_blocks, list):
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
matches_admin, port_desc = _gcp_firewall_allows_admin(allow_blocks)
|
|
78
|
+
if not matches_admin:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
base_factors = RiskFactors(
|
|
82
|
+
change_impact=3.5,
|
|
83
|
+
security_exposure=5.0,
|
|
84
|
+
reversibility=1.0,
|
|
85
|
+
uncertainty=0.5,
|
|
86
|
+
)
|
|
87
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
Finding(
|
|
91
|
+
rule_id=self.rule_id,
|
|
92
|
+
rule_name=self.name,
|
|
93
|
+
severity=self.default_severity,
|
|
94
|
+
resource_address=resource.address,
|
|
95
|
+
resource_type=resource.type,
|
|
96
|
+
action=resource.action,
|
|
97
|
+
what_changed=(
|
|
98
|
+
f"Firewall rule '{resource.address}' allows ingress {port_desc} "
|
|
99
|
+
f"from public internet ({', '.join(sorted(matched_cidrs))})."
|
|
100
|
+
),
|
|
101
|
+
why_it_matters="Exposing SSH or RDP to all internet IP addresses exposes instances to automated port scanning and brute force attacks.",
|
|
102
|
+
remediation="Restrict source_ranges to trusted corporate CIDRs, bastion IPs, or use Identity-Aware Proxy (IAP) for TCP forwarding.",
|
|
103
|
+
risk_factors=resolved_factors,
|
|
104
|
+
calculated_score=score,
|
|
105
|
+
details={"source_ranges": sorted(matched_cidrs), "exposed_ports": port_desc},
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GcpStoragePublicAccessRule(Rule):
|
|
111
|
+
rule_id = "GCP-IAM-001"
|
|
112
|
+
name = "GCP Cloud Storage Public IAM Grant"
|
|
113
|
+
description = "Detects storage bucket IAM bindings or members that grant access to allUsers or allAuthenticatedUsers."
|
|
114
|
+
default_severity = Severity.CRITICAL
|
|
115
|
+
|
|
116
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
117
|
+
if resource.action == ActionType.DELETE or not resource.after:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
public_members: list[str] = []
|
|
121
|
+
|
|
122
|
+
if resource.type in (
|
|
123
|
+
"google_storage_bucket_iam_binding",
|
|
124
|
+
"google_storage_bucket_iam_policy",
|
|
125
|
+
):
|
|
126
|
+
members = resource.after.get("members") or []
|
|
127
|
+
if isinstance(members, list):
|
|
128
|
+
public_members = [
|
|
129
|
+
str(m) for m in members if str(m).strip().lower() in PUBLIC_IDENTITIES
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
elif resource.type == "google_storage_bucket_iam_member":
|
|
133
|
+
member = str(resource.after.get("member", "")).strip().lower()
|
|
134
|
+
if member in PUBLIC_IDENTITIES:
|
|
135
|
+
public_members = [member]
|
|
136
|
+
|
|
137
|
+
elif resource.type == "google_storage_bucket_access_control":
|
|
138
|
+
entity = str(resource.after.get("entity", "")).strip().lower()
|
|
139
|
+
if entity in PUBLIC_IDENTITIES:
|
|
140
|
+
public_members = [entity]
|
|
141
|
+
|
|
142
|
+
if not public_members:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
role = str(resource.after.get("role", "custom"))
|
|
146
|
+
base_factors = RiskFactors(
|
|
147
|
+
change_impact=4.0,
|
|
148
|
+
security_exposure=5.0,
|
|
149
|
+
reversibility=2.0,
|
|
150
|
+
uncertainty=0.5,
|
|
151
|
+
)
|
|
152
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
153
|
+
|
|
154
|
+
return [
|
|
155
|
+
Finding(
|
|
156
|
+
rule_id=self.rule_id,
|
|
157
|
+
rule_name=self.name,
|
|
158
|
+
severity=self.default_severity,
|
|
159
|
+
resource_address=resource.address,
|
|
160
|
+
resource_type=resource.type,
|
|
161
|
+
action=resource.action,
|
|
162
|
+
what_changed=(
|
|
163
|
+
f"Storage IAM resource '{resource.address}' grants role '{role}' "
|
|
164
|
+
f"to public identities ({', '.join(public_members)})."
|
|
165
|
+
),
|
|
166
|
+
why_it_matters="Granting storage bucket permissions to allUsers or allAuthenticatedUsers exposes data publicly without authentication.",
|
|
167
|
+
remediation="Remove public identity grants and enforce uniform bucket-level access with targeted service account or user bindings.",
|
|
168
|
+
risk_factors=resolved_factors,
|
|
169
|
+
calculated_score=score,
|
|
170
|
+
details={"role": role, "public_members": public_members},
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class GcpSqlDeletionProtectionRule(Rule):
|
|
176
|
+
rule_id = "GCP-SQL-001"
|
|
177
|
+
name = "Cloud SQL Deletion or Deletion Protection Disabled"
|
|
178
|
+
description = "Detects deletion or forced replacement of Cloud SQL instances, or disabling instance deletion protection."
|
|
179
|
+
default_severity = Severity.CRITICAL
|
|
180
|
+
|
|
181
|
+
def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
|
|
182
|
+
if resource.type != "google_sql_database_instance":
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
# 1. Instance Deletion or Recreation
|
|
186
|
+
if resource.action in (ActionType.DELETE, ActionType.REPLACE):
|
|
187
|
+
base_factors = RiskFactors(
|
|
188
|
+
change_impact=5.0,
|
|
189
|
+
security_exposure=1.0,
|
|
190
|
+
reversibility=5.0,
|
|
191
|
+
uncertainty=1.0,
|
|
192
|
+
)
|
|
193
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
194
|
+
action_label = (
|
|
195
|
+
"deleted"
|
|
196
|
+
if resource.action == ActionType.DELETE
|
|
197
|
+
else "replaced (destroy and recreate)"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return [
|
|
201
|
+
Finding(
|
|
202
|
+
rule_id=self.rule_id,
|
|
203
|
+
rule_name=self.name,
|
|
204
|
+
severity=Severity.CRITICAL,
|
|
205
|
+
resource_address=resource.address,
|
|
206
|
+
resource_type=resource.type,
|
|
207
|
+
action=resource.action,
|
|
208
|
+
what_changed=f"Cloud SQL instance '{resource.address}' is scheduled to be {action_label}.",
|
|
209
|
+
why_it_matters="Destroying or replacing a managed database causes downtime and irreversible data loss unless failover replicas exist.",
|
|
210
|
+
remediation="Verify immutable configuration parameters causing replacement, ensure automated backups are current, and confirm deletion intent.",
|
|
211
|
+
risk_factors=resolved_factors,
|
|
212
|
+
calculated_score=score,
|
|
213
|
+
details={"action": resource.action.value},
|
|
214
|
+
)
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
# 2. Deletion Protection Disabled
|
|
218
|
+
after = resource.after or {}
|
|
219
|
+
settings = after.get("settings") if isinstance(after.get("settings"), dict) else {}
|
|
220
|
+
deletion_prot = after.get("deletion_protection")
|
|
221
|
+
if deletion_prot is None and settings:
|
|
222
|
+
deletion_prot = settings.get("deletion_protection")
|
|
223
|
+
|
|
224
|
+
if deletion_prot is False:
|
|
225
|
+
base_factors = RiskFactors(
|
|
226
|
+
change_impact=3.0,
|
|
227
|
+
security_exposure=1.0,
|
|
228
|
+
reversibility=3.5,
|
|
229
|
+
uncertainty=0.5,
|
|
230
|
+
)
|
|
231
|
+
resolved_factors, score = score_finding(base_factors, context.environment)
|
|
232
|
+
|
|
233
|
+
return [
|
|
234
|
+
Finding(
|
|
235
|
+
rule_id=self.rule_id,
|
|
236
|
+
rule_name=self.name,
|
|
237
|
+
severity=Severity.HIGH,
|
|
238
|
+
resource_address=resource.address,
|
|
239
|
+
resource_type=resource.type,
|
|
240
|
+
action=resource.action,
|
|
241
|
+
what_changed=f"Cloud SQL instance '{resource.address}' has deletion_protection set to false.",
|
|
242
|
+
why_it_matters="Disabling deletion protection leaves production databases vulnerable to accidental deletion via Terraform or API calls.",
|
|
243
|
+
remediation="Set deletion_protection = true to safeguard against inadvertent database destruction.",
|
|
244
|
+
risk_factors=resolved_factors,
|
|
245
|
+
calculated_score=score,
|
|
246
|
+
details={"deletion_protection": False},
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
return []
|