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
sourcecode/spring_findings.py
CHANGED
|
@@ -80,6 +80,9 @@ class SpringAuditResult:
|
|
|
80
80
|
findings: list[SpringFinding] = field(default_factory=list)
|
|
81
81
|
limitations: list[str] = field(default_factory=list)
|
|
82
82
|
metadata: dict = field(default_factory=dict)
|
|
83
|
+
# P1-A: per-endpoint security-posture projection (Knowledge Layer). Empty
|
|
84
|
+
# dict when not computed. Serialized as a top-level `security_posture` block.
|
|
85
|
+
security_posture: dict = field(default_factory=dict)
|
|
83
86
|
|
|
84
87
|
# Populated by finalize()
|
|
85
88
|
summary: dict = field(default_factory=dict)
|
|
@@ -128,6 +131,7 @@ class SpringAuditResult:
|
|
|
128
131
|
"findings": [f.to_dict() for f in self.findings],
|
|
129
132
|
"limitations": self.limitations,
|
|
130
133
|
"metadata": self.metadata,
|
|
134
|
+
"security_posture": self.security_posture,
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
|
|
@@ -468,6 +468,34 @@ def run_security_audit(
|
|
|
468
468
|
scanner = SecurityScanner(patterns=patterns)
|
|
469
469
|
findings = scanner.analyze(cir, tx_index=tx_index, root=root, model=model)
|
|
470
470
|
|
|
471
|
+
# P1-A: infer the security posture (custom-gate auto-detect + per-endpoint
|
|
472
|
+
# verdicts). Knowledge Layer, never raises. When a custom authorization
|
|
473
|
+
# mechanism is inferred, suppress the categorical SEC-001 "unsecured" finding
|
|
474
|
+
# on any endpoint carrying that gate — the posture block reports it honestly
|
|
475
|
+
# instead (DESIGN §10 acceptance #3: never a categorical "N unsecured").
|
|
476
|
+
security_posture: dict = {}
|
|
477
|
+
_posture_note: Optional[str] = None
|
|
478
|
+
try:
|
|
479
|
+
from sourcecode.security_posture import infer_security_posture
|
|
480
|
+
|
|
481
|
+
_posture = infer_security_posture(cir, root=root)
|
|
482
|
+
security_posture = _posture.to_dict()
|
|
483
|
+
_gate_bearers = getattr(_posture, "_gate_bearing_handlers", set()) or set()
|
|
484
|
+
if _gate_bearers:
|
|
485
|
+
_before = len(findings)
|
|
486
|
+
findings = [
|
|
487
|
+
f for f in findings
|
|
488
|
+
if not (f.pattern_id == "SEC-001" and f.symbol in _gate_bearers)
|
|
489
|
+
]
|
|
490
|
+
_suppressed = _before - len(findings)
|
|
491
|
+
if _suppressed:
|
|
492
|
+
_posture_note = (
|
|
493
|
+
f"{_suppressed} SEC-001 finding(s) suppressed: endpoints carry an "
|
|
494
|
+
"auto-detected custom authorization mechanism (see security_posture)."
|
|
495
|
+
)
|
|
496
|
+
except Exception:
|
|
497
|
+
security_posture = {}
|
|
498
|
+
|
|
471
499
|
findings = [f for f in findings if SEVERITY_ORDER.get(f.severity, 9) <= min_rank]
|
|
472
500
|
|
|
473
501
|
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
@@ -482,6 +510,8 @@ def run_security_audit(
|
|
|
482
510
|
]
|
|
483
511
|
for _err in getattr(scanner, "_last_analysis_errors", []):
|
|
484
512
|
_sec_limitations.append(f"PATTERN_ERROR: {_err}")
|
|
513
|
+
if _posture_note:
|
|
514
|
+
_sec_limitations.append(_posture_note)
|
|
485
515
|
|
|
486
516
|
result = SpringAuditResult(
|
|
487
517
|
repo_id=getattr(cir, "cir_hash", "")[:16],
|
|
@@ -505,5 +535,6 @@ def run_security_audit(
|
|
|
505
535
|
"security_model": cir.metadata.get("security_model", "unknown"),
|
|
506
536
|
"analysis_time_ms": elapsed_ms,
|
|
507
537
|
},
|
|
538
|
+
security_posture=security_posture,
|
|
508
539
|
)
|
|
509
540
|
return result.finalize()
|
sourcecode/spring_semantic.py
CHANGED
|
@@ -122,9 +122,17 @@ class TransactionBoundary:
|
|
|
122
122
|
class TransactionBoundaryIndex:
|
|
123
123
|
"""Index of all @Transactional boundaries in a repository."""
|
|
124
124
|
|
|
125
|
-
# FQN → boundary (both class-level and method-level)
|
|
125
|
+
# FQN → boundary (both class-level and method-level). Keyed by FQN for
|
|
126
|
+
# effective_boundary() call-graph lookups; OVERLOADED methods share an FQN
|
|
127
|
+
# (e.g. findAll() and findAll(Pageable) → Repo#findAll) so this dict collapses
|
|
128
|
+
# them — do NOT count from it (see all_declared / A1 field-validation fix).
|
|
126
129
|
by_symbol: dict[str, TransactionBoundary] = field(default_factory=dict)
|
|
127
130
|
|
|
131
|
+
# Every declared @Transactional boundary, one entry per annotation site.
|
|
132
|
+
# Unlike by_symbol this does NOT collapse overloaded methods, so it is the
|
|
133
|
+
# authoritative source for counts/stats.
|
|
134
|
+
all_declared: list[TransactionBoundary] = field(default_factory=list)
|
|
135
|
+
|
|
128
136
|
# class FQN → list of method-level boundaries declared on that class
|
|
129
137
|
by_class: dict[str, list[TransactionBoundary]] = field(default_factory=dict)
|
|
130
138
|
|
|
@@ -152,17 +160,19 @@ class TransactionBoundaryIndex:
|
|
|
152
160
|
return list(self.by_symbol.values())
|
|
153
161
|
|
|
154
162
|
def stats(self) -> dict:
|
|
163
|
+
# Count from all_declared (one entry per annotation site) so overloaded
|
|
164
|
+
# @Transactional methods are not collapsed by the FQN-keyed by_symbol.
|
|
155
165
|
n_class = len(self.class_level)
|
|
156
|
-
n_method = sum(1 for b in self.
|
|
166
|
+
n_method = sum(1 for b in self.all_declared if b.scope == "method")
|
|
157
167
|
propagations: dict[str, int] = {}
|
|
158
|
-
for b in self.
|
|
168
|
+
for b in self.all_declared:
|
|
159
169
|
propagations[b.propagation] = propagations.get(b.propagation, 0) + 1
|
|
160
170
|
return {
|
|
161
|
-
"total": len(self.
|
|
171
|
+
"total": len(self.all_declared),
|
|
162
172
|
"class_level": n_class,
|
|
163
173
|
"method_level": n_method,
|
|
164
174
|
"propagations": propagations,
|
|
165
|
-
"read_only_count": sum(1 for b in self.
|
|
175
|
+
"read_only_count": sum(1 for b in self.all_declared if b.read_only),
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
|
|
@@ -353,6 +363,9 @@ def build_tx_index(cir: "CanonicalRepositoryIR") -> TransactionBoundaryIndex:
|
|
|
353
363
|
boundary = _build_boundary(fqn, scope, raw_args, source_file, modifiers)
|
|
354
364
|
|
|
355
365
|
index.by_symbol[fqn] = boundary
|
|
366
|
+
# Authoritative per-site list — overloaded methods share an FQN and would
|
|
367
|
+
# collapse in by_symbol; all_declared keeps one entry per annotation site.
|
|
368
|
+
index.all_declared.append(boundary)
|
|
356
369
|
|
|
357
370
|
if scope == "class":
|
|
358
371
|
index.class_level[fqn] = boundary
|
sourcecode/spring_tx_analyzer.py
CHANGED
|
@@ -194,8 +194,12 @@ class _TX001ProxyBypass:
|
|
|
194
194
|
model: Optional[SpringSemanticModel] = None,
|
|
195
195
|
) -> list[SpringFinding]:
|
|
196
196
|
findings: list[SpringFinding] = []
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
# A1-RESIDUAL: iterate all_declared (one entry per annotation site), not the
|
|
198
|
+
# FQN-keyed all_boundaries which collapses overloads — a public overload
|
|
199
|
+
# overwriting a private one would hide the proxy-bypass risk. Dedupe by
|
|
200
|
+
# finding id (symbol-keyed) so two risky overloads of the same FQN yield one.
|
|
201
|
+
_seen_ids: set[str] = set()
|
|
202
|
+
for boundary in tx_index.all_declared:
|
|
199
203
|
if boundary.scope != "method":
|
|
200
204
|
continue
|
|
201
205
|
if not boundary.is_proxy_bypass_risk:
|
|
@@ -209,10 +213,14 @@ class _TX001ProxyBypass:
|
|
|
209
213
|
if problematic_modifier == "private"
|
|
210
214
|
else "Spring CGLIB proxy cannot override final methods"
|
|
211
215
|
)
|
|
216
|
+
_fid = SpringFinding.make_id(self.pattern_id, boundary.symbol)
|
|
217
|
+
if _fid in _seen_ids:
|
|
218
|
+
continue
|
|
219
|
+
_seen_ids.add(_fid)
|
|
212
220
|
simple_name = boundary.symbol.rsplit(".", 1)[-1].replace("#", ".")
|
|
213
221
|
|
|
214
222
|
findings.append(SpringFinding(
|
|
215
|
-
id=
|
|
223
|
+
id=_fid,
|
|
216
224
|
pattern_id=self.pattern_id,
|
|
217
225
|
category="tx",
|
|
218
226
|
severity=self.severity,
|
|
@@ -550,8 +558,11 @@ class _TX005ExceptionSwallowing:
|
|
|
550
558
|
return []
|
|
551
559
|
|
|
552
560
|
findings: list[SpringFinding] = []
|
|
553
|
-
|
|
554
|
-
|
|
561
|
+
# A1-RESIDUAL: iterate all_declared so a write overload that swallows is not
|
|
562
|
+
# hidden by a read_only overload of the same FQN winning the by_symbol slot
|
|
563
|
+
# (the read_only gate below would skip the whole FQN). Dedupe by finding id.
|
|
564
|
+
_seen_ids: set[str] = set()
|
|
565
|
+
for boundary in tx_index.all_declared:
|
|
555
566
|
if boundary.scope != "method":
|
|
556
567
|
continue
|
|
557
568
|
if not boundary.source_file:
|
|
@@ -573,9 +584,13 @@ class _TX005ExceptionSwallowing:
|
|
|
573
584
|
if not self._has_swallowed_exception(source, boundary.symbol):
|
|
574
585
|
continue
|
|
575
586
|
|
|
587
|
+
_fid = SpringFinding.make_id(self.pattern_id, boundary.symbol)
|
|
588
|
+
if _fid in _seen_ids:
|
|
589
|
+
continue
|
|
590
|
+
_seen_ids.add(_fid)
|
|
576
591
|
simple_name = boundary.symbol.rsplit(".", 1)[-1].replace("#", ".")
|
|
577
592
|
findings.append(SpringFinding(
|
|
578
|
-
id=
|
|
593
|
+
id=_fid,
|
|
579
594
|
pattern_id=self.pattern_id,
|
|
580
595
|
category="tx",
|
|
581
596
|
severity=self.severity,
|
sourcecode/telemetry/consent.py
CHANGED
|
@@ -26,7 +26,7 @@ _NOTICE = """\
|
|
|
26
26
|
tokens, environment variables, or any repository content.
|
|
27
27
|
|
|
28
28
|
Disable at any time:
|
|
29
|
-
|
|
29
|
+
ask telemetry disable
|
|
30
30
|
export SOURCECODE_TELEMETRY=0 (or DO_NOT_TRACK=1)
|
|
31
31
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
|
|
32
32
|
"""
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""validation_inference.py — Knowledge-Layer validation-pattern inference (P1-C).
|
|
2
|
+
|
|
3
|
+
Answers the field-audit failure "the report goes quiet or says 0 validated" by
|
|
4
|
+
classifying a repo's request-body validation *pattern* instead of counting zeros
|
|
5
|
+
(DESIGN-robustness-security-inference.md §2 / §5). The true finding on the field
|
|
6
|
+
repo was **909 `@RequestBody` and 0 `@Valid`** — "a bean-validation framework is
|
|
7
|
+
on the classpath but unused", not "nothing to report".
|
|
8
|
+
|
|
9
|
+
This is the Knowledge Layer: it reads facts that already exist — endpoint verbs,
|
|
10
|
+
`@Valid`/`@Validated` markers captured on handler nodes, and
|
|
11
|
+
`jakarta/javax.validation` import edges — and emits an Evidence-Confidence
|
|
12
|
+
`Verdict` per the shared §0 envelope. It adds no IR atom.
|
|
13
|
+
|
|
14
|
+
VAI note (§0.5): the only names this module keys on are **published open
|
|
15
|
+
standards** (`jakarta.validation`, `javax.validation`, the bean-validation
|
|
16
|
+
annotation vocabulary). No client / convention name enters a predicate.
|
|
17
|
+
|
|
18
|
+
ARCHITECTURAL DIVERGENCE — manual_validation (design §2, deferred)
|
|
19
|
+
-----------------------------------------------------------------
|
|
20
|
+
Design §2 also asks for a `manual_validation` class detected by the **β
|
|
21
|
+
GuardClause** shape on handler bodies (a guard that throws on a bad parameter).
|
|
22
|
+
That path assumes guard atoms cover handlers. They do NOT: `_extract_guard_facts`
|
|
23
|
+
(repository_ir.py) scans only `symbol_kind in {"method", "constructor"}`, and a
|
|
24
|
+
mapped handler surfaces as kind `"endpoint"` — so `guard_facts` is empty for
|
|
25
|
+
every handler body. Design §8 classifies §2 as *Incremental / no new primitive*;
|
|
26
|
+
against the current architecture that is false. Rather than smuggle in an IR
|
|
27
|
+
change under an "Incremental" increment, `manual_validation` is deferred and the
|
|
28
|
+
conflict is documented in the design doc (EPV / INV-2: build the endpoint-scoped
|
|
29
|
+
guard capability only when this consumer is ratified). The three classes that ARE
|
|
30
|
+
reuse-only (active / available-unused / none) ship here and already resolve the
|
|
31
|
+
field defect.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
from sourcecode.security_posture import (
|
|
39
|
+
Confidence,
|
|
40
|
+
Evidence,
|
|
41
|
+
Verdict,
|
|
42
|
+
_annotation_simple,
|
|
43
|
+
_graph_nodes,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Published bean-validation import namespaces — DATA, subtract/match only (§0.5).
|
|
51
|
+
_VALIDATION_NAMESPACES: tuple[str, ...] = (
|
|
52
|
+
"jakarta.validation", "javax.validation",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Published bean-validation constraint / marker simple-names (open standard).
|
|
56
|
+
_BEAN_VALIDATION_VOCAB: frozenset[str] = frozenset({
|
|
57
|
+
"Valid", "Validated", "NotNull", "NotEmpty", "NotBlank", "Null", "AssertTrue",
|
|
58
|
+
"AssertFalse", "Min", "Max", "DecimalMin", "DecimalMax", "Digits", "Positive",
|
|
59
|
+
"PositiveOrZero", "Negative", "NegativeOrZero", "Size", "Pattern", "Email",
|
|
60
|
+
"Past", "PastOrPresent", "Future", "FutureOrPresent",
|
|
61
|
+
})
|
|
62
|
+
_VALIDATE_MARKERS: frozenset[str] = frozenset({"Valid", "Validated"})
|
|
63
|
+
|
|
64
|
+
# Body-carrying HTTP verbs — the surface where request-body validation applies.
|
|
65
|
+
_BODY_VERBS: frozenset[str] = frozenset({"POST", "PUT", "PATCH"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validation_available(cir: "CanonicalRepositoryIR") -> tuple[bool, list[str]]:
|
|
69
|
+
"""True when a published bean-validation framework is on the classpath,
|
|
70
|
+
proven structurally by an import edge into a validation namespace (or, as a
|
|
71
|
+
fallback, any bean-validation annotation present anywhere). Returns
|
|
72
|
+
(available, evidence_imports)."""
|
|
73
|
+
imports_seen: list[str] = []
|
|
74
|
+
for edge in getattr(cir, "dependencies", None) or []:
|
|
75
|
+
if not isinstance(edge, dict) or edge.get("type") != "imports":
|
|
76
|
+
continue
|
|
77
|
+
target = str(edge.get("to") or "")
|
|
78
|
+
if any(target.startswith(ns + ".") or target == ns for ns in _VALIDATION_NAMESPACES):
|
|
79
|
+
imports_seen.append(target)
|
|
80
|
+
if imports_seen:
|
|
81
|
+
return True, sorted(set(imports_seen))
|
|
82
|
+
|
|
83
|
+
# Fallback: a bean-validation annotation used anywhere also proves the
|
|
84
|
+
# framework is on the classpath (even without a visible import edge).
|
|
85
|
+
for node in _graph_nodes(cir):
|
|
86
|
+
if not isinstance(node, dict):
|
|
87
|
+
continue
|
|
88
|
+
for tok in node.get("annotations") or []:
|
|
89
|
+
if _annotation_simple(str(tok)) in _BEAN_VALIDATION_VOCAB:
|
|
90
|
+
return True, []
|
|
91
|
+
return False, []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validated_handler_symbols(cir: "CanonicalRepositoryIR") -> set[str]:
|
|
95
|
+
"""handler_symbols whose node carries a captured `_validated_params` value
|
|
96
|
+
naming a @Valid/@Validated parameter (reuses the existing Phase-3.2 capture)."""
|
|
97
|
+
out: set[str] = set()
|
|
98
|
+
for node in _graph_nodes(cir):
|
|
99
|
+
if not isinstance(node, dict):
|
|
100
|
+
continue
|
|
101
|
+
av = node.get("annotation_values") or {}
|
|
102
|
+
params = av.get("_validated_params", "") if isinstance(av, dict) else ""
|
|
103
|
+
if not params:
|
|
104
|
+
continue
|
|
105
|
+
# the capture only fires when a marker is present, but re-check by shape
|
|
106
|
+
if "@Valid" in params or "@Validated" in params:
|
|
107
|
+
out.add(node.get("fqn") or "")
|
|
108
|
+
out.discard("")
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ValidationInferenceResult:
|
|
114
|
+
"""Repo + per-endpoint request-body validation-pattern projection (P1-C)."""
|
|
115
|
+
|
|
116
|
+
pattern: str # repo-level class (see classify)
|
|
117
|
+
verdict: Verdict
|
|
118
|
+
endpoint_findings: list[dict] = field(default_factory=list)
|
|
119
|
+
rollup: dict = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict:
|
|
122
|
+
return {
|
|
123
|
+
"provenance": "validation_inference projection (P1-C)",
|
|
124
|
+
"pattern": self.pattern,
|
|
125
|
+
"verdict": self.verdict.to_dict(),
|
|
126
|
+
"endpoints": self.endpoint_findings,
|
|
127
|
+
"rollup": self.rollup,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenceResult:
|
|
132
|
+
"""Classify the repo's request-body validation pattern (P1-C).
|
|
133
|
+
|
|
134
|
+
Repo-level class (DESIGN §2):
|
|
135
|
+
- ``bean_validation_active`` — @Valid present on body handlers.
|
|
136
|
+
- ``bean_validation_available_unused`` — validation on the classpath and
|
|
137
|
+
body endpoints exist, but no handler validates → risk (Likely).
|
|
138
|
+
- ``no_validation_detected`` — none of the above (Unknown).
|
|
139
|
+
(``manual_validation`` deferred — see module docstring.)
|
|
140
|
+
|
|
141
|
+
Never raises.
|
|
142
|
+
"""
|
|
143
|
+
available, import_evidence = _validation_available(cir)
|
|
144
|
+
validated_handlers = _validated_handler_symbols(cir)
|
|
145
|
+
|
|
146
|
+
body_eps = [ep for ep in cir.endpoints if str(ep.method).upper() in _BODY_VERBS]
|
|
147
|
+
body_total = len(body_eps)
|
|
148
|
+
validated_body = sum(1 for ep in body_eps if ep.handler_symbol in validated_handlers)
|
|
149
|
+
unvalidated_body = body_total - validated_body
|
|
150
|
+
|
|
151
|
+
# per-endpoint findings (only the body surface — where a request body applies)
|
|
152
|
+
endpoint_findings: list[dict] = []
|
|
153
|
+
rollup = {"validated": 0, "available_unused": 0, "unvalidated_unknown": 0}
|
|
154
|
+
for ep in body_eps:
|
|
155
|
+
if ep.handler_symbol in validated_handlers:
|
|
156
|
+
klass, conf = "validated", Confidence.VERIFIED
|
|
157
|
+
elif available:
|
|
158
|
+
# @RequestBody-shaped verb, framework available, no @Valid → gap
|
|
159
|
+
klass, conf = "available_unused", Confidence.LIKELY
|
|
160
|
+
else:
|
|
161
|
+
klass, conf = "unvalidated_unknown", Confidence.UNKNOWN
|
|
162
|
+
rollup[klass] += 1
|
|
163
|
+
endpoint_findings.append({
|
|
164
|
+
"endpoint_id": ep.id,
|
|
165
|
+
"method": ep.method,
|
|
166
|
+
"path": ep.path,
|
|
167
|
+
"handler_symbol": ep.handler_symbol,
|
|
168
|
+
"finding": klass,
|
|
169
|
+
"confidence": conf.value,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
# repo-level classification + envelope
|
|
173
|
+
common_limitation = (
|
|
174
|
+
"Body surface is approximated by body-carrying verbs (POST/PUT/PATCH); "
|
|
175
|
+
"the exact @RequestBody parameter count is not captured (no IR atom "
|
|
176
|
+
"exposes it), so 'available_unused' may include body-less handlers."
|
|
177
|
+
)
|
|
178
|
+
manual_fn = (
|
|
179
|
+
"manual_validation (a handler that throws on a bad parameter) is not "
|
|
180
|
+
"detected — guard-clause atoms do not cover endpoint-kind handlers "
|
|
181
|
+
"(architectural defer, see module docstring); such a repo classifies "
|
|
182
|
+
"no_validation_detected here."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if validated_body > 0:
|
|
186
|
+
pattern = "bean_validation_active"
|
|
187
|
+
verdict = Verdict(
|
|
188
|
+
claim="Bean validation active on request bodies",
|
|
189
|
+
confidence=Confidence.VERIFIED,
|
|
190
|
+
evidence=Evidence(
|
|
191
|
+
atoms_used=[f"@Valid handler: {h}" for h in sorted(validated_handlers)][:50],
|
|
192
|
+
details={
|
|
193
|
+
"validated_body_endpoints": validated_body,
|
|
194
|
+
"body_endpoints": body_total,
|
|
195
|
+
"unvalidated_body_endpoints": unvalidated_body,
|
|
196
|
+
"validation_available": available,
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
limitations=[common_limitation],
|
|
200
|
+
fn_causes=[manual_fn],
|
|
201
|
+
)
|
|
202
|
+
elif available and body_total > 0:
|
|
203
|
+
pattern = "bean_validation_available_unused"
|
|
204
|
+
verdict = Verdict(
|
|
205
|
+
claim="Validation framework present but unused on request bodies",
|
|
206
|
+
confidence=Confidence.LIKELY,
|
|
207
|
+
evidence=Evidence(
|
|
208
|
+
relations_used=[f"imports {i}" for i in import_evidence][:20],
|
|
209
|
+
details={
|
|
210
|
+
"body_endpoints": body_total,
|
|
211
|
+
"validated_body_endpoints": 0,
|
|
212
|
+
"validation_available": True,
|
|
213
|
+
"validation_imports": import_evidence[:20],
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
limitations=[
|
|
217
|
+
common_limitation,
|
|
218
|
+
"Validation could live in a shared interceptor / filter this "
|
|
219
|
+
"projection does not traverse.",
|
|
220
|
+
],
|
|
221
|
+
fp_causes=[
|
|
222
|
+
"A body endpoint may be intentionally unvalidated (idempotent "
|
|
223
|
+
"echo, internal-only), or validated manually.",
|
|
224
|
+
],
|
|
225
|
+
fn_causes=[manual_fn],
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
pattern = "no_validation_detected"
|
|
229
|
+
verdict = Verdict(
|
|
230
|
+
claim="No request-body validation pattern detected",
|
|
231
|
+
confidence=Confidence.UNKNOWN,
|
|
232
|
+
evidence=Evidence(details={
|
|
233
|
+
"body_endpoints": body_total,
|
|
234
|
+
"validation_available": available,
|
|
235
|
+
}),
|
|
236
|
+
limitations=[
|
|
237
|
+
"Absence of evidence is not evidence of absence — no @Valid, no "
|
|
238
|
+
"validation import, and no body endpoints, or validation is done "
|
|
239
|
+
"by a mechanism this projection does not model.",
|
|
240
|
+
],
|
|
241
|
+
fn_causes=[manual_fn],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return ValidationInferenceResult(
|
|
245
|
+
pattern=pattern,
|
|
246
|
+
verdict=verdict,
|
|
247
|
+
endpoint_findings=endpoint_findings,
|
|
248
|
+
rollup=rollup,
|
|
249
|
+
)
|