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.
@@ -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
+ ]
@@ -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
+ """