sourcecode 2.7.0__py3-none-any.whl → 3.0.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.
sourcecode/posture.py ADDED
@@ -0,0 +1,458 @@
1
+ """Which beans are actually wired under a profile set — and which cannot be decided.
2
+
3
+ Profile *detection* already ships: `@Profile`, negations, expressions,
4
+ `spring.config.activate.on-profile`. What was missing is the consumer. "We saw a
5
+ profile" is a fact about a file; "under `prod`, these beans are registered and
6
+ these four conditions could not be resolved" is a fact about the running system,
7
+ and only the second one answers the question a reviewer actually has.
8
+
9
+ Resolution is deliberately partial and says so. A bean whose registration
10
+ depends on a property value, an environment lookup, or a condition class we
11
+ cannot evaluate is reported as **unresolved**, never assumed active and never
12
+ assumed absent. A posture answer that guesses is worse than no posture answer:
13
+ it is a confident security falsehood, which is the failure mode this whole
14
+ kernel exists to prevent.
15
+
16
+ Supported today:
17
+ * `@Profile("a")`, `@Profile({"a","b"})` (OR), `@Profile("!a")`
18
+ * expressions over those with `&`, `|`, `!` and parentheses
19
+
20
+ Everything else — `@ConditionalOnProperty` and the rest of the `@Conditional*`
21
+ family — lands in `unresolved` with the condition named.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Any, Optional
30
+
31
+ if TYPE_CHECKING:
32
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
33
+
34
+ POSTURE_SCHEMA = "posture-v1"
35
+
36
+ #: Conditions we can see but not decide. Named so the report can say which one
37
+ #: blocked resolution instead of dropping the bean silently.
38
+ _UNRESOLVABLE_CONDITIONS = (
39
+ "@ConditionalOnProperty",
40
+ "@ConditionalOnExpression",
41
+ "@ConditionalOnBean",
42
+ "@ConditionalOnMissingBean",
43
+ "@ConditionalOnClass",
44
+ "@ConditionalOnMissingClass",
45
+ "@ConditionalOnResource",
46
+ "@ConditionalOnWebApplication",
47
+ "@ConditionalOnNotWebApplication",
48
+ "@ConditionalOnJava",
49
+ "@ConditionalOnCloudPlatform",
50
+ "@Conditional",
51
+ )
52
+
53
+ _TOKEN_RE = re.compile(r'"([^"]*)"|([A-Za-z0-9_.\-]+)|([()&|!])')
54
+
55
+
56
+ class ProfileExpressionError(ValueError):
57
+ """The expression could not be evaluated — the bean is reported unresolved."""
58
+
59
+
60
+ def _tokenize(expression: str) -> "list[str]":
61
+ tokens: list[str] = []
62
+ for quoted, bare, op in _TOKEN_RE.findall(expression):
63
+ text = quoted if quoted else (bare or op)
64
+ if quoted:
65
+ # A quoted argument may itself be an expression: "a & !b".
66
+ inner = _tokenize(text) if any(c in text for c in "&|!()") else [text]
67
+ if tokens and tokens[-1] not in ("&", "|", "!", "("):
68
+ tokens.append("|") # {"a","b"} — array elements are OR'd
69
+ tokens.extend(inner)
70
+ elif text.strip():
71
+ tokens.append(text.strip())
72
+ return [t for t in tokens if t and t != ","]
73
+
74
+
75
+ def evaluate_profile_expression(expression: str, active: "set[str]") -> bool:
76
+ """Spring `@Profile` semantics: array is OR, `!` negates, `&`/`|` combine.
77
+
78
+ Raises ProfileExpressionError when the expression is not a form we evaluate,
79
+ so the caller reports the bean as unresolved rather than deciding by guess.
80
+ """
81
+ tokens = _tokenize(expression)
82
+ if not tokens:
83
+ raise ProfileExpressionError("empty profile expression")
84
+ pos = 0
85
+
86
+ def parse_or() -> bool:
87
+ nonlocal pos
88
+ value = parse_and()
89
+ while pos < len(tokens) and tokens[pos] == "|":
90
+ pos += 1
91
+ value = parse_and() or value
92
+ return value
93
+
94
+ def parse_and() -> bool:
95
+ nonlocal pos
96
+ value = parse_unary()
97
+ while pos < len(tokens) and tokens[pos] == "&":
98
+ pos += 1
99
+ value = parse_unary() and value
100
+ return value
101
+
102
+ def parse_unary() -> bool:
103
+ nonlocal pos
104
+ if pos < len(tokens) and tokens[pos] == "!":
105
+ pos += 1
106
+ return not parse_unary()
107
+ if pos < len(tokens) and tokens[pos] == "(":
108
+ pos += 1
109
+ value = parse_or()
110
+ if pos >= len(tokens) or tokens[pos] != ")":
111
+ raise ProfileExpressionError(f"unbalanced parentheses in {expression!r}")
112
+ pos += 1
113
+ return value
114
+ if pos >= len(tokens):
115
+ raise ProfileExpressionError(f"expression ends early: {expression!r}")
116
+ name = tokens[pos]
117
+ if name in ("&", "|", ")"):
118
+ raise ProfileExpressionError(f"unexpected {name!r} in {expression!r}")
119
+ pos += 1
120
+ return name in active
121
+
122
+ result = parse_or()
123
+ if pos != len(tokens):
124
+ raise ProfileExpressionError(f"trailing tokens in {expression!r}")
125
+ return result
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class BeanResolution:
130
+ """What a profile set implies for the bean graph, and what it does not."""
131
+
132
+ profiles: tuple[str, ...]
133
+ active: tuple[dict, ...] = ()
134
+ inactive: tuple[dict, ...] = ()
135
+ unresolved: tuple[dict, ...] = ()
136
+ unconditional_count: int = 0
137
+
138
+ def to_dict(self) -> dict:
139
+ return {
140
+ "profiles": list(self.profiles),
141
+ "summary": {
142
+ "conditional_beans": len(self.active) + len(self.inactive) + len(self.unresolved),
143
+ "active": len(self.active),
144
+ "inactive": len(self.inactive),
145
+ "unresolved": len(self.unresolved),
146
+ "unconditional": self.unconditional_count,
147
+ },
148
+ "active": list(self.active),
149
+ "inactive": list(self.inactive),
150
+ # Never folded into active or inactive: a condition we cannot decide
151
+ # is a hole in the answer, and the reader is told where it is.
152
+ "unresolved": list(self.unresolved),
153
+ }
154
+
155
+
156
+ def _node_annotations(node: dict) -> "list[str]":
157
+ raw = node.get("annotations") or []
158
+ return [str(a) for a in raw] if isinstance(raw, list) else []
159
+
160
+
161
+ def _unresolvable_conditions(node: dict) -> "list[str]":
162
+ present: list[str] = []
163
+ for annotation in _node_annotations(node):
164
+ simple = annotation.split("(", 1)[0].strip()
165
+ if simple in _UNRESOLVABLE_CONDITIONS:
166
+ present.append(simple)
167
+ return sorted(set(present))
168
+
169
+
170
+ def resolve_beans(
171
+ cir: "CanonicalRepositoryIR", profiles: "Optional[set[str]]" = None
172
+ ) -> BeanResolution:
173
+ """Resolve the conditional bean graph against an active profile set."""
174
+ from sourcecode.security_posture import _graph_nodes
175
+ from sourcecode.spring_profiles import profile_conditional_beans
176
+
177
+ active_profiles = set(profiles or set())
178
+ conditional = {b.symbol: b for b in profile_conditional_beans(cir)}
179
+
180
+ active: list[dict] = []
181
+ inactive: list[dict] = []
182
+ unresolved: list[dict] = []
183
+ unconditional = 0
184
+
185
+ for node in _graph_nodes(cir):
186
+ if not isinstance(node, dict):
187
+ continue
188
+ fqn = str(node.get("fqn") or "")
189
+ if not fqn or "#" in fqn:
190
+ continue # members follow their declaring type
191
+ blockers = _unresolvable_conditions(node)
192
+ bean = conditional.get(fqn)
193
+ if bean is None and not blockers:
194
+ unconditional += 1
195
+ continue
196
+ entry = {"symbol": fqn, "source_file": str(node.get("source_file") or "")}
197
+ if blockers:
198
+ unresolved.append({
199
+ **entry,
200
+ "condition": blockers[0] if len(blockers) == 1 else ", ".join(blockers),
201
+ "reason": "registration depends on a condition this analyzer does not evaluate",
202
+ })
203
+ continue
204
+ assert bean is not None
205
+ expression = bean.expression or " | ".join(f'"{p}"' for p in bean.profiles)
206
+ try:
207
+ is_active = evaluate_profile_expression(expression, active_profiles)
208
+ except ProfileExpressionError as exc:
209
+ unresolved.append({
210
+ **entry, "condition": f"@Profile({expression})", "reason": str(exc),
211
+ })
212
+ continue
213
+ (active if is_active else inactive).append({
214
+ **entry, "condition": f"@Profile({expression})",
215
+ })
216
+
217
+ key = lambda e: e["symbol"] # noqa: E731 — deterministic ordering, nothing else
218
+ return BeanResolution(
219
+ profiles=tuple(sorted(active_profiles)),
220
+ active=tuple(sorted(active, key=key)),
221
+ inactive=tuple(sorted(inactive, key=key)),
222
+ unresolved=tuple(sorted(unresolved, key=key)),
223
+ unconditional_count=unconditional,
224
+ )
225
+
226
+
227
+ #: Published Spring Security vocabulary that makes a bean part of the request
228
+ #: chain's authorization configuration. Recorded as evidence; never branched on
229
+ #: a client's own class name.
230
+ _SECURITY_CONFIG_ANNOTATIONS = frozenset({
231
+ "@EnableWebSecurity", "@EnableMethodSecurity", "@EnableGlobalMethodSecurity",
232
+ "@EnableReactiveMethodSecurity", "@EnableWebFluxSecurity",
233
+ })
234
+ _SECURITY_CONFIG_SUPERTYPES = frozenset({
235
+ "WebSecurityConfigurerAdapter", "SecurityFilterChain",
236
+ "AbstractSecurityInterceptor", "OncePerRequestFilter",
237
+ })
238
+
239
+
240
+ def _is_security_relevant(node: dict, cir: "CanonicalRepositoryIR") -> bool:
241
+ """True when this bean configures the request chain's authorization.
242
+
243
+ Structure only: a published security annotation, or a supertype from the
244
+ request-chain SPI. Which profile switches the security configuration is the
245
+ single most consequential thing a profile can change, so this projection
246
+ exists to make that switch visible rather than buried in a bean count.
247
+ """
248
+ for annotation in _node_annotations(node):
249
+ if annotation.split("(", 1)[0].strip() in _SECURITY_CONFIG_ANNOTATIONS:
250
+ return True
251
+ supertypes = node.get("supertypes") or node.get("extends") or []
252
+ if isinstance(supertypes, str):
253
+ supertypes = [supertypes]
254
+ if isinstance(supertypes, list):
255
+ for base in supertypes:
256
+ if str(base).rsplit(".", 1)[-1] in _SECURITY_CONFIG_SUPERTYPES:
257
+ return True
258
+ # A @Bean method returning SecurityFilterChain lives on the declaring class.
259
+ fqn = str(node.get("fqn") or "")
260
+ for member in _graph_nodes_cached(cir):
261
+ if not isinstance(member, dict):
262
+ continue
263
+ member_fqn = str(member.get("fqn") or "")
264
+ if not member_fqn.startswith(f"{fqn}#"):
265
+ continue
266
+ return_type = str(member.get("return_type") or "").rsplit(".", 1)[-1]
267
+ if return_type in _SECURITY_CONFIG_SUPERTYPES:
268
+ return True
269
+ return False
270
+
271
+
272
+ def _graph_nodes_cached(cir: "CanonicalRepositoryIR") -> "list[dict]":
273
+ cached = getattr(cir, "_posture_nodes", None)
274
+ if cached is not None:
275
+ return cached
276
+ from sourcecode.security_posture import _graph_nodes
277
+
278
+ nodes = list(_graph_nodes(cir))
279
+ try:
280
+ object.__setattr__(cir, "_posture_nodes", nodes)
281
+ except Exception:
282
+ pass
283
+ return nodes
284
+
285
+
286
+ def security_projection(cir: "CanonicalRepositoryIR", resolution: BeanResolution) -> dict:
287
+ """The security-relevant slice of a bean resolution."""
288
+ by_fqn = {
289
+ str(n.get("fqn") or ""): n for n in _graph_nodes_cached(cir) if isinstance(n, dict)
290
+ }
291
+
292
+ def _slice(entries: "tuple[dict, ...]") -> "list[dict]":
293
+ out = []
294
+ for entry in entries:
295
+ node = by_fqn.get(entry["symbol"])
296
+ if node is not None and _is_security_relevant(node, cir):
297
+ out.append(entry)
298
+ return out
299
+
300
+ active = _slice(resolution.active)
301
+ inactive = _slice(resolution.inactive)
302
+ unresolved = _slice(resolution.unresolved)
303
+ return {
304
+ "active": active,
305
+ "inactive": inactive,
306
+ "unresolved": unresolved,
307
+ "summary": {
308
+ "active": len(active), "inactive": len(inactive), "unresolved": len(unresolved),
309
+ },
310
+ }
311
+
312
+
313
+ #: Policies that mean "this handler carries no guard of its own" — everything
314
+ #: else is one. Empty covers an endpoint with no security record at all.
315
+ _NO_HANDLER_GUARD_POLICIES = frozenset({
316
+ "", "none_detected", "programmatic", "xml_or_filter_chain",
317
+ })
318
+
319
+
320
+ def endpoint_projection(cir: "CanonicalRepositoryIR", security: dict) -> dict:
321
+ """What the resolved security configuration means for the HTTP surface.
322
+
323
+ An endpoint carrying its own guard (`@PreAuthorize`, `@RolesAllowed`, …) is
324
+ protected regardless of which profile is active. An endpoint without one
325
+ depends on the request chain — so when a profile set activates no chain
326
+ configuration at all, those endpoints are the ones a reviewer must be told
327
+ about by name, not left to derive from a bean list.
328
+
329
+ The claim is only made when the repository *has* security configuration that
330
+ this profile set switched off. A repository that never configured security
331
+ has a different problem, and saying "unprotected under prod" about it would
332
+ blame the profile for something the profile does not do.
333
+ """
334
+ guarded: list[str] = []
335
+ chain_dependent: list[dict] = []
336
+ for endpoint in getattr(cir, "endpoints", []) or []:
337
+ policy = getattr(getattr(endpoint, "security", None), "policy", None) or ""
338
+ entry = {"id": endpoint.id, "method": endpoint.method, "path": endpoint.path}
339
+ # Complement, not an allow-list: any policy other than the three that
340
+ # mean "no guard on this handler" IS a guard. Enumerating guard names
341
+ # instead would silently reclassify an endpoint as exposed the day a new
342
+ # guard vocabulary is recognised.
343
+ if policy in _NO_HANDLER_GUARD_POLICIES:
344
+ chain_dependent.append(entry)
345
+ else:
346
+ guarded.append(endpoint.id)
347
+
348
+ out: dict = {
349
+ "total": len(guarded) + len(chain_dependent),
350
+ "guarded_by_own_annotation": len(guarded),
351
+ "depends_on_request_chain": len(chain_dependent),
352
+ }
353
+ configures_chain = bool(security["active"])
354
+ switched_off = bool(security["inactive"]) and not configures_chain
355
+ if switched_off and chain_dependent:
356
+ out["unguarded_under_this_profile_set"] = [e["id"] for e in chain_dependent[:200]]
357
+ out["statement"] = (
358
+ f"{len(chain_dependent)} endpoint(s) carry no per-method guard, and this "
359
+ "profile set activates none of the repository's request-chain configuration"
360
+ )
361
+ if security["unresolved"]:
362
+ out["caveat"] = (
363
+ f"{len(security['unresolved'])} security configuration bean(s) could not be "
364
+ "resolved; the statement above does not account for what they would wire"
365
+ )
366
+ return out
367
+
368
+
369
+ def build_posture(root: Path, profiles: "Optional[set[str]]" = None) -> dict:
370
+ """`posture` payload for one profile set."""
371
+ from sourcecode.context_graph import ContextGraph
372
+ from sourcecode.repository_ir import find_java_files
373
+ from sourcecode.spring_profiles import profiles_from_annotations, profiles_from_config
374
+
375
+ root = Path(root).resolve()
376
+ files = find_java_files(root)
377
+ cir = ContextGraph.build(files, root).cir
378
+ resolution = resolve_beans(cir, profiles)
379
+
380
+ declared = sorted(set(profiles_from_config(root)) | set(profiles_from_annotations(root, files)))
381
+ payload: dict = {
382
+ "schema_version": POSTURE_SCHEMA,
383
+ "profiles_requested": sorted(profiles or set()),
384
+ "profiles_declared": declared,
385
+ **resolution.to_dict(),
386
+ # Called out separately because a profile that switches the security
387
+ # configuration is not one bean among many — it is the difference
388
+ # between an authenticated surface and an open one.
389
+ "security": security_projection(cir, resolution),
390
+ }
391
+ payload["endpoints"] = endpoint_projection(cir, payload["security"])
392
+ # A requested profile no repository artefact mentions is almost always a typo,
393
+ # and silently resolving every `@Profile` against it would answer confidently
394
+ # about a configuration that does not exist.
395
+ unknown = sorted((profiles or set()) - set(declared))
396
+ if unknown:
397
+ payload["profiles_not_declared"] = unknown
398
+ return payload
399
+
400
+
401
+ def diff_posture(root: Path, left: "set[str]", right: "set[str]") -> dict:
402
+ """What changes between two profile sets — the question a reviewer asks."""
403
+ a = build_posture(root, left)
404
+ b = build_posture(root, right)
405
+ a_active = {e["symbol"] for e in a["active"]}
406
+ b_active = {e["symbol"] for e in b["active"]}
407
+ unresolved = sorted(
408
+ {e["symbol"] for e in a["unresolved"]} | {e["symbol"] for e in b["unresolved"]}
409
+ )
410
+ a_sec = {e["symbol"] for e in a["security"]["active"]}
411
+ b_sec = {e["symbol"] for e in b["security"]["active"]}
412
+ sec_unresolved = sorted(
413
+ {e["symbol"] for e in a["security"]["unresolved"]}
414
+ | {e["symbol"] for e in b["security"]["unresolved"]}
415
+ )
416
+ security_delta: dict = {
417
+ "left_active": sorted(a_sec),
418
+ "right_active": sorted(b_sec),
419
+ "only_in_left": sorted(a_sec - b_sec),
420
+ "only_in_right": sorted(b_sec - a_sec),
421
+ "unresolved": sec_unresolved,
422
+ }
423
+ # One side configuring the request chain while the other does not is the
424
+ # finding this command exists to surface; it is stated, not left to be
425
+ # inferred from two lists.
426
+ left_names = ",".join(a["profiles_requested"]) or "(no profile)"
427
+ right_names = ",".join(b["profiles_requested"]) or "(no profile)"
428
+ if a_sec and not b_sec:
429
+ security_delta["note"] = (
430
+ f"{left_names} configures the request chain; "
431
+ f"{right_names} activates no security configuration"
432
+ )
433
+ elif b_sec and not a_sec:
434
+ security_delta["note"] = (
435
+ f"{right_names} configures the request chain; "
436
+ f"{left_names} activates no security configuration"
437
+ )
438
+
439
+ return {
440
+ "schema_version": POSTURE_SCHEMA,
441
+ "left": {
442
+ "profiles": a["profiles_requested"],
443
+ "active": len(a_active),
444
+ "endpoints": a["endpoints"],
445
+ },
446
+ "right": {
447
+ "profiles": b["profiles_requested"],
448
+ "active": len(b_active),
449
+ "endpoints": b["endpoints"],
450
+ },
451
+ "only_in_left": sorted(a_active - b_active),
452
+ "only_in_right": sorted(b_active - a_active),
453
+ "in_both": len(a_active & b_active),
454
+ "security": security_delta,
455
+ # A bean nobody could resolve is not "unchanged" — it is undecided under
456
+ # both sets, and a delta that hid it would read as a clean comparison.
457
+ "unresolved": unresolved,
458
+ }