sourcecode 2.0.1__py3-none-any.whl → 2.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.
@@ -0,0 +1,608 @@
1
+ """security_posture.py — Knowledge-Layer security-posture inference (P1-A).
2
+
3
+ Infers **custom endpoint security models without prior knowledge of the
4
+ framework**. Detection emerges from semantic evidence — coverage, specificity,
5
+ correlation, request-interception topology — never from proprietary annotation
6
+ names. This is the Knowledge Layer of the frozen Semantic IR → ContextGraph →
7
+ Knowledge split (see docs/architecture/DESIGN-robustness-security-inference.md
8
+ §0, §1, §6, §10). It adds no IR atom: every fact it reads (per-symbol
9
+ annotations, endpoint surface, implements/extends edges) already exists.
10
+
11
+ INVARIANT — Vendor-Agnostic Inference (VAI, DESIGN §0.5)
12
+ --------------------------------------------------------
13
+ No predicate in this module branches on a proprietary / client / convention
14
+ name. A concrete annotation name (a repo's bespoke ``@<CustomGuard>``) may appear only in the
15
+ *evidence* of a verdict, never in the predicate that produced it. The only name
16
+ table here (`_OPEN_STANDARD_*`) lists **published open standards** and is used
17
+ purely to *subtract the known baseline*; "custom" is the structural residue,
18
+ discovered by frequency + specificity + correlation. A config file
19
+ (`sourcecode.config.json`) is never required and never enters detection — it only
20
+ upgrades a specific finding's confidence ``Likely → Verified``.
21
+
22
+ The module never raises: any structural gap degrades to ``Unknown`` with a
23
+ reason, following the "absence of evidence is not evidence of absence"
24
+ discipline. It never emits a categorical "N endpoints unsecured".
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any, Optional
32
+
33
+ from sourcecode.security_config import load_custom_security
34
+
35
+ if TYPE_CHECKING:
36
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Evidence-Confidence envelope (DESIGN §0) — shared vocabulary
41
+ # ---------------------------------------------------------------------------
42
+
43
+ class Confidence(str, Enum):
44
+ """A verdict's confidence. ``str`` mixin so it serializes to its label."""
45
+
46
+ VERIFIED = "Verified" # a fact atom directly supports the claim
47
+ LIKELY = "Likely" # inferred from a pattern/relation, not a direct atom
48
+ UNKNOWN = "Unknown" # no recognized pattern matched (absence of evidence)
49
+ REQUIRES_MANUAL_REVIEW = "RequiresManualReview" # conflicting / partial evidence
50
+
51
+
52
+ @dataclass
53
+ class Evidence:
54
+ """What a verdict was derived from — atoms, relations, source spans."""
55
+
56
+ atoms_used: list = field(default_factory=list)
57
+ relations_used: list = field(default_factory=list)
58
+ source_spans: list = field(default_factory=list)
59
+ # free-form structural evidence (coverage %, correlated filter, concrete
60
+ # annotation name-as-evidence). Concrete names are ALLOWED here — never in a
61
+ # predicate.
62
+ details: dict = field(default_factory=dict)
63
+
64
+ def to_dict(self) -> dict:
65
+ out: dict = {}
66
+ if self.atoms_used:
67
+ out["atoms_used"] = list(self.atoms_used)
68
+ if self.relations_used:
69
+ out["relations_used"] = list(self.relations_used)
70
+ if self.source_spans:
71
+ out["source_spans"] = list(self.source_spans)
72
+ if self.details:
73
+ out["details"] = dict(self.details)
74
+ return out
75
+
76
+
77
+ @dataclass
78
+ class Verdict:
79
+ """A Knowledge-Layer conclusion carrying its Evidence-Confidence envelope."""
80
+
81
+ claim: str
82
+ confidence: Confidence
83
+ evidence: Evidence = field(default_factory=Evidence)
84
+ limitations: list[str] = field(default_factory=list)
85
+ fp_causes: list[str] = field(default_factory=list)
86
+ fn_causes: list[str] = field(default_factory=list)
87
+
88
+ def to_dict(self) -> dict:
89
+ return {
90
+ "claim": self.claim,
91
+ "confidence": self.confidence.value,
92
+ "evidence": self.evidence.to_dict(),
93
+ "limitations": list(self.limitations),
94
+ "fp_causes": list(self.fp_causes),
95
+ "fn_causes": list(self.fn_causes),
96
+ }
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Open-standard baseline table (DESIGN §0.5) — DATA, not predicates
101
+ # ---------------------------------------------------------------------------
102
+ # The ONLY name table permitted. It lists published, open-standard vocabulary so
103
+ # the engine can *subtract the known baseline* and treat the residue as
104
+ # candidate-custom. Rules (enforced by the VAI grep-gate test):
105
+ # 1. Only open, published standards — NEVER a client / project name.
106
+ # 2. Used only to subtract the baseline — never to positively match "custom".
107
+ #
108
+ # Simple names (annotation `@X` is stored as the token `@X`; we compare `X`).
109
+ # Covers three published families that legitimately appear on handler methods:
110
+ # authorization, HTTP-mapping/binding, and bean-validation — so that a repo's
111
+ # non-standard *residue* is what remains.
112
+
113
+ # Published authorization / authentication vocabulary
114
+ # (org.springframework.security, jakarta/javax.annotation.security,
115
+ # jakarta/javax.ws.rs.security, Apache Shiro published annotations).
116
+ _OPEN_STANDARD_SECURITY: frozenset[str] = frozenset({
117
+ "PreAuthorize", "PostAuthorize", "PreFilter", "PostFilter", "Secured",
118
+ "RolesAllowed", "PermitAll", "DenyAll", "Authenticated",
119
+ "AuthenticationPrincipal", "CurrentSecurityContext", "EnableWebSecurity",
120
+ "EnableMethodSecurity", "EnableGlobalMethodSecurity", "RequiresAuthentication",
121
+ "RequiresUser", "RequiresGuest", "RequiresRoles", "RequiresPermissions",
122
+ "RequiresGuestOnly",
123
+ })
124
+
125
+ # Published HTTP-mapping / request-binding vocabulary (Spring MVC + JAX-RS).
126
+ # These appear on ~all handlers; subtracting them keeps coverage meaningful.
127
+ _OPEN_STANDARD_WEB: frozenset[str] = frozenset({
128
+ "RequestMapping", "GetMapping", "PostMapping", "PutMapping", "DeleteMapping",
129
+ "PatchMapping", "RequestBody", "ResponseBody", "ResponseStatus", "RestController",
130
+ "Controller", "PathVariable", "RequestParam", "RequestHeader", "RequestPart",
131
+ "ModelAttribute", "CookieValue", "MatrixVariable", "SessionAttribute",
132
+ "RequestAttribute", "CrossOrigin", "ExceptionHandler", "InitBinder",
133
+ "Path", "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS",
134
+ "Produces", "Consumes", "PathParam", "QueryParam", "HeaderParam",
135
+ "FormParam", "CookieParam", "MatrixParam", "BeanParam", "Context", "DefaultValue",
136
+ })
137
+
138
+ # Published bean-validation / documentation vocabulary.
139
+ _OPEN_STANDARD_MISC: frozenset[str] = frozenset({
140
+ "Valid", "Validated", "NotNull", "NotBlank", "NotEmpty", "Size", "Min", "Max",
141
+ "Pattern", "Email", "Positive", "Negative", "Transactional", "Async",
142
+ "Operation", "ApiOperation", "ApiResponse", "ApiResponses", "Parameter",
143
+ "Tag", "Schema", "Deprecated", "Override", "SuppressWarnings", "Autowired",
144
+ "Qualifier", "Value", "Inject", "Resource",
145
+ })
146
+
147
+ _OPEN_STANDARD_BASELINE: frozenset[str] = (
148
+ _OPEN_STANDARD_SECURITY | _OPEN_STANDARD_WEB | _OPEN_STANDARD_MISC
149
+ )
150
+
151
+ # Published request-chain SPI base types / interfaces (servlet + Spring +
152
+ # JAX-RS). A class that implements/extends one of these IS a request interceptor,
153
+ # by structure — the concrete base type is recorded as evidence, never branched.
154
+ _REQUEST_CHAIN_SPI: frozenset[str] = frozenset({
155
+ "Filter", "OncePerRequestFilter", "GenericFilterBean", "HandlerInterceptor",
156
+ "HandlerInterceptorAdapter", "WebFilter", "ContainerRequestFilter",
157
+ "ContainerResponseFilter", "ClientHttpRequestInterceptor",
158
+ "SecurityFilterChain", "WebSecurityConfigurerAdapter", "AbstractSecurityInterceptor",
159
+ "DelegatingFilterProxy",
160
+ })
161
+
162
+ # Endpoint security policies that ARE a recognized standard per-method guard.
163
+ # ("none_detected"/"programmatic"/"xml_or_filter_chain" are NOT per-method guards;
164
+ # "custom" is a config-recognized custom gate, handled separately.)
165
+ _STANDARD_GUARD_POLICIES: frozenset[str] = frozenset({
166
+ "deny_all", "permit_all", "roles_allowed", "authenticated", "secured",
167
+ "openapi_security", "requiresauthentication", "requiresroles",
168
+ "requirespermissions", "requiresuser",
169
+ })
170
+
171
+
172
+ def _is_baseline(simple: str) -> bool:
173
+ """True if ``simple`` (an annotation simple-name, no '@') is published
174
+ open-standard vocabulary. Pure set-subtraction against the baseline DATA
175
+ table — NOT a client-name branch (VAI §0.5)."""
176
+ return simple in _OPEN_STANDARD_BASELINE
177
+
178
+
179
+ def _is_standard_guard_policy(policy: str) -> bool:
180
+ """True if an endpoint's security policy is a recognized standard guard."""
181
+ p = (policy or "").lower()
182
+ return p in _STANDARD_GUARD_POLICIES or p.startswith("spring_") or p.startswith("shiro_")
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Structural substrate (reads existing IR facts — adds no atom)
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def _annotation_simple(token: str) -> str:
190
+ """`@Foo(bar=1)` / `@a.b.Foo` → `Foo`. Meaning-free normalization."""
191
+ t = (token or "").strip().lstrip("@")
192
+ t = t.split("(", 1)[0].strip()
193
+ return t.rsplit(".", 1)[-1]
194
+
195
+
196
+ def _graph_nodes(cir: "CanonicalRepositoryIR") -> list[dict]:
197
+ raw = getattr(cir, "_raw_ir", None) or {}
198
+ graph = raw.get("graph") if isinstance(raw, dict) else None
199
+ if not isinstance(graph, dict):
200
+ return []
201
+ nodes = graph.get("nodes")
202
+ return nodes if isinstance(nodes, list) else []
203
+
204
+
205
+ # Symbol kinds that are callable members carrying method-level annotations.
206
+ # A mapped handler surfaces as kind "endpoint"; a plain method as "method".
207
+ _METHOD_KINDS: frozenset[str] = frozenset({"method", "endpoint"})
208
+
209
+
210
+ def _owner_of(method_fqn: str) -> str:
211
+ """Owning class FQN of a method symbol `pkg.Class#method` → `pkg.Class`."""
212
+ return method_fqn.split("#", 1)[0] if "#" in method_fqn else method_fqn
213
+
214
+
215
+ @dataclass
216
+ class _AnnotationUsage:
217
+ """Meaning-free usage stat for one annotation simple-name (DESIGN §1 IR note,
218
+ computed here as a Knowledge-Layer projection — no new atom)."""
219
+
220
+ simple: str
221
+ token: str # representative source token (evidence only)
222
+ handler_hits: int = 0 # controller-handler methods carrying it
223
+ non_handler_hits: int = 0 # any other method carrying it
224
+ handler_owners: set[str] = field(default_factory=set)
225
+
226
+
227
+ def _collect_usage(
228
+ cir: "CanonicalRepositoryIR",
229
+ ) -> tuple[dict[str, _AnnotationUsage], int, int, set[str]]:
230
+ """Scan existing per-symbol annotations. Returns (usage_by_simple,
231
+ total_handler_methods, total_non_handler_methods, handler_symbols).
232
+
233
+ Controller-handler methods = the handler symbols of `cir.endpoints`; this is
234
+ the authoritative endpoint surface (already structurally derived by the IR).
235
+ """
236
+ handler_symbols: set[str] = {
237
+ ep.handler_symbol for ep in cir.endpoints if getattr(ep, "handler_symbol", "")
238
+ }
239
+ controller_classes: set[str] = {
240
+ ep.controller_class for ep in cir.endpoints if getattr(ep, "controller_class", "")
241
+ }
242
+
243
+ usage: dict[str, _AnnotationUsage] = {}
244
+ total_handler = 0
245
+ total_non_handler = 0
246
+
247
+ for node in _graph_nodes(cir):
248
+ if not isinstance(node, dict):
249
+ continue
250
+ if node.get("symbol_kind") not in _METHOD_KINDS:
251
+ continue
252
+ fqn = node.get("fqn") or ""
253
+ anns = node.get("annotations") or []
254
+ if not isinstance(anns, list):
255
+ continue
256
+ is_handler = fqn in handler_symbols
257
+ # a method of a controller class that is not itself a mapped endpoint
258
+ # still counts toward the "non-handler" denominator (specificity signal)
259
+ if is_handler:
260
+ total_handler += 1
261
+ else:
262
+ total_non_handler += 1
263
+ for tok in anns:
264
+ simple = _annotation_simple(str(tok))
265
+ if not simple:
266
+ continue
267
+ u = usage.get(simple)
268
+ if u is None:
269
+ u = _AnnotationUsage(simple=simple, token=str(tok).split("(", 1)[0])
270
+ usage[simple] = u
271
+ if is_handler:
272
+ u.handler_hits += 1
273
+ u.handler_owners.add(_owner_of(fqn))
274
+ else:
275
+ u.non_handler_hits += 1
276
+
277
+ return usage, total_handler, total_non_handler, handler_symbols
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Request-interception inference (DESIGN §1 correlation booster)
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _infer_interception(cir: "CanonicalRepositoryIR") -> Optional[Verdict]:
285
+ """Infer an authentication/request pipeline from structure: classes that
286
+ implement/extend a published request-chain SPI (§0.5 baseline). The concrete
287
+ base type is evidence, never a branch. Returns a ``Likely`` verdict or None.
288
+ """
289
+ interceptors: list[dict] = []
290
+ for edge in getattr(cir, "dependencies", None) or []:
291
+ if not isinstance(edge, dict):
292
+ continue
293
+ if edge.get("type") not in ("implements", "extends"):
294
+ continue
295
+ base_simple = (edge.get("to") or "").split("<", 1)[0].rsplit(".", 1)[-1]
296
+ if base_simple in _REQUEST_CHAIN_SPI:
297
+ interceptors.append({
298
+ "class": edge.get("from") or "",
299
+ "implements": base_simple,
300
+ })
301
+
302
+ if not interceptors:
303
+ return None
304
+
305
+ interceptors.sort(key=lambda d: d["class"])
306
+ return Verdict(
307
+ claim="Authentication pipeline inferred",
308
+ confidence=Confidence.LIKELY,
309
+ evidence=Evidence(
310
+ relations_used=[f"{d['class']} -> {d['implements']}" for d in interceptors],
311
+ details={
312
+ "interceptor_count": len(interceptors),
313
+ "interceptors": interceptors[:50],
314
+ },
315
+ ),
316
+ limitations=[
317
+ "Pipeline order (@Order/registration) and per-filter URL patterns are "
318
+ "not reconstructed in this increment — presence is structural only.",
319
+ "A request-chain SPI implementer does not prove the filter enforces "
320
+ "authorization; it establishes a request-interception surface.",
321
+ ],
322
+ fp_causes=["A logging/CORS/metrics filter also implements the servlet Filter SPI."],
323
+ )
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Custom-gate auto-detection (DESIGN §1 — VAI-clean heuristic)
328
+ # ---------------------------------------------------------------------------
329
+
330
+ # Tunable thresholds (structural, name-free).
331
+ _DEFAULT_COVERAGE = 0.15 # (b) ≥N% of controller-handler methods
332
+ _SPECIFICITY_RATIO = 3.0 # (c) handler rate ≫ non-handler rate
333
+
334
+
335
+ def _detect_custom_gates(
336
+ cir: "CanonicalRepositoryIR",
337
+ *,
338
+ coverage_threshold: float,
339
+ interception: Optional[Verdict],
340
+ configured: dict[str, Any],
341
+ ) -> list[Verdict]:
342
+ """Discover candidate custom authorization annotations by STRUCTURE only.
343
+
344
+ An annotation simple-name is a candidate custom gate when ALL hold — each a
345
+ structural test, none a name check (DESIGN §1):
346
+ (a) outside the open-standard baseline table (set-subtraction on DATA);
347
+ (b) coverage — on ≥ threshold of controller-handler methods;
348
+ (c) specificity — its handler rate ≫ its non-handler rate;
349
+ (d) correlation (booster) — co-occurs with a request-interception surface.
350
+
351
+ Emits ``Likely`` verdicts. A `sourcecode.config.json` entry naming the same
352
+ annotation upgrades it to ``Verified`` (user asserting ground truth) — but
353
+ detection already fired without it.
354
+ """
355
+ usage, total_handler, total_non_handler, _ = _collect_usage(cir)
356
+ if total_handler == 0:
357
+ return []
358
+
359
+ verdicts: list[Verdict] = []
360
+ for simple, u in sorted(usage.items()):
361
+ # (a) subtract the published baseline
362
+ if _is_baseline(simple):
363
+ continue
364
+ # (b) coverage over the controller-handler surface
365
+ coverage = u.handler_hits / total_handler
366
+ if coverage < coverage_threshold:
367
+ continue
368
+ # (c) specificity: tracks the endpoint surface, not the whole codebase
369
+ handler_rate = coverage
370
+ non_handler_rate = (
371
+ u.non_handler_hits / total_non_handler if total_non_handler else 0.0
372
+ )
373
+ if non_handler_rate > 0.0 and handler_rate < _SPECIFICITY_RATIO * non_handler_rate:
374
+ continue
375
+
376
+ # (d) correlation booster — still Likely without config; config → Verified
377
+ correlated = interception is not None
378
+ config_confirmed = simple in configured
379
+ confidence = Confidence.VERIFIED if config_confirmed else Confidence.LIKELY
380
+
381
+ details: dict = {
382
+ # concrete name lives ONLY here, as evidence (VAI §0.5)
383
+ "annotation": u.token,
384
+ "coverage": round(coverage, 4),
385
+ "coverage_pct": f"{round(coverage * 100)}% of controller handlers",
386
+ "handler_hits": u.handler_hits,
387
+ "handler_total": total_handler,
388
+ "non_handler_hits": u.non_handler_hits,
389
+ "specificity_ratio": (
390
+ round(handler_rate / non_handler_rate, 2)
391
+ if non_handler_rate > 0 else None
392
+ ),
393
+ "correlated_with_interception": correlated,
394
+ "config_confirmed": config_confirmed,
395
+ }
396
+ limitations = [
397
+ "Detected structurally (coverage + specificity); confirm the annotation "
398
+ "actually enforces authorization.",
399
+ ]
400
+ if not config_confirmed:
401
+ limitations.append(
402
+ "Confidence is Likely — declare this annotation in "
403
+ "sourcecode.config.json to upgrade to Verified."
404
+ )
405
+ verdicts.append(Verdict(
406
+ claim="Custom authorization mechanism detected",
407
+ confidence=confidence,
408
+ evidence=Evidence(details=details),
409
+ limitations=limitations,
410
+ fp_causes=[
411
+ "A non-security annotation applied systematically to controllers "
412
+ "(e.g. auditing/logging) can present the same coverage shape.",
413
+ ],
414
+ fn_causes=[
415
+ "A gate applied to <{:.0%} of handlers, or class-level only, may "
416
+ "fall below the coverage threshold.".format(coverage_threshold),
417
+ ],
418
+ ))
419
+ return verdicts
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Per-endpoint 4-way posture (DESIGN §1)
424
+ # ---------------------------------------------------------------------------
425
+
426
+ def _handler_annotation_index(cir: "CanonicalRepositoryIR") -> dict[str, set[str]]:
427
+ """handler_symbol → {annotation simple-names on that method}."""
428
+ idx: dict[str, set[str]] = {}
429
+ for node in _graph_nodes(cir):
430
+ if not isinstance(node, dict) or node.get("symbol_kind") not in _METHOD_KINDS:
431
+ continue
432
+ fqn = node.get("fqn") or ""
433
+ anns = node.get("annotations") or []
434
+ if isinstance(anns, list):
435
+ idx[fqn] = {_annotation_simple(str(t)) for t in anns}
436
+ return idx
437
+
438
+
439
+ def gate_bearing_handlers(
440
+ cir: "CanonicalRepositoryIR", gate_simple_names: set[str]
441
+ ) -> set[str]:
442
+ """handler_symbols whose method carries one of the given (auto-detected or
443
+ configured) custom-gate annotations. Used to suppress categorical
444
+ "unsecured" false alarms (DESIGN §10 acceptance #3)."""
445
+ if not gate_simple_names:
446
+ return set()
447
+ handler_syms = {ep.handler_symbol for ep in cir.endpoints if ep.handler_symbol}
448
+ idx = _handler_annotation_index(cir)
449
+ return {
450
+ h for h in handler_syms
451
+ if idx.get(h, set()) & gate_simple_names
452
+ }
453
+
454
+
455
+ @dataclass
456
+ class SecurityPostureResult:
457
+ """Repo-level security-posture projection (P1-A)."""
458
+
459
+ security_model: str
460
+ custom_gates: list[Verdict] = field(default_factory=list)
461
+ interception: Optional[Verdict] = None
462
+ endpoint_verdicts: list[dict] = field(default_factory=list)
463
+ rollup: dict = field(default_factory=dict)
464
+ limitations: list[str] = field(default_factory=list)
465
+
466
+ def gate_simple_names(self) -> set[str]:
467
+ out: set[str] = set()
468
+ for v in self.custom_gates:
469
+ name = v.evidence.details.get("annotation") if v.evidence else None
470
+ if name:
471
+ out.add(_annotation_simple(str(name)))
472
+ return out
473
+
474
+ def to_dict(self) -> dict:
475
+ return {
476
+ "provenance": "security_posture projection (P1-A)",
477
+ "security_model": self.security_model,
478
+ "custom_authorization_mechanisms": [v.to_dict() for v in self.custom_gates],
479
+ "authentication_pipeline": (
480
+ self.interception.to_dict() if self.interception else None
481
+ ),
482
+ "endpoints": self.endpoint_verdicts,
483
+ "rollup": self.rollup,
484
+ "limitations": list(self.limitations),
485
+ }
486
+
487
+
488
+ def infer_security_posture(
489
+ cir: "CanonicalRepositoryIR",
490
+ *,
491
+ root: Optional[Path] = None,
492
+ coverage_threshold: float = _DEFAULT_COVERAGE,
493
+ ) -> SecurityPostureResult:
494
+ """Infer the repo's endpoint security posture (P1-A).
495
+
496
+ Pipeline (all Knowledge Layer, no IR change):
497
+ 1. infer the request-interception surface (structural);
498
+ 2. auto-detect candidate custom authorization mechanisms (VAI-clean);
499
+ 3. upgrade any that a config file confirms (Likely → Verified);
500
+ 4. classify every endpoint into the 4-way posture with its confidence.
501
+
502
+ Never raises; never emits a categorical "unsecured" verdict.
503
+ """
504
+ security_model = str(cir.metadata.get("security_model", "unknown"))
505
+
506
+ # (0) config: names never drive detection — only upgrade confidence.
507
+ configured: dict[str, Any] = {}
508
+ try:
509
+ for spec in load_custom_security(root):
510
+ configured[spec.short_name] = spec
511
+ except Exception:
512
+ configured = {}
513
+
514
+ # (1) interception surface
515
+ interception = _infer_interception(cir)
516
+
517
+ # (2)+(3) custom-gate auto-detect, with config-driven confidence upgrade
518
+ custom_gates = _detect_custom_gates(
519
+ cir,
520
+ coverage_threshold=coverage_threshold,
521
+ interception=interception,
522
+ configured=configured,
523
+ )
524
+
525
+ gate_names = set()
526
+ for v in custom_gates:
527
+ nm = v.evidence.details.get("annotation")
528
+ if nm:
529
+ gate_names.add(_annotation_simple(str(nm)))
530
+ has_custom_gate = bool(custom_gates)
531
+ gate_bearers = gate_bearing_handlers(cir, gate_names)
532
+
533
+ # (4) per-endpoint 4-way classification
534
+ handler_ann = _handler_annotation_index(cir)
535
+ verdicts: list[dict] = []
536
+ rollup = {"protected_standard": 0, "protected_custom": 0,
537
+ "coverage_unknown": 0, "probably_exposed": 0}
538
+
539
+ for ep in cir.endpoints:
540
+ policy = ep.security.policy if ep.security is not None else "none_detected"
541
+ anns = handler_ann.get(ep.handler_symbol, set())
542
+ carries_gate = bool(anns & gate_names)
543
+
544
+ if policy == "custom" or carries_gate:
545
+ # protected by a custom gate. Verified only if the specific gate is
546
+ # config-confirmed; otherwise the mechanism is inferred → Likely.
547
+ gate_conf = Confidence.LIKELY
548
+ if policy == "custom" and ep.security and ep.security.annotation in configured:
549
+ gate_conf = Confidence.VERIFIED
550
+ elif carries_gate and (anns & set(configured.keys())):
551
+ gate_conf = Confidence.VERIFIED
552
+ verdict = "protected_custom"
553
+ confidence = gate_conf
554
+ elif _is_standard_guard_policy(policy):
555
+ verdict = "protected_standard"
556
+ confidence = Confidence.VERIFIED
557
+ elif security_model == "unknown":
558
+ verdict = "coverage_unknown"
559
+ confidence = Confidence.UNKNOWN
560
+ elif security_model in ("filter_based", "xml_or_filter_chain") or interception is not None:
561
+ # a centralized filter/interception surface plausibly covers this route
562
+ verdict = "coverage_unknown"
563
+ confidence = Confidence.UNKNOWN
564
+ elif has_custom_gate:
565
+ # a custom gate exists in the repo but not on THIS endpoint. Never
566
+ # categorical "unsecured": worst case is manual review (§10 #3).
567
+ verdict = "coverage_unknown"
568
+ confidence = Confidence.REQUIRES_MANUAL_REVIEW
569
+ elif security_model == "annotation_based":
570
+ # only warning verdict — Likely, never categorical.
571
+ verdict = "probably_exposed"
572
+ confidence = Confidence.LIKELY
573
+ else:
574
+ verdict = "coverage_unknown"
575
+ confidence = Confidence.UNKNOWN
576
+
577
+ rollup[verdict] = rollup.get(verdict, 0) + 1
578
+ verdicts.append({
579
+ "endpoint_id": ep.id,
580
+ "method": ep.method,
581
+ "path": ep.path,
582
+ "handler_symbol": ep.handler_symbol,
583
+ "verdict": verdict,
584
+ "confidence": confidence.value,
585
+ "policy": policy,
586
+ })
587
+
588
+ limitations = [
589
+ "Posture is inferred from static structure; a runtime security config "
590
+ "(WebSecurity DSL, XML) may protect routes this projection marks unknown.",
591
+ ]
592
+ if has_custom_gate:
593
+ limitations.append(
594
+ "A custom authorization mechanism was inferred — endpoints not carrying "
595
+ "it are reported 'coverage_unknown', never 'unsecured'."
596
+ )
597
+
598
+ result = SecurityPostureResult(
599
+ security_model=security_model,
600
+ custom_gates=custom_gates,
601
+ interception=interception,
602
+ endpoint_verdicts=verdicts,
603
+ rollup=rollup,
604
+ limitations=limitations,
605
+ )
606
+ # keep reference to suppress-set so callers (SEC-001) can dedupe false alarms
607
+ result._gate_bearing_handlers = gate_bearers # type: ignore[attr-defined]
608
+ return result
@@ -179,7 +179,9 @@ class SemanticIntegrationEngine:
179
179
 
