condor-scan 0.3.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.
- condor_scan/__init__.py +23 -0
- condor_scan/analysis.py +224 -0
- condor_scan/cel.py +50 -0
- condor_scan/cli.py +187 -0
- condor_scan/constraints.py +81 -0
- condor_scan/findings.py +222 -0
- condor_scan/graph.py +318 -0
- condor_scan/knowledge.py +186 -0
- condor_scan/loaders.py +159 -0
- condor_scan/model.py +130 -0
- condor_scan/rules.py +478 -0
- condor_scan/techniques.py +149 -0
- condor_scan/temporal.py +155 -0
- condor_scan-0.3.0.dist-info/METADATA +424 -0
- condor_scan-0.3.0.dist-info/RECORD +18 -0
- condor_scan-0.3.0.dist-info/WHEEL +4 -0
- condor_scan-0.3.0.dist-info/entry_points.txt +2 -0
- condor_scan-0.3.0.dist-info/licenses/LICENSE +202 -0
condor_scan/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""condor-scan: GCP conditional IAM & tag-based privilege-escalation scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .analysis import build_context
|
|
6
|
+
from .findings import Finding, Severity
|
|
7
|
+
from .graph import PostureReport, analyze_posture
|
|
8
|
+
from .loaders import load_from_dict, load_from_file
|
|
9
|
+
from .rules import EscalationEngine
|
|
10
|
+
|
|
11
|
+
__version__ = "0.3.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"EscalationEngine",
|
|
15
|
+
"Finding",
|
|
16
|
+
"PostureReport",
|
|
17
|
+
"Severity",
|
|
18
|
+
"analyze_posture",
|
|
19
|
+
"build_context",
|
|
20
|
+
"load_from_dict",
|
|
21
|
+
"load_from_file",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
condor_scan/analysis.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Pre-computation layer: turn an Environment into queryable index structures.
|
|
2
|
+
|
|
3
|
+
This separates the (pure, cacheable) work of expanding roles and bindings from
|
|
4
|
+
the escalation engine that walks them. Keeping it separate makes both halves
|
|
5
|
+
straightforward to unit-test.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .knowledge import (
|
|
14
|
+
ACT_AS_PERMISSIONS,
|
|
15
|
+
KEY_CREATE_PERMISSIONS,
|
|
16
|
+
TOKEN_CREATE_PERMISSIONS,
|
|
17
|
+
role_permissions,
|
|
18
|
+
)
|
|
19
|
+
from .model import Binding, Environment
|
|
20
|
+
from .temporal import TemporalWindow, parse_temporal_window
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ConditionalGrant:
|
|
25
|
+
"""A role granted to a member only when an IAM Condition holds."""
|
|
26
|
+
|
|
27
|
+
role: str
|
|
28
|
+
permissions: frozenset[str]
|
|
29
|
+
condition_title: str
|
|
30
|
+
condition_expression: str
|
|
31
|
+
resource: str
|
|
32
|
+
temporal: TemporalWindow = field(default_factory=TemporalWindow)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class GrantRecord:
|
|
37
|
+
"""An unconditional binding that granted a role to a principal.
|
|
38
|
+
|
|
39
|
+
Retained so escalation steps can be attributed back to the specific,
|
|
40
|
+
remediable binding that enabled them (used by choke-point analysis).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
role: str
|
|
44
|
+
resource: str
|
|
45
|
+
permissions: frozenset[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ImpersonationGrant:
|
|
50
|
+
"""A binding on a service-account resource that lets a member act as it."""
|
|
51
|
+
|
|
52
|
+
role: str
|
|
53
|
+
resource: str
|
|
54
|
+
permissions: frozenset[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PrincipalIndex:
|
|
59
|
+
"""Everything we need to know about one principal's starting position."""
|
|
60
|
+
|
|
61
|
+
member: str
|
|
62
|
+
permissions: set[str] = field(default_factory=set)
|
|
63
|
+
roles: set[str] = field(default_factory=set)
|
|
64
|
+
conditional_grants: list[ConditionalGrant] = field(default_factory=list)
|
|
65
|
+
grants: list[GrantRecord] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ServiceAccountIndex:
|
|
70
|
+
"""A service account's own privileges and who can impersonate it."""
|
|
71
|
+
|
|
72
|
+
email: str
|
|
73
|
+
permissions: set[str] = field(default_factory=set)
|
|
74
|
+
roles: set[str] = field(default_factory=set)
|
|
75
|
+
# member -> impersonation grants that member holds on this SA
|
|
76
|
+
impersonators: dict[str, list[ImpersonationGrant]] = field(
|
|
77
|
+
default_factory=dict
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AnalysisContext:
|
|
83
|
+
"""The fully indexed environment passed to the escalation engine."""
|
|
84
|
+
|
|
85
|
+
principals: dict[str, PrincipalIndex]
|
|
86
|
+
service_accounts: dict[str, ServiceAccountIndex]
|
|
87
|
+
custom_roles: dict[str, frozenset[str]]
|
|
88
|
+
group_members: dict[str, tuple[str, ...]]
|
|
89
|
+
exposed_principals: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
def members_including_groups(self, member: str) -> set[str]:
|
|
92
|
+
"""Resolve a member to itself plus any groups that contain it.
|
|
93
|
+
|
|
94
|
+
Used so that a conditional binding granted to a *group* is recognised
|
|
95
|
+
as applying to its members.
|
|
96
|
+
"""
|
|
97
|
+
result = {member}
|
|
98
|
+
for group, members in self.group_members.items():
|
|
99
|
+
if member in members:
|
|
100
|
+
result.add(group)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def untrusted_sources(self) -> set[str]:
|
|
104
|
+
"""Members that an attacker can act as without prior access.
|
|
105
|
+
|
|
106
|
+
These are the "initial access" footholds: the public IAM members
|
|
107
|
+
``allUsers`` / ``allAuthenticatedUsers`` (when actually bound to
|
|
108
|
+
something), plus any principals declared internet-exposed in the export
|
|
109
|
+
(e.g. service accounts attached to a public Cloud Run service).
|
|
110
|
+
"""
|
|
111
|
+
sources = set(self.exposed_principals)
|
|
112
|
+
for special in ("allUsers", "allAuthenticatedUsers"):
|
|
113
|
+
if special in self.principals:
|
|
114
|
+
sources.add(special)
|
|
115
|
+
return sources
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_SA_RESOURCE_MARKER = "serviceAccounts/"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _sa_email_from_resource(resource: str) -> str | None:
|
|
122
|
+
if _SA_RESOURCE_MARKER not in resource:
|
|
123
|
+
return None
|
|
124
|
+
return resource.rsplit("/", 1)[-1]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_context(env: Environment) -> AnalysisContext:
|
|
128
|
+
"""Index an :class:`Environment` for escalation analysis."""
|
|
129
|
+
custom_roles = {name: role.permissions for name, role in env.roles.items()}
|
|
130
|
+
|
|
131
|
+
principals: dict[str, PrincipalIndex] = {}
|
|
132
|
+
service_accounts: dict[str, ServiceAccountIndex] = {}
|
|
133
|
+
|
|
134
|
+
def principal(member: str) -> PrincipalIndex:
|
|
135
|
+
return principals.setdefault(member, PrincipalIndex(member=member))
|
|
136
|
+
|
|
137
|
+
def service_account(email: str) -> ServiceAccountIndex:
|
|
138
|
+
return service_accounts.setdefault(
|
|
139
|
+
email, ServiceAccountIndex(email=email)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for policy in env.iam_policies:
|
|
143
|
+
sa_email = _sa_email_from_resource(policy.resource)
|
|
144
|
+
for binding in policy.bindings:
|
|
145
|
+
perms = role_permissions(binding.role, custom_roles)
|
|
146
|
+
for member in binding.members:
|
|
147
|
+
_apply_binding(
|
|
148
|
+
member=member,
|
|
149
|
+
binding=binding,
|
|
150
|
+
perms=perms,
|
|
151
|
+
resource=policy.resource,
|
|
152
|
+
principal=principal,
|
|
153
|
+
)
|
|
154
|
+
# If this policy is attached to a service account, the binding
|
|
155
|
+
# describes who may impersonate that SA.
|
|
156
|
+
if sa_email is not None:
|
|
157
|
+
_record_impersonation(
|
|
158
|
+
sa=service_account(sa_email),
|
|
159
|
+
member=member,
|
|
160
|
+
role=binding.role,
|
|
161
|
+
resource=policy.resource,
|
|
162
|
+
perms=perms,
|
|
163
|
+
)
|
|
164
|
+
# A service account that is itself a *member* gains those perms.
|
|
165
|
+
for member in binding.members:
|
|
166
|
+
if member.startswith("serviceAccount:"):
|
|
167
|
+
email = member.split(":", 1)[1]
|
|
168
|
+
sa = service_account(email)
|
|
169
|
+
sa.permissions.update(perms)
|
|
170
|
+
sa.roles.add(binding.role)
|
|
171
|
+
|
|
172
|
+
return AnalysisContext(
|
|
173
|
+
principals=principals,
|
|
174
|
+
service_accounts=service_accounts,
|
|
175
|
+
custom_roles=custom_roles,
|
|
176
|
+
group_members=dict(env.group_members),
|
|
177
|
+
exposed_principals=tuple(env.exposed_principals),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _apply_binding(
|
|
182
|
+
*,
|
|
183
|
+
member: str,
|
|
184
|
+
binding: Binding,
|
|
185
|
+
perms: frozenset[str],
|
|
186
|
+
resource: str,
|
|
187
|
+
principal: Callable[[str], PrincipalIndex],
|
|
188
|
+
) -> None:
|
|
189
|
+
idx = principal(member)
|
|
190
|
+
if binding.is_conditional:
|
|
191
|
+
assert binding.condition is not None
|
|
192
|
+
idx.conditional_grants.append(
|
|
193
|
+
ConditionalGrant(
|
|
194
|
+
role=binding.role,
|
|
195
|
+
permissions=perms,
|
|
196
|
+
condition_title=binding.condition.title,
|
|
197
|
+
condition_expression=binding.condition.expression,
|
|
198
|
+
resource=resource,
|
|
199
|
+
temporal=parse_temporal_window(binding.condition.expression),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
idx.permissions.update(perms)
|
|
204
|
+
idx.roles.add(binding.role)
|
|
205
|
+
idx.grants.append(
|
|
206
|
+
GrantRecord(role=binding.role, resource=resource, permissions=perms)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _record_impersonation(
|
|
211
|
+
*,
|
|
212
|
+
sa: ServiceAccountIndex,
|
|
213
|
+
member: str,
|
|
214
|
+
role: str,
|
|
215
|
+
resource: str,
|
|
216
|
+
perms: frozenset[str],
|
|
217
|
+
) -> None:
|
|
218
|
+
relevant = perms & (
|
|
219
|
+
TOKEN_CREATE_PERMISSIONS | KEY_CREATE_PERMISSIONS | ACT_AS_PERMISSIONS
|
|
220
|
+
)
|
|
221
|
+
if relevant:
|
|
222
|
+
sa.impersonators.setdefault(member, []).append(
|
|
223
|
+
ImpersonationGrant(role=role, resource=resource, permissions=relevant)
|
|
224
|
+
)
|
condor_scan/cel.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A conservative, well-scoped parser for IAM Condition (CEL) expressions.
|
|
2
|
+
|
|
3
|
+
We do not implement a full CEL evaluator. For escalation analysis we only need
|
|
4
|
+
to answer one question: *can a principal who is able to attach tags satisfy this
|
|
5
|
+
condition?* That requires recognising tag-based predicates:
|
|
6
|
+
|
|
7
|
+
resource.matchTag('123456789/env', 'prod')
|
|
8
|
+
resource.matchTagId('tagKeys/123', 'tagValues/456')
|
|
9
|
+
|
|
10
|
+
Anything we do not recognise is treated as **not** tag-satisfiable, which is the
|
|
11
|
+
safe (non-false-positive) default. Limitations are documented in the README.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
# resource.matchTag('<namespaced key>', '<short value>')
|
|
20
|
+
_MATCH_TAG = re.compile(
|
|
21
|
+
r"resource\.matchTag\(\s*['\"]([^'\"]+)['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)"
|
|
22
|
+
)
|
|
23
|
+
# resource.matchTagId('tagKeys/<id>', 'tagValues/<id>')
|
|
24
|
+
_MATCH_TAG_ID = re.compile(
|
|
25
|
+
r"resource\.matchTagId\(\s*['\"]([^'\"]+)['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TagPredicate:
|
|
31
|
+
"""A single tag requirement extracted from a condition."""
|
|
32
|
+
|
|
33
|
+
key: str
|
|
34
|
+
value: str
|
|
35
|
+
by_id: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_tag_predicates(expression: str) -> list[TagPredicate]:
|
|
39
|
+
"""Return all tag predicates found in a CEL expression."""
|
|
40
|
+
predicates: list[TagPredicate] = []
|
|
41
|
+
for key, value in _MATCH_TAG.findall(expression):
|
|
42
|
+
predicates.append(TagPredicate(key=key, value=value, by_id=False))
|
|
43
|
+
for key, value in _MATCH_TAG_ID.findall(expression):
|
|
44
|
+
predicates.append(TagPredicate(key=key, value=value, by_id=True))
|
|
45
|
+
return predicates
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_tag_based(expression: str) -> bool:
|
|
49
|
+
"""True if the condition's satisfiability depends on resource tags."""
|
|
50
|
+
return bool(extract_tag_predicates(expression))
|
condor_scan/cli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Command-line interface for condor-scan.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library (argparse) to keep the supply-chain footprint of
|
|
4
|
+
a security tool minimal. Subcommands:
|
|
5
|
+
|
|
6
|
+
condor-scan scan EXPORT.json [--format json|sarif|table] [--fail-on SEVERITY]
|
|
7
|
+
condor-scan posture EXPORT.json [--format text|json] [--fail-on-exposed]
|
|
8
|
+
condor-scan gen-constraints [--out-dir DIR]
|
|
9
|
+
|
|
10
|
+
``scan`` and ``posture`` exit non-zero on policy violations so they can gate a
|
|
11
|
+
CI pipeline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .analysis import build_context
|
|
23
|
+
from .constraints import generate_constraint_yaml, generate_rego
|
|
24
|
+
from .findings import Severity, render
|
|
25
|
+
from .graph import analyze_posture
|
|
26
|
+
from .loaders import LoaderError, load_from_file
|
|
27
|
+
from .rules import EscalationEngine
|
|
28
|
+
from .temporal import parse_timestamp
|
|
29
|
+
|
|
30
|
+
_EXIT_OK = 0
|
|
31
|
+
_EXIT_FINDINGS = 1
|
|
32
|
+
_EXIT_USAGE = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="condor-scan",
|
|
38
|
+
description=(
|
|
39
|
+
"Detect GCP IAM privilege-escalation chains, including tag-based "
|
|
40
|
+
"and IAM-Conditions escalation paths."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
scan = sub.add_parser("scan", help="scan an IAM/asset export for escalation")
|
|
46
|
+
scan.add_argument("export", help="path to a JSON IAM/asset export")
|
|
47
|
+
scan.add_argument(
|
|
48
|
+
"--format",
|
|
49
|
+
choices=["json", "sarif", "table"],
|
|
50
|
+
default="table",
|
|
51
|
+
help="output format (default: table)",
|
|
52
|
+
)
|
|
53
|
+
scan.add_argument(
|
|
54
|
+
"--fail-on",
|
|
55
|
+
choices=[s.name.lower() for s in Severity if s >= Severity.LOW],
|
|
56
|
+
default=None,
|
|
57
|
+
help="exit non-zero if any finding is at or above this severity",
|
|
58
|
+
)
|
|
59
|
+
scan.add_argument(
|
|
60
|
+
"--as-of",
|
|
61
|
+
default=None,
|
|
62
|
+
metavar="ISO8601",
|
|
63
|
+
help="evaluate time-bound conditions as of this instant (default: now)",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
gen = sub.add_parser(
|
|
67
|
+
"gen-constraints", help="emit Policy Library / OPA constraints"
|
|
68
|
+
)
|
|
69
|
+
gen.add_argument(
|
|
70
|
+
"--out-dir",
|
|
71
|
+
default=None,
|
|
72
|
+
help="write constraint files to this directory instead of stdout",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
posture = sub.add_parser(
|
|
76
|
+
"posture",
|
|
77
|
+
help="attack-path posture: exposure, choke points, detection blind spots",
|
|
78
|
+
)
|
|
79
|
+
posture.add_argument("export", help="path to a JSON IAM/asset export")
|
|
80
|
+
posture.add_argument(
|
|
81
|
+
"--format",
|
|
82
|
+
choices=["text", "json"],
|
|
83
|
+
default="text",
|
|
84
|
+
help="output format (default: text)",
|
|
85
|
+
)
|
|
86
|
+
posture.add_argument(
|
|
87
|
+
"--fail-on-exposed",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="exit non-zero if any externally-exposed Tier-Zero path exists",
|
|
90
|
+
)
|
|
91
|
+
posture.add_argument(
|
|
92
|
+
"--as-of",
|
|
93
|
+
default=None,
|
|
94
|
+
metavar="ISO8601",
|
|
95
|
+
help="evaluate time-bound conditions as of this instant (default: now)",
|
|
96
|
+
)
|
|
97
|
+
posture.add_argument(
|
|
98
|
+
"--jit-threshold-hours",
|
|
99
|
+
type=float,
|
|
100
|
+
default=24.0,
|
|
101
|
+
help="windows shorter than this count as JIT/short-lived (default: 24)",
|
|
102
|
+
)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_as_of(value: str | None) -> datetime | None:
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
parsed = parse_timestamp(value)
|
|
110
|
+
if parsed is None:
|
|
111
|
+
raise ValueError(f"invalid --as-of timestamp: {value!r}")
|
|
112
|
+
return parsed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_scan(args: argparse.Namespace) -> int:
|
|
116
|
+
try:
|
|
117
|
+
env = load_from_file(args.export)
|
|
118
|
+
now = _parse_as_of(args.as_of)
|
|
119
|
+
except (LoaderError, FileNotFoundError, ValueError) as exc:
|
|
120
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
121
|
+
return _EXIT_USAGE
|
|
122
|
+
|
|
123
|
+
ctx = build_context(env)
|
|
124
|
+
findings = EscalationEngine(ctx, now=now).analyze_all()
|
|
125
|
+
print(render(findings, args.format))
|
|
126
|
+
|
|
127
|
+
if args.fail_on is not None:
|
|
128
|
+
threshold = Severity.from_name(args.fail_on)
|
|
129
|
+
if any(f.severity >= threshold for f in findings):
|
|
130
|
+
return _EXIT_FINDINGS
|
|
131
|
+
return _EXIT_OK
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_gen_constraints(args: argparse.Namespace) -> int:
|
|
135
|
+
rego = generate_rego()
|
|
136
|
+
yaml = generate_constraint_yaml()
|
|
137
|
+
if args.out_dir:
|
|
138
|
+
out = Path(args.out_dir)
|
|
139
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
(out / "tag_condition_escalation.rego").write_text(rego, encoding="utf-8")
|
|
141
|
+
(out / "tag_condition_escalation.yaml").write_text(yaml, encoding="utf-8")
|
|
142
|
+
print(f"wrote constraint template and instance to {out}/")
|
|
143
|
+
else:
|
|
144
|
+
print(rego)
|
|
145
|
+
print("---")
|
|
146
|
+
print(yaml)
|
|
147
|
+
return _EXIT_OK
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run_posture(args: argparse.Namespace) -> int:
|
|
151
|
+
try:
|
|
152
|
+
env = load_from_file(args.export)
|
|
153
|
+
now = _parse_as_of(args.as_of)
|
|
154
|
+
except (LoaderError, FileNotFoundError, ValueError) as exc:
|
|
155
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
156
|
+
return _EXIT_USAGE
|
|
157
|
+
|
|
158
|
+
ctx = build_context(env)
|
|
159
|
+
engine = EscalationEngine(
|
|
160
|
+
ctx, now=now, jit_threshold=timedelta(hours=args.jit_threshold_hours)
|
|
161
|
+
)
|
|
162
|
+
report = analyze_posture(ctx, engine)
|
|
163
|
+
if args.format == "json":
|
|
164
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
165
|
+
else:
|
|
166
|
+
print(report.to_text())
|
|
167
|
+
|
|
168
|
+
if args.fail_on_exposed and report.exposed_tier_zero:
|
|
169
|
+
return _EXIT_FINDINGS
|
|
170
|
+
return _EXIT_OK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: list[str] | None = None) -> int:
|
|
174
|
+
parser = build_parser()
|
|
175
|
+
args = parser.parse_args(argv)
|
|
176
|
+
if args.command == "scan":
|
|
177
|
+
return _run_scan(args)
|
|
178
|
+
if args.command == "gen-constraints":
|
|
179
|
+
return _run_gen_constraints(args)
|
|
180
|
+
if args.command == "posture":
|
|
181
|
+
return _run_posture(args)
|
|
182
|
+
parser.error("unknown command") # pragma: no cover
|
|
183
|
+
return _EXIT_USAGE
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__": # pragma: no cover
|
|
187
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Generate Policy Library / OPA constraints from the engine's knowledge.
|
|
2
|
+
|
|
3
|
+
This is the "Google-adoptable" artifact: Forseti's policy-library and the
|
|
4
|
+
Config Validator / Gatekeeper ecosystem consume OPA (Rego) constraint templates.
|
|
5
|
+
We emit a constraint that flags the tag-conditional escalation pattern the
|
|
6
|
+
engine detects, so the same rule can run as a *preventive* policy at deploy time
|
|
7
|
+
(Terraform validation, CI gate) rather than only as a detective scan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from . import knowledge as kb
|
|
13
|
+
|
|
14
|
+
_REGO_HEADER = """\
|
|
15
|
+
# Auto-generated by condor-scan.
|
|
16
|
+
# Flags conditional IAM bindings whose condition is keyed on resource tags and
|
|
17
|
+
# whose granted role confers escalation-relevant permissions. Such bindings let
|
|
18
|
+
# any principal able to attach tags satisfy the condition and self-escalate
|
|
19
|
+
# (the "Tag Your Way In" pattern; Mitiga, 2026).
|
|
20
|
+
package templates.gcp.GCPIAMTagConditionEscalationConstraintV1
|
|
21
|
+
|
|
22
|
+
import data.validator.gcp.lib as lib
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _escalation_roles() -> list[str]:
|
|
27
|
+
return sorted(kb.TIER_ZERO_ROLES)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_rego() -> str:
|
|
31
|
+
"""Return a Rego constraint-template body for tag-conditional escalation."""
|
|
32
|
+
roles_list = ",\n ".join(f'"{r}"' for r in _escalation_roles())
|
|
33
|
+
return (
|
|
34
|
+
_REGO_HEADER
|
|
35
|
+
+ f"""
|
|
36
|
+
escalation_roles := {{
|
|
37
|
+
{roles_list}
|
|
38
|
+
}}
|
|
39
|
+
|
|
40
|
+
# A condition expression is tag-based if it references resource tag matchers.
|
|
41
|
+
is_tag_condition(expr) {{
|
|
42
|
+
contains(expr, "resource.matchTag(")
|
|
43
|
+
}}
|
|
44
|
+
is_tag_condition(expr) {{
|
|
45
|
+
contains(expr, "resource.matchTagId(")
|
|
46
|
+
}}
|
|
47
|
+
|
|
48
|
+
deny[{{"msg": msg, "details": details}}] {{
|
|
49
|
+
binding := input.asset.iam_policy.bindings[_]
|
|
50
|
+
escalation_roles[binding.role]
|
|
51
|
+
expr := binding.condition.expression
|
|
52
|
+
is_tag_condition(expr)
|
|
53
|
+
|
|
54
|
+
msg := sprintf(
|
|
55
|
+
"Conditional binding grants escalation role %v under a tag-based condition (%v); tag-attach permission holders can self-escalate.",
|
|
56
|
+
[binding.role, binding.condition.title]
|
|
57
|
+
)
|
|
58
|
+
details := {{
|
|
59
|
+
"resource": input.asset.name,
|
|
60
|
+
"role": binding.role,
|
|
61
|
+
"condition": binding.condition.title,
|
|
62
|
+
"expression": expr,
|
|
63
|
+
}}
|
|
64
|
+
}}
|
|
65
|
+
"""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_constraint_yaml(name: str = "deny-tag-condition-escalation") -> str:
|
|
70
|
+
"""Return the constraint instance YAML that binds the template above."""
|
|
71
|
+
return f"""\
|
|
72
|
+
apiVersion: constraints.gatekeeper.sh/v1alpha1
|
|
73
|
+
kind: GCPIAMTagConditionEscalationConstraintV1
|
|
74
|
+
metadata:
|
|
75
|
+
name: {name}
|
|
76
|
+
spec:
|
|
77
|
+
severity: high
|
|
78
|
+
match:
|
|
79
|
+
target: ["organizations/**"]
|
|
80
|
+
parameters: {{}}
|
|
81
|
+
"""
|