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/__init__.py +1 -1
- sourcecode/cache.py +44 -3
- sourcecode/cli.py +439 -33
- sourcecode/context_cache.py +9 -0
- sourcecode/contract_pipeline.py +14 -3
- sourcecode/detectors/java.py +7 -1
- sourcecode/envelope.py +193 -0
- sourcecode/format_contract.py +11 -5
- sourcecode/git_analyzer.py +74 -12
- sourcecode/posture.py +458 -0
- sourcecode/prepare_context.py +126 -35
- sourcecode/repository_ir.py +70 -1
- sourcecode/schemas/envelope-v1.schema.json +74 -0
- sourcecode/semantic_integration_engine.py +11 -0
- sourcecode/spring_impact.py +44 -10
- sourcecode/spring_profiles.py +11 -1
- sourcecode/spring_security_audit.py +5 -3
- sourcecode/verify_repo.py +252 -0
- sourcecode/verify_rules.py +135 -12
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/METADATA +3 -1
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/RECORD +24 -20
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.0.0.dist-info}/licenses/LICENSE +0 -0
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
|
+
}
|