180
180
  def detect(self) -> list[Integration]:
181
181
  """All outbound integrations recoverable from typed facts, deterministically
182
- ordered. One record per (type, kind, client). Targets are attributed by
182
+ ordered. One record per semantic point (kind + evidence location): a type that
183
+ touches several client libraries of the same kind counts once. Targets are
184
+ attributed by
183
185
  annotation args (declarative) or by literal scheme (imperative), never by a
184
186
  receiver variable name."""
185
187
  per_type: dict[str, list[Integration]] = {}
@@ -231,7 +233,21 @@ class SemanticIntegrationEngine:
231
233
  per_type[t.fqn] = list(recs.values())
232
234
 
233
235
  out = [r for recs in per_type.values() for r in recs]
234
- return sorted(out, key=lambda i: (i.kind, i.client, i.evidence))
236
+ out.sort(key=lambda i: (i.kind, i.client, i.evidence))
237
+ # REG-1 (Broadleaf field test): one integration per semantic point =
238
+ # (kind, evidence location). A single type touching several client-library
239
+ # types of the SAME kind — e.g. spring-ldap + spring-security-ldap on one
240
+ # LdapUserDetailsMapper, or javamail + spring-mail on one MessageCreator —
241
+ # previously emitted one record per client label, double-counting the same
242
+ # integration. Collapse to a single record per (kind, evidence), preferring
243
+ # one that carries a resolved target; ties broken by the deterministic
244
+ # (kind, client, evidence) order established above.
245
+ deduped: dict[tuple, Integration] = {}
246
+ for r in out:
247
+ k = (r.kind, r.evidence)
248
+ if k not in deduped or (r.target and deduped[k].target is None):
249
+ deduped[k] = r
250
+ return list(deduped.values())
235
251
 
