subvectors 0.2.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.
- subvectors/__init__.py +8 -0
- subvectors/cel.py +322 -0
- subvectors/corpus.py +55 -0
- subvectors/ffl.py +172 -0
- subvectors/github.py +80 -0
- subvectors/gitlab.py +97 -0
- subvectors/matcher.py +208 -0
- subvectors/vectors/LICENSE +121 -0
- subvectors/vectors/bitbucket-aws.json +126 -0
- subvectors/vectors/circleci-aws.json +144 -0
- subvectors/vectors/circleci-gcp.json +102 -0
- subvectors/vectors/github-aws.json +564 -0
- subvectors/vectors/github-azure-flexible.json +126 -0
- subvectors/vectors/github-azure.json +138 -0
- subvectors/vectors/github-gcp.json +186 -0
- subvectors/vectors/gitlab-aws.json +307 -0
- subvectors/vectors/gitlab-azure-flexible.json +88 -0
- subvectors/vectors/gitlab-azure.json +98 -0
- subvectors/vectors/gitlab-gcp.json +119 -0
- subvectors/vectors/schema/vector-suite.schema.json +178 -0
- subvectors/vectors/terraform-aws.json +126 -0
- subvectors/vectors/terraform-azure-flexible.json +98 -0
- subvectors/vectors/terraform-gcp.json +116 -0
- subvectors-0.2.0.dist-info/METADATA +170 -0
- subvectors-0.2.0.dist-info/RECORD +27 -0
- subvectors-0.2.0.dist-info/WHEEL +4 -0
- subvectors-0.2.0.dist-info/licenses/LICENSE +201 -0
subvectors/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Reference matcher and subject grammar for OIDC CI/CD trust-condition
|
|
2
|
+
conformance vectors.
|
|
3
|
+
|
|
4
|
+
The corpus (the ``vectors/`` tree) is the product. This package is only the
|
|
5
|
+
correctness oracle that proves each vector's expected match result is real.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
subvectors/cel.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""A minimal CEL evaluator for GCP Workload Identity Federation attribute_conditions.
|
|
2
|
+
|
|
3
|
+
GCP does not string-match the token subject. A workload-identity-pool provider carries
|
|
4
|
+
an ``attribute_condition`` written in CEL (Common Expression Language); the credential is
|
|
5
|
+
accepted iff that expression evaluates to ``true`` over the token's claims. So the
|
|
6
|
+
correctness oracle for the ``gcp-cel`` consumer is a CEL evaluator, not a string matcher.
|
|
7
|
+
|
|
8
|
+
This implements the small, security-relevant subset of CEL that realistic GitHub -> GCP
|
|
9
|
+
attribute_conditions use, and nothing more (see the "not implemented" note below). The token
|
|
10
|
+
claims are exposed under the ``assertion`` namespace by dot notation, e.g.
|
|
11
|
+
``assertion.repository_owner_id == '1342004'``. A claim whose NAME is not a valid identifier
|
|
12
|
+
(dots or slashes, e.g. CircleCI's ``oidc.circleci.com/project-id``) is addressed by CEL map
|
|
13
|
+
indexing instead: ``assertion['oidc.circleci.com/project-id'] == '...'``.
|
|
14
|
+
|
|
15
|
+
Semantics that are easy to get wrong, pinned to primary sources:
|
|
16
|
+
- The condition is the accept/reject gate: true = accepted, false = rejected.
|
|
17
|
+
https://docs.cloud.google.com/iam/docs/workload-identity-federation
|
|
18
|
+
- ``matches(re)`` is RE2 and matches a SUBSTRING (unanchored) -- so it uses re.search, and a
|
|
19
|
+
pattern must use ^ / $ to anchor. https://github.com/google/cel-spec/blob/master/doc/langdef.md
|
|
20
|
+
- Token claim values relevant here are strings (issuers mint even numeric IDs and protection
|
|
21
|
+
flags as quoted JSON strings, e.g. GitLab's "project_id": "20", "ref_protected": "false"),
|
|
22
|
+
and the CEL JSON mapping keeps a JSON string a CEL string
|
|
23
|
+
(langdef.md#json-data-conversion); comparisons are byte-exact and case-sensitive.
|
|
24
|
+
- Equality across types is CEL "heterogeneous equality": numeric types compare
|
|
25
|
+
mathematically, and any other cross-type comparison is FALSE -- never an error
|
|
26
|
+
(langdef.md#equality, the ``: false`` branch of the spec's own pseudo-code). So
|
|
27
|
+
``assertion.project_id == 20`` is false when the claim is the string "20", and the negation
|
|
28
|
+
``!= 20`` is true for EVERY string value -- the type-level trap the gitlab-gcp vectors pin.
|
|
29
|
+
Python quirk guarded explicitly: bool is an int subclass in Python, but CEL bool is not a
|
|
30
|
+
numeric type, so ``true == 1`` must not fall through to Python's ``True == 1``.
|
|
31
|
+
|
|
32
|
+
Honest scope cut: referencing a claim absent from ``claims`` raises CelError rather than
|
|
33
|
+
evaluating to false, keeping the oracle honest (a vector can never pass by being un-evaluated).
|
|
34
|
+
CEL's production error-absorption through commutative logic is only partially reproduced (via
|
|
35
|
+
Python short-circuit); vectors must supply every claim on an evaluated path.
|
|
36
|
+
|
|
37
|
+
Not implemented (deliberately -- not used in these conditions, and building them would imply
|
|
38
|
+
support we do not verify): ordering comparisons < <= > >=, ternary ?:, string concatenation,
|
|
39
|
+
arithmetic, timestamps, uint/double literals (int literals exist solely so the type-trap
|
|
40
|
+
vectors can express the mistaken ``== 20`` form), macros (.all/.exists/.map/.filter), and the
|
|
41
|
+
extract()/split() extensions that appear in attribute_MAPPING source expressions rather than
|
|
42
|
+
admission conditions.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import re
|
|
48
|
+
|
|
49
|
+
__all__ = ["evaluate", "CelError"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CelError(ValueError):
|
|
53
|
+
"""Raised on a parse error, an unknown function, or a reference to an absent claim."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_TOKEN_RE = re.compile(
|
|
57
|
+
r"(?P<ws>\s+)"
|
|
58
|
+
r"|(?P<str>'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\")"
|
|
59
|
+
r"|(?P<num>\d+)"
|
|
60
|
+
r"|(?P<op>==|!=|&&|\|\||!|\(|\)|\[|\]|,|\.)"
|
|
61
|
+
r"|(?P<ident>[A-Za-z_][A-Za-z0-9_]*)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_STRING_METHODS = frozenset({"startsWith", "endsWith", "contains", "matches"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unescape(literal: str) -> str:
|
|
68
|
+
body = literal[1:-1]
|
|
69
|
+
return body.replace("\\\\", "\\").replace("\\'", "'").replace('\\"', '"')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tokenize(expr: str) -> list[tuple[str, str]]:
|
|
73
|
+
tokens: list[tuple[str, str]] = []
|
|
74
|
+
pos = 0
|
|
75
|
+
while pos < len(expr):
|
|
76
|
+
m = _TOKEN_RE.match(expr, pos)
|
|
77
|
+
if m is None:
|
|
78
|
+
raise CelError(f"unexpected character at offset {pos}: {expr[pos:pos + 12]!r}")
|
|
79
|
+
pos = m.end()
|
|
80
|
+
kind = m.lastgroup
|
|
81
|
+
if kind == "ws":
|
|
82
|
+
continue
|
|
83
|
+
value = m.group()
|
|
84
|
+
tokens.append((kind, _unescape(value) if kind == "str" else value))
|
|
85
|
+
return tokens
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Parser:
|
|
89
|
+
"""Recursive-descent parser. Precedence (loosest -> tightest): || , && , (== != in) , ! ,
|
|
90
|
+
member/method access."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, tokens: list[tuple[str, str]], expr: str) -> None:
|
|
93
|
+
self._toks = tokens
|
|
94
|
+
self._expr = expr
|
|
95
|
+
self._i = 0
|
|
96
|
+
|
|
97
|
+
def _peek(self) -> tuple[str | None, str | None]:
|
|
98
|
+
return self._toks[self._i] if self._i < len(self._toks) else (None, None)
|
|
99
|
+
|
|
100
|
+
def _advance(self) -> tuple[str | None, str | None]:
|
|
101
|
+
tok = self._peek()
|
|
102
|
+
self._i += 1
|
|
103
|
+
return tok
|
|
104
|
+
|
|
105
|
+
def _at_op(self, value: str) -> bool:
|
|
106
|
+
k, v = self._peek()
|
|
107
|
+
return k == "op" and v == value
|
|
108
|
+
|
|
109
|
+
def parse(self):
|
|
110
|
+
node = self._parse_or()
|
|
111
|
+
if self._i != len(self._toks):
|
|
112
|
+
raise CelError(f"unexpected trailing tokens in: {self._expr!r}")
|
|
113
|
+
return node
|
|
114
|
+
|
|
115
|
+
def _parse_or(self):
|
|
116
|
+
node = self._parse_and()
|
|
117
|
+
while self._at_op("||"):
|
|
118
|
+
self._advance()
|
|
119
|
+
node = ("or", node, self._parse_and())
|
|
120
|
+
return node
|
|
121
|
+
|
|
122
|
+
def _parse_and(self):
|
|
123
|
+
node = self._parse_relation()
|
|
124
|
+
while self._at_op("&&"):
|
|
125
|
+
self._advance()
|
|
126
|
+
node = ("and", node, self._parse_relation())
|
|
127
|
+
return node
|
|
128
|
+
|
|
129
|
+
def _parse_relation(self):
|
|
130
|
+
left = self._parse_unary()
|
|
131
|
+
k, v = self._peek()
|
|
132
|
+
if k == "op" and v in ("==", "!="):
|
|
133
|
+
self._advance()
|
|
134
|
+
return (v, left, self._parse_unary())
|
|
135
|
+
if k == "ident" and v == "in":
|
|
136
|
+
self._advance()
|
|
137
|
+
return ("in", left, self._parse_list())
|
|
138
|
+
return left
|
|
139
|
+
|
|
140
|
+
def _parse_unary(self):
|
|
141
|
+
if self._at_op("!"):
|
|
142
|
+
self._advance()
|
|
143
|
+
return ("not", self._parse_unary())
|
|
144
|
+
return self._parse_operand()
|
|
145
|
+
|
|
146
|
+
def _parse_operand(self):
|
|
147
|
+
node = self._parse_primary()
|
|
148
|
+
while self._at_op("."):
|
|
149
|
+
self._advance()
|
|
150
|
+
k, name = self._advance()
|
|
151
|
+
if k != "ident":
|
|
152
|
+
raise CelError("expected a method name after '.'")
|
|
153
|
+
if not self._at_op("("):
|
|
154
|
+
raise CelError(f"unsupported field access .{name} (only method calls follow a value)")
|
|
155
|
+
self._advance() # (
|
|
156
|
+
ak, arg = self._advance()
|
|
157
|
+
if ak != "str":
|
|
158
|
+
raise CelError(f"method {name}() expects a single string-literal argument")
|
|
159
|
+
if not self._at_op(")"):
|
|
160
|
+
raise CelError(f"expected ')' to close {name}(...)")
|
|
161
|
+
self._advance() # )
|
|
162
|
+
node = ("method", node, name, ("str", arg))
|
|
163
|
+
return node
|
|
164
|
+
|
|
165
|
+
def _parse_primary(self):
|
|
166
|
+
k, v = self._peek()
|
|
167
|
+
if k == "ident" and v == "assertion":
|
|
168
|
+
self._advance()
|
|
169
|
+
if self._at_op("."):
|
|
170
|
+
self._advance()
|
|
171
|
+
ck, claim = self._advance()
|
|
172
|
+
if ck != "ident":
|
|
173
|
+
raise CelError("expected a claim name after 'assertion.'")
|
|
174
|
+
return ("claim", claim)
|
|
175
|
+
if self._at_op("["):
|
|
176
|
+
# Map indexing: the only way to address a claim whose NAME contains
|
|
177
|
+
# characters not valid in a CEL identifier (dots, slashes), e.g.
|
|
178
|
+
# CircleCI's 'oidc.circleci.com/project-id'. This is CEL map access
|
|
179
|
+
# by a string key -- GCP's own condition CEL uses the same form for
|
|
180
|
+
# special-character keys (e.g. assertion.attributes['https://.../SAML/...']).
|
|
181
|
+
self._advance() # [
|
|
182
|
+
sk, name = self._advance()
|
|
183
|
+
if sk != "str":
|
|
184
|
+
raise CelError("assertion[...] index must be a single quoted claim name")
|
|
185
|
+
if not self._at_op("]"):
|
|
186
|
+
raise CelError("expected ']' to close assertion[...]")
|
|
187
|
+
self._advance() # ]
|
|
188
|
+
return ("claim", name)
|
|
189
|
+
raise CelError("expected '.<claim>' or ['<claim>'] after 'assertion'")
|
|
190
|
+
if k == "str":
|
|
191
|
+
self._advance()
|
|
192
|
+
return ("str", v)
|
|
193
|
+
if k == "num":
|
|
194
|
+
self._advance()
|
|
195
|
+
value = int(v)
|
|
196
|
+
# CEL int is 64-bit; real CEL rejects an out-of-range literal at
|
|
197
|
+
# parse time, so the oracle must not silently evaluate one.
|
|
198
|
+
if value > 2**63 - 1:
|
|
199
|
+
raise CelError(f"integer literal out of int64 range: {v}")
|
|
200
|
+
return ("int", value)
|
|
201
|
+
if k == "ident" and v in ("true", "false"):
|
|
202
|
+
self._advance()
|
|
203
|
+
return ("bool", v == "true")
|
|
204
|
+
if k == "op" and v == "(":
|
|
205
|
+
self._advance()
|
|
206
|
+
node = self._parse_or()
|
|
207
|
+
if not self._at_op(")"):
|
|
208
|
+
raise CelError("expected ')'")
|
|
209
|
+
self._advance()
|
|
210
|
+
return node
|
|
211
|
+
raise CelError(f"unexpected token {v!r} in: {self._expr!r}")
|
|
212
|
+
|
|
213
|
+
def _parse_list(self):
|
|
214
|
+
if not self._at_op("["):
|
|
215
|
+
raise CelError("expected '[' after 'in'")
|
|
216
|
+
self._advance()
|
|
217
|
+
items = []
|
|
218
|
+
if not self._at_op("]"):
|
|
219
|
+
while True:
|
|
220
|
+
ik, iv = self._advance()
|
|
221
|
+
if ik != "str":
|
|
222
|
+
raise CelError("list literals may contain only string literals")
|
|
223
|
+
items.append(("str", iv))
|
|
224
|
+
if self._at_op(","):
|
|
225
|
+
self._advance()
|
|
226
|
+
continue
|
|
227
|
+
break
|
|
228
|
+
if not self._at_op("]"):
|
|
229
|
+
raise CelError("expected ']'")
|
|
230
|
+
self._advance()
|
|
231
|
+
return items
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _as_bool(value) -> bool:
|
|
235
|
+
if isinstance(value, bool):
|
|
236
|
+
return value
|
|
237
|
+
raise CelError(f"expected a boolean in a logical position, got {type(value).__name__}: {value!r}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _cel_equal(a, b) -> bool:
|
|
241
|
+
"""CEL runtime heterogeneous equality (langdef.md#equality).
|
|
242
|
+
|
|
243
|
+
Numeric types compare mathematically on a continuous number line (int is the
|
|
244
|
+
only numeric type implemented here); any other cross-type comparison is
|
|
245
|
+
false, never an error. bool is checked FIRST because Python's bool is an int
|
|
246
|
+
subclass, while CEL's bool is not numeric: ``true == 1`` is false in CEL but
|
|
247
|
+
``True == 1`` is True in Python.
|
|
248
|
+
"""
|
|
249
|
+
a_is_bool, b_is_bool = isinstance(a, bool), isinstance(b, bool)
|
|
250
|
+
if a_is_bool or b_is_bool:
|
|
251
|
+
return a_is_bool and b_is_bool and a is b
|
|
252
|
+
if isinstance(a, int) and isinstance(b, int):
|
|
253
|
+
return a == b
|
|
254
|
+
if type(a) is not type(b):
|
|
255
|
+
return False
|
|
256
|
+
return a == b
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _method(name: str, receiver, arg) -> bool:
|
|
260
|
+
if name not in _STRING_METHODS:
|
|
261
|
+
raise CelError(f"unsupported function {name}()")
|
|
262
|
+
if not isinstance(receiver, str) or not isinstance(arg, str):
|
|
263
|
+
raise CelError(f"{name}() operates on strings")
|
|
264
|
+
if name == "startsWith":
|
|
265
|
+
return receiver.startswith(arg)
|
|
266
|
+
if name == "endsWith":
|
|
267
|
+
return receiver.endswith(arg)
|
|
268
|
+
if name == "contains":
|
|
269
|
+
return arg in receiver
|
|
270
|
+
# matches(): RE2, substring semantics -> re.search, not fullmatch.
|
|
271
|
+
try:
|
|
272
|
+
return re.search(arg, receiver) is not None
|
|
273
|
+
except re.error as exc:
|
|
274
|
+
raise CelError(f"invalid regex in matches(): {exc}") from exc
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _eval(node, claims: dict) -> object:
|
|
278
|
+
kind = node[0]
|
|
279
|
+
if kind == "str":
|
|
280
|
+
return node[1]
|
|
281
|
+
if kind == "bool":
|
|
282
|
+
return node[1]
|
|
283
|
+
if kind == "int":
|
|
284
|
+
return node[1]
|
|
285
|
+
if kind == "claim":
|
|
286
|
+
name = node[1]
|
|
287
|
+
if name not in claims:
|
|
288
|
+
raise CelError(
|
|
289
|
+
f"condition references assertion.{name} but the token has no such claim"
|
|
290
|
+
)
|
|
291
|
+
return claims[name]
|
|
292
|
+
if kind == "not":
|
|
293
|
+
return not _as_bool(_eval(node[1], claims))
|
|
294
|
+
if kind == "and":
|
|
295
|
+
return _as_bool(_eval(node[1], claims)) and _as_bool(_eval(node[2], claims))
|
|
296
|
+
if kind == "or":
|
|
297
|
+
return _as_bool(_eval(node[1], claims)) or _as_bool(_eval(node[2], claims))
|
|
298
|
+
if kind in ("==", "!="):
|
|
299
|
+
equal = _cel_equal(_eval(node[1], claims), _eval(node[2], claims))
|
|
300
|
+
return equal if kind == "==" else not equal
|
|
301
|
+
if kind == "in":
|
|
302
|
+
needle = _eval(node[1], claims)
|
|
303
|
+
return any(_cel_equal(needle, _eval(item, claims)) for item in node[2])
|
|
304
|
+
if kind == "method":
|
|
305
|
+
return _method(node[2], _eval(node[1], claims), _eval(node[3], claims))
|
|
306
|
+
raise CelError(f"internal: unknown node {kind!r}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def evaluate(expression: str, claims: dict) -> bool:
|
|
310
|
+
"""Evaluate a GCP WIF ``attribute_condition`` CEL expression against a token's claims.
|
|
311
|
+
|
|
312
|
+
``claims`` maps raw claim names to string values (addressable as ``assertion.<name>``).
|
|
313
|
+
Returns the boolean admission decision. Raises :class:`CelError` on any parse error,
|
|
314
|
+
unknown function, non-boolean result, or reference to a claim absent from ``claims``.
|
|
315
|
+
"""
|
|
316
|
+
if not isinstance(expression, str):
|
|
317
|
+
raise CelError("expression must be a string")
|
|
318
|
+
tokens = _tokenize(expression)
|
|
319
|
+
if not tokens:
|
|
320
|
+
raise CelError("empty expression")
|
|
321
|
+
ast = _Parser(tokens, expression).parse()
|
|
322
|
+
return _as_bool(_eval(ast, claims))
|
subvectors/corpus.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Access to the vector corpus itself — the product this package exists to carry.
|
|
2
|
+
|
|
3
|
+
The wheel force-includes the repository's ``vectors/`` tree at
|
|
4
|
+
``subvectors/vectors`` (see ``[tool.hatch.build.targets.wheel.force-include]``),
|
|
5
|
+
so an installed consumer can pin a versioned corpus instead of hand-vendoring
|
|
6
|
+
JSON files. In a source checkout the same tree lives at the repository root;
|
|
7
|
+
the loader serves both layouts so the test suite and an installed wheel read
|
|
8
|
+
identical bytes.
|
|
9
|
+
|
|
10
|
+
Zero runtime dependencies, like everything else here: consumers keep their own
|
|
11
|
+
matching code and load these vectors at test time.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from importlib.resources.abc import Traversable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _vectors_root() -> Traversable | Path:
|
|
23
|
+
packaged = resources.files(__package__) / "vectors"
|
|
24
|
+
if packaged.is_dir():
|
|
25
|
+
return packaged
|
|
26
|
+
checkout = Path(__file__).resolve().parents[2] / "vectors"
|
|
27
|
+
if checkout.is_dir():
|
|
28
|
+
return checkout
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
"vector corpus not found: neither packaged subvectors/vectors nor a "
|
|
31
|
+
"repository-root vectors/ directory exists"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def suite_names() -> list[str]:
|
|
36
|
+
"""Sorted names of every suite in the corpus (file stems of vectors/*.json)."""
|
|
37
|
+
return sorted(
|
|
38
|
+
entry.name[: -len(".json")]
|
|
39
|
+
for entry in _vectors_root().iterdir()
|
|
40
|
+
if entry.name.endswith(".json")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_suite(name: str) -> dict:
|
|
45
|
+
"""One suite by name, parsed. Raises FileNotFoundError for unknown names."""
|
|
46
|
+
suite = _vectors_root() / f"{name}.json"
|
|
47
|
+
if not suite.is_file():
|
|
48
|
+
raise FileNotFoundError(f"no such suite: {name!r} (see suite_names())")
|
|
49
|
+
return json.loads(suite.read_text(encoding="utf-8"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_schema() -> dict:
|
|
53
|
+
"""The JSON Schema every suite file conforms to (vectors/schema/)."""
|
|
54
|
+
schema = _vectors_root() / "schema" / "vector-suite.schema.json"
|
|
55
|
+
return json.loads(schema.read_text(encoding="utf-8"))
|
subvectors/ffl.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""A minimal evaluator for Azure Entra "flexible federated identity credential"
|
|
2
|
+
matching expressions -- the ``azure-fic-flexible`` consumer. PREVIEW feature.
|
|
3
|
+
|
|
4
|
+
Classic Azure FIC (azure-fic-exact) compares the token ``sub`` to a fixed string.
|
|
5
|
+
Flexible FIC replaces the ``subject`` property with a ``claimsMatchingExpression``
|
|
6
|
+
object ``{value, languageVersion}`` (the two are mutually exclusive; ``languageVersion``
|
|
7
|
+
is always ``1``). The credential is accepted iff ``value`` -- a restricted expression
|
|
8
|
+
in Microsoft's "flexible federated identity credential expression language" -- evaluates
|
|
9
|
+
to true over the token's claims. So the correctness oracle is a small expression
|
|
10
|
+
evaluator, not a string matcher.
|
|
11
|
+
|
|
12
|
+
Grammar (the whole language -- there is nothing else):
|
|
13
|
+
|
|
14
|
+
expression := clause ( 'and' clause )*
|
|
15
|
+
clause := "claims['" NAME "']" SP operator SP "'" comparand "'"
|
|
16
|
+
operator := 'eq' | 'matches'
|
|
17
|
+
|
|
18
|
+
- ``claims['<name>']`` is the claim lookup; each part is separated by a single space.
|
|
19
|
+
- ``eq`` is exact, case-sensitive string equality against the named claim.
|
|
20
|
+
- ``matches`` is wildcard matching: ``?`` matches a single character, ``*`` matches
|
|
21
|
+
multiple characters. Modeled here as an ANCHORED full-claim match (the entire claim
|
|
22
|
+
value must match the pattern) -- every documented example is a full-subject pattern,
|
|
23
|
+
and a substring match would make the control meaningless. ``*`` -> ``.*`` and ``?``
|
|
24
|
+
-> ``.``, so like AWS StringLike the wildcards are not path-aware (they span ``/``
|
|
25
|
+
and ``:``). Case sensitivity and zero-width ``*`` follow the classic-FIC / AWS
|
|
26
|
+
reading where the preview doc is silent ("multi-character", not "zero or more").
|
|
27
|
+
- ``and`` is the only boolean combinator (no ``or``, no parentheses).
|
|
28
|
+
- Single quotes escape by doubling (``''`` -> a literal ``'``).
|
|
29
|
+
|
|
30
|
+
Per-issuer support (the token claims an expression may reference), from the same page:
|
|
31
|
+
GitHub -> ``sub`` and ``job_workflow_ref``; GitLab -> ``sub`` only; Terraform Cloud ->
|
|
32
|
+
``sub`` only. Flexible FIC is application-object-only and configurable via Microsoft
|
|
33
|
+
Graph or the Azure portal only (no CLI/PowerShell/Terraform provider surface yet).
|
|
34
|
+
|
|
35
|
+
Honest scope cut (as in cel.py): referencing a claim absent from ``claims`` raises
|
|
36
|
+
FflError rather than evaluating to false, so a vector can never pass by being
|
|
37
|
+
un-evaluated. This evaluator does NOT enforce the per-issuer claim/operator allow-list
|
|
38
|
+
(that is a configuration-validity concern, not a match-semantics one) or the
|
|
39
|
+
subject/claimsMatchingExpression mutual exclusion; it evaluates a well-formed
|
|
40
|
+
``value`` expression against a claim set.
|
|
41
|
+
|
|
42
|
+
Source (preview; page updated 2026-06-15):
|
|
43
|
+
https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-flexible-federated-identity-credentials
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import re
|
|
49
|
+
|
|
50
|
+
__all__ = ["evaluate", "FflError"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FflError(ValueError):
|
|
54
|
+
"""Raised on a parse error, an unknown operator, or a reference to an absent claim."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_TOKEN_RE = re.compile(
|
|
58
|
+
r"(?P<ws>\s+)"
|
|
59
|
+
r"|(?P<str>'(?:[^']|'')*')"
|
|
60
|
+
r"|(?P<lbracket>\[)"
|
|
61
|
+
r"|(?P<rbracket>\])"
|
|
62
|
+
r"|(?P<ident>[A-Za-z_][A-Za-z0-9_]*)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_OPERATORS = frozenset({"eq", "matches"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tokenize(expr: str) -> list[tuple[str, str]]:
|
|
69
|
+
tokens: list[tuple[str, str]] = []
|
|
70
|
+
pos = 0
|
|
71
|
+
while pos < len(expr):
|
|
72
|
+
m = _TOKEN_RE.match(expr, pos)
|
|
73
|
+
if m is None:
|
|
74
|
+
raise FflError(f"unexpected character at offset {pos}: {expr[pos:pos + 12]!r}")
|
|
75
|
+
pos = m.end()
|
|
76
|
+
kind = m.lastgroup
|
|
77
|
+
if kind == "ws":
|
|
78
|
+
continue
|
|
79
|
+
value = m.group()
|
|
80
|
+
if kind == "str":
|
|
81
|
+
value = value[1:-1].replace("''", "'")
|
|
82
|
+
tokens.append((kind, value))
|
|
83
|
+
return tokens
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _matches(value: str, pattern: str) -> bool:
|
|
87
|
+
"""``matches``: anchored, case-sensitive glob. ``*`` -> ``.*``, ``?`` -> ``.``."""
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
for ch in pattern:
|
|
90
|
+
if ch == "*":
|
|
91
|
+
out.append(".*")
|
|
92
|
+
elif ch == "?":
|
|
93
|
+
out.append(".")
|
|
94
|
+
else:
|
|
95
|
+
out.append(re.escape(ch))
|
|
96
|
+
return re.compile("".join(out)).fullmatch(value) is not None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Parser:
|
|
100
|
+
def __init__(self, tokens: list[tuple[str, str]], claims: dict, expr: str) -> None:
|
|
101
|
+
self._toks = tokens
|
|
102
|
+
self._claims = claims
|
|
103
|
+
self._expr = expr
|
|
104
|
+
self._i = 0
|
|
105
|
+
|
|
106
|
+
def _peek(self) -> tuple[str | None, str | None]:
|
|
107
|
+
return self._toks[self._i] if self._i < len(self._toks) else (None, None)
|
|
108
|
+
|
|
109
|
+
def _advance(self) -> tuple[str | None, str | None]:
|
|
110
|
+
tok = self._peek()
|
|
111
|
+
self._i += 1
|
|
112
|
+
return tok
|
|
113
|
+
|
|
114
|
+
def parse(self) -> bool:
|
|
115
|
+
result = self._clause()
|
|
116
|
+
while True:
|
|
117
|
+
k, v = self._peek()
|
|
118
|
+
if k is None:
|
|
119
|
+
break
|
|
120
|
+
if k == "ident" and v == "and":
|
|
121
|
+
self._advance()
|
|
122
|
+
# Every clause is parsed (tokens consumed) before combining, so a
|
|
123
|
+
# later invalid clause is a parse error, not silently short-circuited.
|
|
124
|
+
clause_value = self._clause()
|
|
125
|
+
result = result and clause_value
|
|
126
|
+
else:
|
|
127
|
+
raise FflError(f"expected 'and' or end of expression, got {v!r} in: {self._expr!r}")
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
def _clause(self) -> bool:
|
|
131
|
+
k, v = self._advance()
|
|
132
|
+
if not (k == "ident" and v == "claims"):
|
|
133
|
+
raise FflError(f"expected a claims[...] lookup, got {v!r}")
|
|
134
|
+
k, _ = self._advance()
|
|
135
|
+
if k != "lbracket":
|
|
136
|
+
raise FflError("expected '[' after 'claims'")
|
|
137
|
+
k, name = self._advance()
|
|
138
|
+
if k != "str":
|
|
139
|
+
raise FflError("expected a quoted claim name in claims['...']")
|
|
140
|
+
k, _ = self._advance()
|
|
141
|
+
if k != "rbracket":
|
|
142
|
+
raise FflError("expected ']' after the claim name")
|
|
143
|
+
k, op = self._advance()
|
|
144
|
+
if k != "ident" or op not in _OPERATORS:
|
|
145
|
+
raise FflError(f"unsupported operator {op!r}; expected 'eq' or 'matches'")
|
|
146
|
+
k, comparand = self._advance()
|
|
147
|
+
if k != "str":
|
|
148
|
+
raise FflError(f"operator {op} expects a single-quoted comparand")
|
|
149
|
+
if name not in self._claims:
|
|
150
|
+
raise FflError(
|
|
151
|
+
f"expression references claims['{name}'] but the token has no such claim"
|
|
152
|
+
)
|
|
153
|
+
value = self._claims[name]
|
|
154
|
+
if op == "eq":
|
|
155
|
+
return value == comparand
|
|
156
|
+
return _matches(value, comparand)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def evaluate(expression: str, claims: dict) -> bool:
|
|
160
|
+
"""Evaluate a flexible-FIC ``claimsMatchingExpression`` value against a claim set.
|
|
161
|
+
|
|
162
|
+
``claims`` maps raw claim names to string values (addressable as
|
|
163
|
+
``claims['<name>']``). Returns the boolean admission decision. Raises
|
|
164
|
+
:class:`FflError` on any parse error, unknown operator, or reference to a claim
|
|
165
|
+
absent from ``claims``.
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(expression, str):
|
|
168
|
+
raise FflError("expression must be a string")
|
|
169
|
+
tokens = _tokenize(expression)
|
|
170
|
+
if not tokens:
|
|
171
|
+
raise FflError("empty expression")
|
|
172
|
+
return _Parser(tokens, claims, expression).parse()
|
subvectors/github.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""GitHub Actions OIDC subject grammar (the leading ``repo:`` segment).
|
|
2
|
+
|
|
3
|
+
Recognizes the owner/repo segment of a GitHub OIDC token subject in BOTH forms:
|
|
4
|
+
|
|
5
|
+
classic repo:octo-org/octo-repo:ref:refs/heads/main
|
|
6
|
+
immutable repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
|
|
7
|
+
|
|
8
|
+
Immutable subjects carry an appended numeric ID on the owner and repo so the
|
|
9
|
+
claim survives a rename. They become mandatory for repositories created after
|
|
10
|
+
2026-07-15. A parser that understands only the classic form silently rejects
|
|
11
|
+
every new repository's token -- the exact defect this project exists to catch
|
|
12
|
+
(e.g. Checkov's gh_repo_regex, which has no ``@`` in its character class).
|
|
13
|
+
|
|
14
|
+
This operates on concrete *subjects*, not trust-policy *patterns*: a value
|
|
15
|
+
containing ``*`` or ``?`` is a wildcard condition, not a minted subject, so it
|
|
16
|
+
is not a valid subject here and returns None.
|
|
17
|
+
|
|
18
|
+
Sources:
|
|
19
|
+
- Immutable subject claims:
|
|
20
|
+
https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/
|
|
21
|
+
- OIDC subject claim reference:
|
|
22
|
+
https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
__all__ = ["RepoSegment", "parse_repo_segment"]
|
|
31
|
+
|
|
32
|
+
# repo:OWNER[@ownerid]/REPO[@repoid]: -- a concrete subject always has a
|
|
33
|
+
# suffix claim after the repo, so the trailing ':' is required. Owner/repo
|
|
34
|
+
# names exclude the '/', '@', ':' delimiters and the '*'/'?' wildcard chars.
|
|
35
|
+
_REPO_RE = re.compile(
|
|
36
|
+
r"^repo:"
|
|
37
|
+
r"(?P<owner>[^/@:*?]+)(?:@(?P<owner_id>\d+))?"
|
|
38
|
+
r"/"
|
|
39
|
+
r"(?P<repo>[^/@:*?]+)(?:@(?P<repo_id>\d+))?"
|
|
40
|
+
r":"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RepoSegment:
|
|
46
|
+
"""The parsed owner/repo prefix of a GitHub OIDC subject."""
|
|
47
|
+
|
|
48
|
+
owner: str
|
|
49
|
+
repo: str
|
|
50
|
+
owner_id: str | None
|
|
51
|
+
repo_id: str | None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def immutable(self) -> bool:
|
|
55
|
+
"""True when the subject carries embedded owner AND repo IDs.
|
|
56
|
+
|
|
57
|
+
GitHub appends ``@id`` to both segments or to neither, so a one-sided
|
|
58
|
+
subject (``owner@123/repo`` or ``owner/repo@456``) is *malformed* --
|
|
59
|
+
a shape GitHub never mints -- and is not immutable. The parsed
|
|
60
|
+
``owner_id``/``repo_id`` still report whichever id was present.
|
|
61
|
+
"""
|
|
62
|
+
return self.owner_id is not None and self.repo_id is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_repo_segment(subject: str) -> RepoSegment | None:
|
|
66
|
+
"""Parse the leading ``repo:owner/repo:`` segment of a GitHub subject.
|
|
67
|
+
|
|
68
|
+
Returns a :class:`RepoSegment`, or None if ``subject`` is not a
|
|
69
|
+
``repo:``-scoped concrete subject (e.g. a wildcard pattern, or a subject
|
|
70
|
+
scoped by a different leading claim such as ``repository_owner:``).
|
|
71
|
+
"""
|
|
72
|
+
m = _REPO_RE.match(subject)
|
|
73
|
+
if m is None:
|
|
74
|
+
return None
|
|
75
|
+
return RepoSegment(
|
|
76
|
+
owner=m.group("owner"),
|
|
77
|
+
repo=m.group("repo"),
|
|
78
|
+
owner_id=m.group("owner_id"),
|
|
79
|
+
repo_id=m.group("repo_id"),
|
|
80
|
+
)
|