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.
@@ -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()
@@ -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.by_symbol.values() if b.scope == "method")
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.by_symbol.values():
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.by_symbol),
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.by_symbol.values() if b.read_only),
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
@@ -194,8 +194,12 @@ class _TX001ProxyBypass:
194
194
  model: Optional[SpringSemanticModel] = None,
195
195
  ) -> list[SpringFinding]:
196
196
  findings: list[SpringFinding] = []
197
-
198
- for boundary in tx_index.all_boundaries():
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=SpringFinding.make_id(self.pattern_id, boundary.symbol),
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
- for boundary in tx_index.all_boundaries():
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=SpringFinding.make_id(self.pattern_id, boundary.symbol),
593
+ id=_fid,
579
594
  pattern_id=self.pattern_id,
580
595
  category="tx",
581
596
  severity=self.severity,
@@ -26,7 +26,7 @@ _NOTICE = """\
26
26
  tokens, environment variables, or any repository content.
27
27
 
28
28
  Disable at any time:
29
- sourcecode telemetry disable
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
+ )