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 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
+ )