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.
- sourcecode/__init__.py +6 -2
- sourcecode/caller_metrics.py +42 -0
- sourcecode/cli.py +325 -207
- sourcecode/detectors/java.py +29 -1
- sourcecode/endpoint_metrics.py +42 -0
- sourcecode/explain.py +2 -0
- sourcecode/license.py +31 -13
- sourcecode/mcp/onboarding/applier.py +14 -6
- sourcecode/mcp/registry.py +22 -22
- sourcecode/mcp/runner.py +2 -2
- sourcecode/mcp/server.py +24 -24
- sourcecode/mcp_nudge.py +1 -1
- sourcecode/path_filters.py +2 -1
- sourcecode/repository_ir.py +51 -15
- sourcecode/ris.py +1 -1
- sourcecode/security_posture.py +608 -0
- sourcecode/semantic_integration_engine.py +18 -2
- sourcecode/serializer.py +24 -4
- sourcecode/spring_findings.py +4 -0
- sourcecode/spring_security_audit.py +31 -0
- sourcecode/spring_semantic.py +18 -5
- sourcecode/spring_tx_analyzer.py +21 -6
- sourcecode/telemetry/consent.py +1 -1
- sourcecode/validation_inference.py +249 -0
- sourcecode-2.2.0.dist-info/METADATA +241 -0
- {sourcecode-2.0.1.dist-info → sourcecode-2.2.0.dist-info}/RECORD +29 -25
- {sourcecode-2.0.1.dist-info → sourcecode-2.2.0.dist-info}/entry_points.txt +1 -0
- sourcecode-2.0.1.dist-info/METADATA +0 -922
- {sourcecode-2.0.1.dist-info → sourcecode-2.2.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.0.1.dist-info → sourcecode-2.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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 (
|
|
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
|
-
|
|
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"
|
|
469
|
-
|
|
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 '
|
|
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)
|