236
252
  def _coverage(self, n_records: int, n_files: int) -> "tuple[str, str]":
237
253
  """Honest coverage signal (mirrors integration_detector): a low count on a
sourcecode/serializer.py CHANGED
@@ -462,11 +462,31 @@ def _mybatis_pairing(sm: "SourceMap", *, full: bool = False) -> "Optional[dict[s
462
462
 
463
463
 
464
464
  def _spring_boot_version(sm: "SourceMap") -> "Optional[str]":
465
- """Extract Spring Boot version from detected frameworks."""
465
+ """Extract Spring Boot version from detected frameworks.
466
+
467
+ A3 fix (field validation, Broadleaf): when Spring Boot IS detected but the
468
+ stack detector could not attach a version (e.g. the version lives in a
469
+ `<spring.boot.version>` pom property rather than spring-boot-starter-parent),
470
+ fall back to migrate-check's property-resolving detector so `--compact`
471
+ reports the SAME version migrate-check already knows — the engine must not
472
+ surface a fact in one command and hide it in another. Gated on Boot already
473
+ being detected, so no phantom version is ever introduced.
474
+ """
475
+ _boot_detected = False
466
476
  for s in sm.stacks:
467
477
  for fw in s.frameworks:
468
- if fw.name == "Spring Boot" and fw.version:
469
- return fw.version
478
+ if fw.name == "Spring Boot":
479
+ if fw.version:
480
+ return fw.version
481
+ _boot_detected = True
482
+ if _boot_detected:
483
+ _root = getattr(sm.metadata, "analyzed_path", None) if sm.metadata else None
484
+ if _root:
485
+ try:
486
+ from sourcecode.migrate_check import _detect_spring_boot
487
+ return _detect_spring_boot(Path(_root), 0)[1]
488
+ except Exception:
489
+ return None
470
490
  return None
471
491
 
472
492
 
@@ -753,7 +773,7 @@ def _bootstrap_structured(eps: list) -> "Optional[dict[str, Any]]":
753
773
 
754
774
  _ctrl_note = (
755
775
  f"{controller_classes} controller classes detected"
756
- f" (use 'sourcecode endpoints' for per-method HTTP surface)"
776
+ f" (use 'ask endpoints' for per-method HTTP surface)"
757
777
  )
758
778
  if len(module_names) > 30:
759
779
  # Group by first path segment under ddd/ (inferred domain area)